297 lines
8.8 KiB
Python
297 lines
8.8 KiB
Python
"""Adapter for ffmpeg-related operations.
|
|
|
|
Provides a small OO wrapper around common ffmpeg workflows used by the
|
|
project. Methods delegate to the infra implementation where appropriate
|
|
or run the ffmpeg commands directly for small utilities.
|
|
"""
|
|
import subprocess
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from typing import Iterable, List, Optional
|
|
|
|
|
|
def ensure_ffmpeg_available() -> bool:
|
|
"""Simple check to ensure ffmpeg/ffprobe are present in PATH.
|
|
|
|
Returns True if both are available, otherwise raises RuntimeError.
|
|
"""
|
|
for cmd in ("ffmpeg", "ffprobe"):
|
|
try:
|
|
subprocess.run([cmd, "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
|
except Exception:
|
|
raise RuntimeError(f"Required binary not found in PATH: {cmd}")
|
|
return True
|
|
|
|
|
|
__all__ = ["FFmpegAudioProcessor", "ensure_ffmpeg_available"]
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from typing import Iterable, List, Optional
|
|
|
|
|
|
def ensure_ffmpeg_available() -> None:
|
|
if shutil.which("ffmpeg") is None:
|
|
raise RuntimeError("ffmpeg no está disponible en PATH")
|
|
|
|
|
|
def _run(cmd: List[str], hide_output: bool = False) -> None:
|
|
if hide_output:
|
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
else:
|
|
subprocess.run(cmd, check=True)
|
|
|
|
|
|
def extract_audio(video_path: str, out_wav: str, sr: int = 16000) -> None:
|
|
"""Extrae la pista de audio de un vídeo y la convierte a WAV PCM mono a sr hz."""
|
|
ensure_ffmpeg_available()
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
video_path,
|
|
"-vn",
|
|
"-acodec",
|
|
"pcm_s16le",
|
|
"-ar",
|
|
str(sr),
|
|
"-ac",
|
|
"1",
|
|
out_wav,
|
|
]
|
|
_run(cmd)
|
|
|
|
|
|
def replace_audio_in_video(video_path: str, audio_path: str, out_video: str) -> None:
|
|
"""Reemplaza la pista de audio del vídeo por audio_path (codifica a AAC)."""
|
|
ensure_ffmpeg_available()
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
video_path,
|
|
"-i",
|
|
audio_path,
|
|
"-map",
|
|
"0:v:0",
|
|
"-map",
|
|
"1:a:0",
|
|
"-c:v",
|
|
"copy",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"192k",
|
|
out_video,
|
|
]
|
|
_run(cmd)
|
|
|
|
|
|
def burn_subtitles(video_path: str, srt_path: str, out_video: str, font: Optional[str] = "Arial", size: int = 24) -> None:
|
|
"""Quema subtítulos en el vídeo usando el filtro subtitles de ffmpeg.
|
|
|
|
Nota: el path al .srt debe ser accesible y no contener caracteres problemáticos.
|
|
"""
|
|
ensure_ffmpeg_available()
|
|
# usar filter_complex cuando el path contiene caracteres especiales puede complicar,
|
|
# pero normalmente subtitles=path funciona si el path es abosluto
|
|
abs_srt = os.path.abspath(srt_path)
|
|
vf = f"subtitles={abs_srt}:force_style='FontName={font},FontSize={size}'"
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
video_path,
|
|
"-vf",
|
|
vf,
|
|
"-c:a",
|
|
"copy",
|
|
out_video,
|
|
]
|
|
_run(cmd)
|
|
|
|
|
|
def save_bytes_as_wav(raw_bytes: bytes, target_path: str, sr: int = 22050) -> None:
|
|
"""Guarda bytes recibidos de un servicio TTS en un WAV válido usando ffmpeg.
|
|
|
|
Escribe bytes a un archivo temporal y usa ffmpeg para convertir al formato objetivo.
|
|
"""
|
|
ensure_ffmpeg_available()
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".bin") as tmp:
|
|
tmp.write(raw_bytes)
|
|
tmp.flush()
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
tmp_path,
|
|
"-ar",
|
|
str(sr),
|
|
"-ac",
|
|
"1",
|
|
"-sample_fmt",
|
|
"s16",
|
|
target_path,
|
|
]
|
|
_run(cmd, hide_output=True)
|
|
except subprocess.CalledProcessError:
|
|
# fallback: escribir bytes crudos
|
|
with open(target_path, "wb") as out:
|
|
out.write(raw_bytes)
|
|
finally:
|
|
try:
|
|
os.remove(tmp_path)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def create_silence(duration: float, out_path: str, sr: int = 22050) -> None:
|
|
"""Crea un WAV silencioso de duración (segundos) usando anullsrc."""
|
|
ensure_ffmpeg_available()
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
f"anullsrc=channel_layout=mono:sample_rate={sr}",
|
|
"-t",
|
|
f"{duration}",
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
out_path,
|
|
]
|
|
try:
|
|
_run(cmd, hide_output=True)
|
|
except subprocess.CalledProcessError:
|
|
# fallback: crear archivo pequeño de ceros
|
|
with open(out_path, "wb") as fh:
|
|
fh.write(b"\x00" * 1024)
|
|
|
|
|
|
def pad_or_trim_wav(in_path: str, out_path: str, target_duration: float, sr: int = 22050) -> None:
|
|
"""Rellena con silencio o recorta para que el WAV tenga target_duration en segundos."""
|
|
ensure_ffmpeg_available()
|
|
# obtener duración con ffprobe
|
|
try:
|
|
p = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
"format=duration",
|
|
"-of",
|
|
"default=noprint_wrappers=1:nokey=1",
|
|
in_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
cur = float(p.stdout.strip())
|
|
except Exception:
|
|
cur = 0.0
|
|
|
|
if cur == 0.0:
|
|
shutil.copy(in_path, out_path)
|
|
return
|
|
|
|
if abs(cur - target_duration) < 0.02:
|
|
shutil.copy(in_path, out_path)
|
|
return
|
|
|
|
if cur > target_duration:
|
|
cmd = ["ffmpeg", "-y", "-i", in_path, "-t", f"{target_duration}", out_path]
|
|
_run(cmd, hide_output=True)
|
|
return
|
|
|
|
# pad: crear silencio y concatenar
|
|
pad = target_duration - cur
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as sil:
|
|
sil_path = sil.name
|
|
listname = None
|
|
try:
|
|
create_silence(pad, sil_path, sr=sr)
|
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
|
listf.write(f"file '{os.path.abspath(in_path)}'\n")
|
|
listf.write(f"file '{os.path.abspath(sil_path)}'\n")
|
|
listname = listf.name
|
|
cmd2 = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
|
_run(cmd2, hide_output=True)
|
|
finally:
|
|
try:
|
|
os.remove(sil_path)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if listname:
|
|
os.remove(listname)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def concat_wavs(chunks: Iterable[str], out_path: str) -> None:
|
|
"""Concatena una lista de WAVs en out_path usando el demuxer concat (sin recodificar)."""
|
|
ensure_ffmpeg_available()
|
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as listf:
|
|
for c in chunks:
|
|
listf.write(f"file '{os.path.abspath(c)}'\n")
|
|
listname = listf.name
|
|
|
|
try:
|
|
cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", listname, "-c", "copy", out_path]
|
|
_run(cmd)
|
|
except subprocess.CalledProcessError:
|
|
# fallback: reconvertir por entrada concat
|
|
tmp_concat = out_path + ".tmp.wav"
|
|
cmd2 = ["ffmpeg", "-y", "-i", f"concat:{'|'.join(chunks)}", "-c", "copy", tmp_concat]
|
|
_run(cmd2)
|
|
shutil.move(tmp_concat, out_path)
|
|
finally:
|
|
try:
|
|
os.remove(listname)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class FFmpegAudioProcessor:
|
|
"""Adaptador de audio que expone utilidades necesarias por el orquestador.
|
|
|
|
Métodos principales:
|
|
- extract_audio
|
|
- replace_audio_in_video
|
|
- burn_subtitles
|
|
- save_bytes_as_wav
|
|
- create_silence
|
|
- pad_or_trim_wav
|
|
- concat_wavs
|
|
"""
|
|
|
|
def extract_audio(self, video_path: str, out_wav: str, sr: int = 16000) -> None:
|
|
return extract_audio(video_path, out_wav, sr=sr)
|
|
|
|
def replace_audio_in_video(self, video_path: str, audio_path: str, out_video: str) -> None:
|
|
return replace_audio_in_video(video_path, audio_path, out_video)
|
|
|
|
def burn_subtitles(self, video_path: str, srt_path: str, out_video: str, font: Optional[str] = "Arial", size: int = 24) -> None:
|
|
return burn_subtitles(video_path, srt_path, out_video, font=font, size=size)
|
|
|
|
def save_bytes_as_wav(self, raw_bytes: bytes, target_path: str, sr: int = 22050) -> None:
|
|
return save_bytes_as_wav(raw_bytes, target_path, sr=sr)
|
|
|
|
def create_silence(self, duration: float, out_path: str, sr: int = 22050) -> None:
|
|
return create_silence(duration, out_path, sr=sr)
|
|
|
|
def pad_or_trim_wav(self, in_path: str, out_path: str, target_duration: float, sr: int = 22050) -> None:
|
|
return pad_or_trim_wav(in_path, out_path, target_duration, sr=sr)
|
|
|
|
def concat_wavs(self, chunks: Iterable[str], out_path: str) -> None:
|
|
return concat_wavs(chunks, out_path)
|
|
|