""" Thin wrapper around ffmpeg-python for building and running ffmpeg pipelines. All ffmpeg command construction goes through this module so manager.py and other consumers never build raw CLI arg lists. Uses ffmpeg-python's own run/run_async for subprocess management. """ import logging import os import signal import subprocess from pathlib import Path import ffmpeg log = logging.getLogger(__name__) GLOBAL_ARGS = ("-hide_banner", "-loglevel", "warning") def receive_to_pipe(stream_url, segment_dir=None, segment_duration=60): """Receive mpegts stream and pipe to stdout for mpv. If segment_dir is provided, also saves segments to disk. Uses pipe (not fifo) so OS kernel buffers prevent blocking. """ stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay") out_pipe = ffmpeg.output(stream, "pipe:", c="copy", f="mpegts") if segment_dir: out_segments = ffmpeg.output( stream, str(segment_dir / "segment_%04d.ts"), c="copy", f="segment", segment_time=segment_duration, reset_timestamps=1, ) return ffmpeg.merge_outputs(out_pipe, out_segments).global_args(*GLOBAL_ARGS) return out_pipe.global_args(*GLOBAL_ARGS) def receive_and_segment(stream_url, segment_dir, segment_duration=60): """Receive mpegts stream and save as segmented .ts files.""" stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay") return ( ffmpeg.output( stream, str(segment_dir / "segment_%04d.ts"), c="copy", f="segment", segment_time=segment_duration, reset_timestamps=1, ) .global_args(*GLOBAL_ARGS) ) def receive_and_segment_with_monitor(stream_url, segment_dir, fifo_path, segment_duration=60): """Receive stream, save segments AND tee to a named pipe for monitoring.""" if not fifo_path.exists(): os.mkfifo(str(fifo_path)) stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay") out_segments = ffmpeg.output( stream, str(segment_dir / "segment_%04d.ts"), c="copy", f="segment", segment_time=segment_duration, reset_timestamps=1, ) out_monitor = ffmpeg.output( stream, str(fifo_path), c="copy", f="mpegts", ) return ffmpeg.merge_outputs(out_segments, out_monitor).global_args(*GLOBAL_ARGS) def extract_scene_frames(input_path, output_dir, scene_threshold=0.3, max_interval=30, start_number=1): """Extract frames from a file on scene change. Uses ffmpeg select filter with scene detection and a max-interval fallback. Returns (stdout bytes, stderr bytes) for timestamp parsing. """ select_expr = ( f"gt(scene,{scene_threshold})" f"+gte(t-prev_selected_t,{max_interval})" ) stream = ffmpeg.input(str(input_path)) stream = stream.filter("select", select_expr).filter("showinfo") output = ( ffmpeg.output( stream, str(output_dir / "F%04d.jpg"), vsync="vfr", **{"q:v": "2"}, start_number=start_number, ) .global_args(*GLOBAL_ARGS) ) log.info("extract_scene_frames: %s", " ".join(output.compile())) stdout, stderr = output.run(capture_stdout=True, capture_stderr=True) return stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") def extract_audio_pcm(input_path): """Extract audio as 16kHz mono PCM wav, returning an output node for piping.""" stream = ffmpeg.input(str(input_path)) return ( ffmpeg.output( stream.audio, "pipe:", vn=None, acodec="pcm_s16le", ar=16000, ac=1, f="wav", ) .global_args(*GLOBAL_ARGS) ) def run_async(output_node, pipe_stdout=False, pipe_stderr=False): """Start an ffmpeg pipeline asynchronously via ffmpeg-python's run_async.""" log.info("run_async: %s", " ".join(output_node.compile())) return output_node.run_async( pipe_stdout=pipe_stdout, pipe_stderr=pipe_stderr, ) def stop_proc(proc, timeout=5): """Gracefully stop an ffmpeg subprocess.""" if proc and proc.poll() is None: proc.send_signal(signal.SIGINT) try: proc.wait(timeout=timeout) except subprocess.TimeoutExpired: proc.kill()