"""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 import logging from typing import Iterable, List, Optional logger = logging.getLogger(__name__) 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: logger.debug("Ejecutando comando: %s", " ".join(cmd)) 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, ] logger.info("Extraer audio: %s -> %s (sr=%s)", video_path, out_wav, sr) _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, ] logger.info("Replace audio en video: %s + %s -> %s", video_path, audio_path, 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, ] logger.info("Burn subtitles: %s + %s -> %s", video_path, srt_path, 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 logger.debug("Convertir bytes a wav -> %s (sr=%s)", target_path, sr) 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 logger.exception("ffmpeg falló al convertir bytes a wav; escribiendo crudo") 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: logger.exception("No se pudo crear silencio con ffmpeg; creando archivo 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: logger.debug("Duración desconocida; copiando %s -> %s", in_path, out_path) shutil.copy(in_path, out_path) return if abs(cur - target_duration) < 0.02: logger.debug("Duración ya cercana al target; copiando") 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)