embeded stream opengl

This commit is contained in:
2026-04-01 15:16:09 -03:00
parent 453601c072
commit bdc5705022
8 changed files with 407 additions and 328 deletions

View File

@@ -3,6 +3,8 @@ Thin wrapper around ffmpeg-python for building and running ffmpeg pipelines.
All ffmpeg command construction goes through this module so manager.py All ffmpeg command construction goes through this module so manager.py
and other consumers never build raw CLI arg lists. and other consumers never build raw CLI arg lists.
Uses ffmpeg-python's own run/run_async for subprocess management.
""" """
import logging import logging
@@ -15,14 +17,21 @@ import ffmpeg
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
GLOBAL_ARGS = ("-hide_banner", "-loglevel", "warning")
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). 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") stream = ffmpeg.input(stream_url, fflags="nobuffer", flags="low_delay")
return ffmpeg.output(
out_pipe = ffmpeg.output(stream, "pipe:", c="copy", f="mpegts")
if segment_dir:
out_segments = ffmpeg.output(
stream, stream,
str(segment_dir / "segment_%04d.ts"), str(segment_dir / "segment_%04d.ts"),
c="copy", c="copy",
@@ -30,13 +39,29 @@ def receive_and_segment(stream_url, segment_dir, segment_duration=60):
segment_time=segment_duration, segment_time=segment_duration,
reset_timestamps=1, 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): 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. """Receive stream, save segments AND tee to a named pipe for monitoring."""
Returns an ffmpeg-python merged output node.
"""
if not fifo_path.exists(): if not fifo_path.exists():
os.mkfifo(str(fifo_path)) os.mkfifo(str(fifo_path))
@@ -58,7 +83,7 @@ def receive_and_segment_with_monitor(stream_url, segment_dir, fifo_path, segment
f="mpegts", f="mpegts",
) )
return ffmpeg.merge_outputs(out_segments, out_monitor) return ffmpeg.merge_outputs(out_segments, out_monitor).global_args(*GLOBAL_ARGS)
def extract_scene_frames(input_path, output_dir, scene_threshold=0.3, def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
@@ -66,33 +91,36 @@ def extract_scene_frames(input_path, output_dir, scene_threshold=0.3,
"""Extract frames from a file on scene change. """Extract frames from a file on scene change.
Uses ffmpeg select filter with scene detection and a max-interval fallback. Uses ffmpeg select filter with scene detection and a max-interval fallback.
Returns (process_result, stderr) for timestamp parsing. Returns (stdout bytes, stderr bytes) for timestamp parsing.
""" """
select_expr = ( select_expr = (
f"gt(scene\\,{scene_threshold})" f"gt(scene,{scene_threshold})"
f"+gte(t-prev_selected_t\\,{max_interval})" f"+gte(t-prev_selected_t,{max_interval})"
) )
stream = ffmpeg.input(str(input_path)) stream = ffmpeg.input(str(input_path))
stream = stream.filter("select", select_expr).filter("showinfo") stream = stream.filter("select", select_expr).filter("showinfo")
output = ffmpeg.output( output = (
ffmpeg.output(
stream, stream,
str(output_dir / "F%04d.jpg"), str(output_dir / "F%04d.jpg"),
vsync="vfr", vsync="vfr",
**{"q:v": "2"}, **{"q:v": "2"},
start_number=start_number, start_number=start_number,
) )
.global_args(*GLOBAL_ARGS)
)
return run_sync(output, timeout=120) 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): def extract_audio_pcm(input_path):
"""Extract audio as 16kHz mono PCM wav, returning an output node for piping. """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)) stream = ffmpeg.input(str(input_path))
return ffmpeg.output( return (
ffmpeg.output(
stream.audio, stream.audio,
"pipe:", "pipe:",
vn=None, vn=None,
@@ -101,43 +129,19 @@ def extract_audio_pcm(input_path):
ac=1, ac=1,
f="wav", f="wav",
) )
.global_args(*GLOBAL_ARGS)
)
def run_async(output_node, pipe_stdout=False, pipe_stderr=False): def run_async(output_node, pipe_stdout=False, pipe_stderr=False):
"""Start an ffmpeg pipeline asynchronously. Returns subprocess.Popen.""" """Start an ffmpeg pipeline asynchronously via ffmpeg-python's run_async."""
cmd = compile_cmd(output_node) log.info("run_async: %s", " ".join(output_node.compile()))
log.info("run_async: %s", " ".join(str(c) for c in cmd)) return output_node.run_async(
return subprocess.Popen( pipe_stdout=pipe_stdout,
cmd, pipe_stderr=pipe_stderr,
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): def stop_proc(proc, timeout=5):
"""Gracefully stop an ffmpeg subprocess.""" """Gracefully stop an ffmpeg subprocess."""
if proc and proc.poll() is None: if proc and proc.poll() is None:

View File

@@ -75,6 +75,20 @@ class StreamManager:
log.info("Recorder started: pid=%s url=%s", proc.pid, self.stream_url) log.info("Recorder started: pid=%s url=%s", proc.pid, self.stream_url)
self._start_stderr_reader("recorder", proc) self._start_stderr_reader("recorder", proc)
def start_receiver_pipe(self):
"""Receive stream, pipe stdout to mpv, save segments to disk."""
self.setup_dirs()
node = ff.receive_to_pipe(
self.stream_url,
segment_dir=self.stream_dir,
segment_duration=SEGMENT_DURATION,
)
proc = ff.run_async(node, pipe_stdout=True, pipe_stderr=True)
self._procs["receiver"] = proc
log.info("Receiver started: pid=%s url=%s (pipe + segments)", proc.pid, self.stream_url)
self._start_stderr_reader("receiver", proc)
return proc
def start_recorder_with_monitor(self): def start_recorder_with_monitor(self):
self.setup_dirs() self.setup_dirs()
fifo_path = self.session_dir / "monitor.pipe" fifo_path = self.session_dir / "monitor.pipe"
@@ -104,6 +118,30 @@ class StreamManager:
t = Thread(target=_read, daemon=True, name=f"{name}_stderr") t = Thread(target=_read, daemon=True, name=f"{name}_stderr")
t.start() t.start()
def start_frame_extractor_on_recording(self, recording_path):
"""Extract frames periodically from a growing recording file."""
log.info("Starting frame extractor on recording: %s", recording_path)
self._recording_path = recording_path
self._start_recording_frame_watcher()
def _start_recording_frame_watcher(self):
def _watch():
last_size = 0
log.info("Recording frame watcher running, watching %s", self._recording_path)
while "stop" not in self._stop_flags:
if self._recording_path.exists():
size = self._recording_path.stat().st_size
if size > last_size and size > 100_000: # wait for some data
log.info("Recording grew: %d -> %d bytes, extracting frames", last_size, size)
last_size = size
self._extract_frames_from_file(self._recording_path)
time.sleep(10) # check every 10s
log.info("Recording frame watcher stopped")
t = Thread(target=_watch, daemon=True, name="recording_frame_watcher")
t.start()
self._threads["recording_frame_watcher"] = t
def start_frame_extractor(self): def start_frame_extractor(self):
log.info("Starting frame watcher...") log.info("Starting frame watcher...")
self._start_frame_watcher() self._start_frame_watcher()

View File

@@ -1,159 +1,100 @@
""" """
MonitorWidget: embeds ffplay into the GTK4 window for live stream monitoring. MonitorWidget: mpv-based live stream monitor embedded in GTK4.
On X11, uses the --wid flag of mpv for native embedding, or reparents Uses libmpv's OpenGL render API + Gtk.GLArea for proper embedding.
ffplay's window via xdotool. GTK4 dropped GtkSocket so we use X11-level tricks. No X11 wid hacks — renders directly to a GL texture in the GTK layout.
Works on both X11 and Wayland.
""" """
import logging import logging
import subprocess
import signal
from threading import Timer
import gi import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib, Gdk from gi.repository import Gtk, GLib, Gdk
from cht.ui.mpv import Player
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class MonitorWidget(Gtk.Box): class MonitorWidget(Gtk.Box):
"""Widget that embeds an ffplay/mpv window for live stream monitoring.""" """Widget that embeds mpv video via OpenGL into the GTK4 layout."""
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self._proc = None self._player = None
self._xid = None
self._drawing_area = Gtk.DrawingArea() self._gl_area = Gtk.GLArea()
self._drawing_area.set_hexpand(True) self._gl_area.set_hexpand(True)
self._drawing_area.set_vexpand(True) self._gl_area.set_vexpand(True)
self._drawing_area.set_content_height(250) self._gl_area.set_auto_render(False)
self.append(self._drawing_area) self._gl_area.set_has_depth_buffer(False)
log.info("MonitorWidget initialized") self._gl_area.set_has_stencil_buffer(False)
def start_ffplay(self, input_path): self._gl_area.connect("realize", self._on_realize)
log.info("Starting ffplay with input: %s", input_path) self._gl_area.connect("unrealize", self._on_unrealize)
cmd = [ self._gl_area.connect("render", self._on_render)
"ffplay",
"-fflags", "nobuffer",
"-flags", "low_delay",
"-framedrop",
"-noborder",
"-i", str(input_path),
]
log.info("ffplay cmd: %s", " ".join(cmd))
self._proc = subprocess.Popen( self.append(self._gl_area)
cmd, log.info("MonitorWidget initialized (GLArea)")
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
log.info("ffplay started: pid=%s", self._proc.pid)
self._start_stderr_log("ffplay")
Timer(1.0, self._try_reparent).start()
def start_mpv(self, input_path): def _on_realize(self, gl_area):
log.info("Starting mpv with input: %s", input_path) """GL context is ready — initialize mpv's render context."""
log.info("GLArea realized")
surface = self._drawing_area.get_native().get_surface() gl_area.make_current()
xid = None if gl_area.get_error():
if hasattr(surface, "get_xid"): log.error("GLArea error: %s", gl_area.get_error())
xid = surface.get_xid()
log.info("Got X11 window ID: %s", xid)
else:
log.warning("No get_xid on surface (type=%s), mpv will open separate window", type(surface).__name__)
cmd = [
"mpv",
"--no-terminal",
"--no-osc",
"--no-input-default-bindings",
"--profile=low-latency",
"--demuxer-lavf-o=fflags=+nobuffer",
]
if xid:
cmd.append(f"--wid={xid}")
cmd.append(str(input_path))
log.info("mpv cmd: %s", " ".join(cmd))
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
log.info("mpv started: pid=%s", self._proc.pid)
self._start_stderr_log("mpv")
def _start_stderr_log(self, name):
"""Read stderr in background thread and log it."""
import threading
def _read():
try:
for line in self._proc.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
log.info("[%s:stderr] %s", name, text)
except Exception as e:
log.warning("[%s:stderr] read error: %s", name, e)
retcode = self._proc.poll() if self._proc else None
log.info("[%s] process exited: code=%s", name, retcode)
t = threading.Thread(target=_read, daemon=True, name=f"{name}_stderr")
t.start()
def _try_reparent(self):
if self._proc is None or self._proc.poll() is not None:
log.warning("ffplay not running, cannot reparent")
return return
log.info("Attempting to reparent ffplay window (pid=%s)", self._proc.pid) def _on_unrealize(self, gl_area):
try: """Clean up mpv render context."""
result = subprocess.run( log.info("GLArea unrealized")
["xdotool", "search", "--pid", str(self._proc.pid)], if self._player:
capture_output=True, self._player.terminate()
text=True, self._player = None
timeout=5,
) def _on_render(self, gl_area, gl_context):
windows = result.stdout.strip().split("\n") """Render mpv's current frame to the GLArea."""
log.info("xdotool found windows: %s", windows) if not self._player:
if windows and windows[0]: return True
ffplay_wid = windows[0]
surface = self._drawing_area.get_native().get_surface() # Get the default FBO that GTK4 GLArea renders to
if hasattr(surface, "get_xid"): fbo = gl_area.get_buffer() if hasattr(gl_area, 'get_buffer') else 0
parent_xid = surface.get_xid() width = gl_area.get_width()
log.info("Reparenting ffplay %s into %s", ffplay_wid, parent_xid) height = gl_area.get_height()
subprocess.run(
["xdotool", "windowreparent", ffplay_wid, str(parent_xid)], # GTK4 GLArea uses its own FBO, get it from GL state
timeout=5, import ctypes
) fbo_id = ctypes.c_int(0)
subprocess.run( gl = ctypes.cdll.LoadLibrary("libGL.so.1")
["xdotool", "windowsize", ffplay_wid, "100%", "100%"], gl.glGetIntegerv(0x8CA6, ctypes.byref(fbo_id)) # GL_DRAW_FRAMEBUFFER_BINDING
timeout=5,
) self._player.render(fbo_id.value, width, height)
log.info("Reparenting done") return True
else:
log.warning("No get_xid on surface, cannot reparent") def _on_mpv_update(self):
else: """Called by mpv when a new frame is ready. Triggers re-render."""
log.warning("No windows found for ffplay pid=%s", self._proc.pid) GLib.idle_add(self._gl_area.queue_render)
except FileNotFoundError:
log.error("xdotool not found, cannot reparent ffplay window") def start_stream(self, source, record_path=None):
except subprocess.TimeoutExpired: """Start playing from a URL and optionally record to disk.
log.error("xdotool timed out")
Args:
source: TCP URL (tcp://...), file path, etc.
record_path: if set, mpv dumps the raw stream to this file
"""
self._gl_area.make_current()
self._player = Player(record_path=record_path)
self._player.init_gl(update_callback=self._on_mpv_update)
self._player.play(source)
log.info("Monitor streaming from: %s (record=%s)", source, record_path)
def stop(self): def stop(self):
if self._proc and self._proc.poll() is None: """Stop playback and release mpv."""
log.info("Stopping monitor process pid=%s", self._proc.pid) if self._player:
self._proc.send_signal(signal.SIGINT) log.info("Stopping monitor")
try: self._player.terminate()
self._proc.wait(timeout=3) self._player = None
log.info("Monitor process stopped gracefully")
except subprocess.TimeoutExpired:
self._proc.kill()
log.warning("Monitor process killed (did not stop in 3s)")
self._proc = None
else: else:
log.info("Monitor process already stopped or never started") log.info("Monitor already stopped")

160
cht/ui/mpv.py Normal file
View File

@@ -0,0 +1,160 @@
"""
MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API.
Renders video frames to an OpenGL context provided by GTK4's GLArea.
No subprocess calls, no X11 wid hacks.
"""
import ctypes
import logging
import mpv as libmpv
log = logging.getLogger(__name__)
def _make_get_proc_address():
"""Create a ctypes callback for OpenGL function loading."""
libgl = ctypes.cdll.LoadLibrary("libGL.so.1")
libgl.glXGetProcAddressARB.restype = ctypes.c_void_p
libgl.glXGetProcAddressARB.argtypes = [ctypes.c_char_p]
@ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p)
def get_proc_address(_, name):
return libgl.glXGetProcAddressARB(name)
return get_proc_address
_get_proc_address = _make_get_proc_address()
class Player:
"""Wraps a libmpv player with OpenGL render context for GTK4 embedding."""
def __init__(self, record_path=None):
opts = {
"input_default_bindings": False,
"input_vo_keyboard": False,
"osc": False,
"profile": "low-latency",
"cache": "no",
"untimed": True,
"demuxer_thread": "no",
"demuxer_lavf_o": "fflags=+nobuffer",
"video_sync": "display-desync",
"vo": "libmpv",
"hwdec": "auto",
}
if record_path:
opts["stream_record"] = str(record_path)
log.info("mpv will record stream to: %s", record_path)
log.info("Creating mpv player (OpenGL render mode)")
self._player = libmpv.MPV(**opts)
self._ctx = None
self._update_callback = None
log.info("mpv player created")
def init_gl(self, update_callback):
"""Initialize the OpenGL render context.
Must be called from a thread with an active GL context (e.g. GLArea realize).
Args:
update_callback: called by mpv when a new frame is ready to render.
Should trigger a GLArea queue_render().
"""
self._update_callback = update_callback
self._ctx = libmpv.MpvRenderContext(
self._player,
"opengl",
opengl_init_params={
"get_proc_address": _get_proc_address,
},
)
# Keep reference to prevent GC of the ctypes callback
self._get_proc_address_ref = _get_proc_address
self._ctx.update_cb = self._on_mpv_update
log.info("mpv OpenGL render context initialized")
def _on_mpv_update(self):
"""Called by mpv from any thread when a new frame is available."""
if self._update_callback:
self._update_callback()
def render(self, fbo, width, height):
"""Render the current frame to the given OpenGL FBO.
Call from the GLArea render signal handler.
"""
if self._ctx:
self._ctx.render(
flip_y=True,
opengl_fbo={
"fbo": fbo,
"w": width,
"h": height,
},
)
def play(self, source):
"""Play from a file path or URL."""
log.info("mpv play: %s", source)
self._player.play(str(source))
def play_fd(self, fd):
"""Play from a raw file descriptor."""
source = f"fd://{fd}"
log.info("mpv play from fd: %s", source)
self._player.play(source)
def pause(self):
self._player.pause = True
def resume(self):
self._player.pause = False
@property
def paused(self):
return self._player.pause
def seek(self, seconds):
"""Seek to absolute position in seconds."""
self._player.seek(seconds, reference="absolute")
def seek_relative(self, seconds):
"""Seek relative to current position."""
self._player.seek(seconds, reference="relative")
def stop(self):
log.info("mpv stop")
self._player.stop()
def terminate(self):
log.info("mpv terminate")
try:
if self._ctx:
self._ctx.free()
self._ctx = None
self._player.terminate()
except Exception as e:
log.warning("mpv terminate error: %s", e)
@property
def idle(self):
return self._player.core_idle
@property
def duration(self):
try:
return self._player.duration
except Exception:
return None
@property
def time_pos(self):
try:
return self._player.time_pos
except Exception:
return None

View File

@@ -70,24 +70,23 @@ class ChtWindow(Adw.ApplicationWindow):
self._stream_mgr = StreamManager() self._stream_mgr = StreamManager()
log.info("Session: %s", self._stream_mgr.session_id) log.info("Session: %s", self._stream_mgr.session_id)
log.info("Session dir: %s", self._stream_mgr.session_dir) self._stream_mgr.setup_dirs()
fifo_path = self._stream_mgr.start_recorder_with_monitor() # mpv listens on TCP directly (lowest latency, like def scripts)
log.info("FIFO path: %s", fifo_path) # and records the raw stream to disk for frame extraction
log.info("Stream URL: %s", self._stream_mgr.stream_url) stream_url = self._stream_mgr.stream_url
log.info("Recorder started, waiting for sender connection...") record_path = self._stream_mgr.stream_dir / "recording.ts"
log.info("Starting mpv on %s, recording to %s", stream_url, record_path)
self._monitor.start_stream(stream_url, record_path=record_path)
log.info("Monitor started, waiting for sender...")
self._monitor.start_mpv(fifo_path) self._stream_mgr.start_frame_extractor_on_recording(record_path)
log.info("Monitor (mpv) started")
self._stream_mgr.start_frame_extractor()
log.info("Frame extractor started") log.info("Frame extractor started")
def _stop_stream(self): def _stop_stream(self):
log.info("Stopping stream...") log.info("Stopping stream...")
self._monitor.stop() self._monitor.stop()
log.info("Monitor stopped") log.info("Monitor stopped")
if self._stream_mgr: if self._stream_mgr:
self._stream_mgr.stop_all() self._stream_mgr.stop_all()
log.info("Stream manager stopped") log.info("Stream manager stopped")

View File

@@ -9,6 +9,7 @@ description = "Stream viewer with Claude agent integration"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"ffmpeg-python", "ffmpeg-python",
"python-mpv",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -1,4 +1,4 @@
"""Tests for cht.stream.ffmpeg — command compilation and pipeline construction.""" """Tests for cht.stream.ffmpeg — pipeline construction and execution."""
import os import os
import signal import signal
@@ -7,6 +7,7 @@ from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pytest import pytest
import ffmpeg as ffmpeg_lib
from cht.stream import ffmpeg as ff from cht.stream import ffmpeg as ff
@@ -16,33 +17,28 @@ class TestReceiveAndSegment:
node = ff.receive_and_segment( node = ff.receive_and_segment(
"tcp://0.0.0.0:4444?listen", tmp_path, segment_duration=30 "tcp://0.0.0.0:4444?listen", tmp_path, segment_duration=30
) )
cmd = ff.compile_cmd(node) cmd = node.compile()
cmd_str = " ".join(str(c) for c in cmd)
assert cmd[0] == "ffmpeg" assert cmd[0] == "ffmpeg"
assert "-hide_banner" in cmd assert "tcp://0.0.0.0:4444?listen" in cmd_str
assert "-loglevel" in cmd assert "segment" in cmd_str
# Input options assert str(tmp_path / "segment_%04d.ts") in cmd_str
assert "tcp://0.0.0.0:4444?listen" in cmd
# Segment output options
assert "segment" in cmd
assert "30" in cmd or 30 in cmd
assert str(tmp_path / "segment_%04d.ts") in cmd
def test_input_has_low_latency_flags(self, tmp_path): def test_input_has_low_latency_flags(self, tmp_path):
node = ff.receive_and_segment( node = ff.receive_and_segment("tcp://0.0.0.0:4444?listen", tmp_path)
"tcp://0.0.0.0:4444?listen", tmp_path cmd_str = " ".join(str(c) for c in node.compile())
)
cmd = ff.compile_cmd(node)
cmd_str = " ".join(str(c) for c in cmd)
assert "nobuffer" in cmd_str assert "nobuffer" in cmd_str
assert "low_delay" in cmd_str assert "low_delay" in cmd_str
def test_copy_codec(self, tmp_path): def test_copy_codec(self, tmp_path):
node = ff.receive_and_segment( node = ff.receive_and_segment("tcp://0.0.0.0:4444?listen", tmp_path)
"tcp://0.0.0.0:4444?listen", tmp_path assert "copy" in node.compile()
)
cmd = ff.compile_cmd(node) def test_has_global_args(self, tmp_path):
assert "copy" in cmd node = ff.receive_and_segment("tcp://0.0.0.0:4444?listen", tmp_path)
cmd = node.compile()
assert "-hide_banner" in cmd
class TestReceiveAndSegmentWithMonitor: class TestReceiveAndSegmentWithMonitor:
@@ -52,13 +48,11 @@ class TestReceiveAndSegmentWithMonitor:
"tcp://0.0.0.0:4444?listen", tmp_path, fifo "tcp://0.0.0.0:4444?listen", tmp_path, fifo
) )
assert fifo.exists() assert fifo.exists()
assert os.path.isfile(fifo) or os.stat(fifo).st_mode & 0o010000 # is fifo
def test_does_not_recreate_existing_fifo(self, tmp_path): def test_does_not_recreate_existing_fifo(self, tmp_path):
fifo = tmp_path / "monitor.pipe" fifo = tmp_path / "monitor.pipe"
os.mkfifo(str(fifo)) os.mkfifo(str(fifo))
inode_before = fifo.stat().st_ino inode_before = fifo.stat().st_ino
ff.receive_and_segment_with_monitor( ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo "tcp://0.0.0.0:4444?listen", tmp_path, fifo
) )
@@ -69,9 +63,7 @@ class TestReceiveAndSegmentWithMonitor:
node = ff.receive_and_segment_with_monitor( node = ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo "tcp://0.0.0.0:4444?listen", tmp_path, fifo
) )
cmd = ff.compile_cmd(node) cmd_str = " ".join(str(c) for c in node.compile())
cmd_str = " ".join(str(c) for c in cmd)
# Should have segment output and fifo output
assert "segment_%04d.ts" in cmd_str assert "segment_%04d.ts" in cmd_str
assert "monitor.pipe" in cmd_str assert "monitor.pipe" in cmd_str
@@ -80,129 +72,73 @@ class TestReceiveAndSegmentWithMonitor:
node = ff.receive_and_segment_with_monitor( node = ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo "tcp://0.0.0.0:4444?listen", tmp_path, fifo
) )
cmd = ff.compile_cmd(node) cmd = node.compile()
# Should have copy codec for both outputs
assert cmd.count("copy") >= 2 assert cmd.count("copy") >= 2
def test_has_global_args(self, tmp_path):
fifo = tmp_path / "monitor.pipe"
node = ff.receive_and_segment_with_monitor(
"tcp://0.0.0.0:4444?listen", tmp_path, fifo
)
assert "-hide_banner" in node.compile()
class TestExtractSceneFrames: class TestExtractSceneFrames:
def test_compiles_select_filter(self, tmp_path): def test_compiles_select_filter(self, tmp_path):
# We can't run ffmpeg without a real file, but we can test compilation stream = ffmpeg_lib.input(str(tmp_path / "test.ts"))
import ffmpeg stream = stream.filter("select", "gt(scene,0.3)+gte(t-prev_selected_t,30)")
stream = ffmpeg.input(str(tmp_path / "test.ts"))
stream = stream.filter("select", "gt(scene\\,0.3)+gte(t-prev_selected_t\\,30)")
stream = stream.filter("showinfo") stream = stream.filter("showinfo")
output = ffmpeg.output( output = ffmpeg_lib.output(
stream, stream,
str(tmp_path / "F%04d.jpg"), str(tmp_path / "F%04d.jpg"),
vsync="vfr", vsync="vfr",
**{"q:v": "2"}, **{"q:v": "2"},
start_number=1, start_number=1,
) )
cmd = ff.compile_cmd(output) cmd_str = " ".join(str(c) for c in output.compile())
cmd_str = " ".join(str(c) for c in cmd)
assert "select" in cmd_str assert "select" in cmd_str
assert "scene" in cmd_str assert "scene" in cmd_str
assert "showinfo" in cmd_str assert "showinfo" in cmd_str
assert "vfr" in cmd_str assert "vfr" in cmd_str
@patch("cht.stream.ffmpeg.subprocess.run") def test_returns_decoded_strings(self, tmp_path):
def test_calls_subprocess_with_timeout(self, mock_run, tmp_path): mock_proc = MagicMock()
mock_run.return_value = MagicMock(stdout="", stderr="") mock_proc.communicate.return_value = (b"out", b"err")
ff.extract_scene_frames( mock_proc.poll.return_value = 0
tmp_path / "test.ts", tmp_path, with patch("ffmpeg._run.run_async", return_value=mock_proc):
scene_threshold=0.4, max_interval=20, start_number=5,
)
mock_run.assert_called_once()
call_kwargs = mock_run.call_args
assert call_kwargs.kwargs["timeout"] == 120
assert call_kwargs.kwargs["capture_output"] is True
@patch("cht.stream.ffmpeg.subprocess.run")
def test_returns_stdout_stderr(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(stdout="out", stderr="err")
stdout, stderr = ff.extract_scene_frames( stdout, stderr = ff.extract_scene_frames(
tmp_path / "test.ts", tmp_path, tmp_path / "test.ts", tmp_path,
) )
assert stdout == "out" assert stdout == "out"
assert stderr == "err" assert stderr == "err"
@patch("cht.stream.ffmpeg.subprocess.run")
def test_start_number_in_cmd(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(stdout="", stderr="")
ff.extract_scene_frames(
tmp_path / "test.ts", tmp_path, start_number=42
)
cmd = mock_run.call_args.args[0]
assert "42" in [str(c) for c in cmd]
class TestExtractAudioPcm: class TestExtractAudioPcm:
def test_compiles_audio_extraction(self, tmp_path): def test_compiles_audio_extraction(self, tmp_path):
node = ff.extract_audio_pcm(tmp_path / "test.ts") node = ff.extract_audio_pcm(tmp_path / "test.ts")
cmd = ff.compile_cmd(node) cmd_str = " ".join(str(c) for c in node.compile())
cmd_str = " ".join(str(c) for c in cmd)
assert "pcm_s16le" in cmd_str assert "pcm_s16le" in cmd_str
assert "16000" in cmd_str assert "16000" in cmd_str
assert "pipe:" in cmd_str assert "pipe:" in cmd_str
assert "wav" in cmd_str assert "wav" in cmd_str
def test_has_global_args(self, tmp_path):
class TestCompileCmd: node = ff.extract_audio_pcm(tmp_path / "test.ts")
def test_inserts_global_flags_after_ffmpeg(self, tmp_path): assert "-hide_banner" in node.compile()
import ffmpeg
node = ffmpeg.input(str(tmp_path / "test.ts")).output(str(tmp_path / "out.ts"))
cmd = ff.compile_cmd(node)
assert cmd[0] == "ffmpeg"
assert cmd[1] == "-hide_banner"
assert cmd[2] == "-loglevel"
assert cmd[3] == "warning"
class TestRunAsync: class TestRunAsync:
@patch("cht.stream.ffmpeg.subprocess.Popen") @patch("ffmpeg.run_async")
def test_default_no_pipes(self, mock_popen, tmp_path): def test_delegates_to_ffmpeg_python(self, mock_run_async, tmp_path):
import ffmpeg node = ffmpeg_lib.input("test").output("out")
node = ffmpeg.input("test").output("out")
ff.run_async(node) ff.run_async(node)
call_kwargs = mock_popen.call_args # ffmpeg-python's run_async is called on the node
assert call_kwargs.kwargs["stdout"] == subprocess.DEVNULL # Our function calls node.run_async() which is the bound method
assert call_kwargs.kwargs["stderr"] == subprocess.DEVNULL
@patch("cht.stream.ffmpeg.subprocess.Popen") @patch("ffmpeg.run_async")
def test_pipe_stdout(self, mock_popen, tmp_path): def test_passes_pipe_flags(self, mock_run_async, tmp_path):
import ffmpeg node = ffmpeg_lib.input("test").output("out")
node = ffmpeg.input("test").output("out") ff.run_async(node, pipe_stdout=True, pipe_stderr=True)
ff.run_async(node, pipe_stdout=True)
call_kwargs = mock_popen.call_args
assert call_kwargs.kwargs["stdout"] == subprocess.PIPE
@patch("cht.stream.ffmpeg.subprocess.Popen")
def test_pipe_stderr(self, mock_popen, tmp_path):
import ffmpeg
node = ffmpeg.input("test").output("out")
ff.run_async(node, pipe_stderr=True)
call_kwargs = mock_popen.call_args
assert call_kwargs.kwargs["stderr"] == subprocess.PIPE
class TestRunSync:
@patch("cht.stream.ffmpeg.subprocess.run")
def test_returns_tuple(self, mock_run):
mock_run.return_value = MagicMock(stdout="hello", stderr="world")
import ffmpeg
node = ffmpeg.input("test").output("out")
stdout, stderr = ff.run_sync(node)
assert stdout == "hello"
assert stderr == "world"
@patch("cht.stream.ffmpeg.subprocess.run")
def test_passes_timeout(self, mock_run):
mock_run.return_value = MagicMock(stdout="", stderr="")
import ffmpeg
node = ffmpeg.input("test").output("out")
ff.run_sync(node, timeout=30)
assert mock_run.call_args.kwargs["timeout"] == 30
class TestStopProc: class TestStopProc:
@@ -227,4 +163,4 @@ class TestStopProc:
proc.send_signal.assert_not_called() proc.send_signal.assert_not_called()
def test_noop_if_none(self): def test_noop_if_none(self):
ff.stop_proc(None) # should not raise ff.stop_proc(None)

View File

@@ -67,7 +67,7 @@ class TestStartRecorder:
mock_segment.assert_called_once_with( mock_segment.assert_called_once_with(
manager.stream_url, manager.stream_dir, 60, manager.stream_url, manager.stream_dir, 60,
) )
mock_async.assert_called_once_with(mock_node) mock_async.assert_called_once_with(mock_node, pipe_stderr=True)
assert "recorder" in manager._procs assert "recorder" in manager._procs
@@ -82,7 +82,7 @@ class TestStartRecorderWithMonitor:
assert fifo == manager.session_dir / "monitor.pipe" assert fifo == manager.session_dir / "monitor.pipe"
mock_monitor.assert_called_once() mock_monitor.assert_called_once()
mock_async.assert_called_once_with(mock_node) mock_async.assert_called_once_with(mock_node, pipe_stderr=True)
@patch("cht.stream.manager.ff.run_async") @patch("cht.stream.manager.ff.run_async")
@patch("cht.stream.manager.ff.receive_and_segment_with_monitor") @patch("cht.stream.manager.ff.receive_and_segment_with_monitor")