init commit

This commit is contained in:
2026-04-01 13:53:09 -03:00
commit 453601c072
22 changed files with 1525 additions and 0 deletions

148
cht/stream/ffmpeg.py Normal file
View File

@@ -0,0 +1,148 @@
"""
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.
"""
import logging
import os
import signal
import subprocess
from pathlib import Path
import ffmpeg
log = logging.getLogger(__name__)
def receive_and_segment(stream_url, segment_dir, segment_duration=60):
"""Receive mpegts stream and save as segmented .ts files.
Returns an ffmpeg-python output node (not yet running).
"""
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,
)
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.
Returns an ffmpeg-python merged output node.
"""
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)
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 (process_result, stderr) 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,
)
return run_sync(output, timeout=120)
def extract_audio_pcm(input_path):
"""Extract audio as 16kHz mono PCM wav, returning an output node for piping.
Use run_async with pipe_stdout=True to stream PCM data.
"""
stream = ffmpeg.input(str(input_path))
return ffmpeg.output(
stream.audio,
"pipe:",
vn=None,
acodec="pcm_s16le",
ar=16000,
ac=1,
f="wav",
)
def run_async(output_node, pipe_stdout=False, pipe_stderr=False):
"""Start an ffmpeg pipeline asynchronously. Returns subprocess.Popen."""
cmd = compile_cmd(output_node)
log.info("run_async: %s", " ".join(str(c) for c in cmd))
return subprocess.Popen(
cmd,
stdout=subprocess.PIPE if pipe_stdout else subprocess.DEVNULL,
stderr=subprocess.PIPE if pipe_stderr else subprocess.DEVNULL,
)
def run_sync(output_node, timeout=None):
"""Run an ffmpeg pipeline synchronously. Returns (stdout, stderr) as strings."""
cmd = compile_cmd(output_node)
log.info("run_sync: %s", " ".join(str(c) for c in cmd))
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
return result.stdout, result.stderr
def compile_cmd(output_node):
"""Compile an ffmpeg-python node to a command list, adding global flags."""
cmd = output_node.compile()
# Insert global flags after 'ffmpeg'
idx = 1
for flag in ["-hide_banner", "-loglevel", "warning"]:
cmd.insert(idx, flag)
idx += 1
return cmd
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()