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

@@ -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
ffplay's window via xdotool. GTK4 dropped GtkSocket so we use X11-level tricks.
Uses libmpv's OpenGL render API + Gtk.GLArea for proper embedding.
No X11 wid hacks — renders directly to a GL texture in the GTK layout.
Works on both X11 and Wayland.
"""
import logging
import subprocess
import signal
from threading import Timer
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, GLib, Gdk
from cht.ui.mpv import Player
log = logging.getLogger(__name__)
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):
super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs)
self._proc = None
self._xid = None
self._player = None
self._drawing_area = Gtk.DrawingArea()
self._drawing_area.set_hexpand(True)
self._drawing_area.set_vexpand(True)
self._drawing_area.set_content_height(250)
self.append(self._drawing_area)
log.info("MonitorWidget initialized")
self._gl_area = Gtk.GLArea()
self._gl_area.set_hexpand(True)
self._gl_area.set_vexpand(True)
self._gl_area.set_auto_render(False)
self._gl_area.set_has_depth_buffer(False)
self._gl_area.set_has_stencil_buffer(False)
def start_ffplay(self, input_path):
log.info("Starting ffplay with input: %s", input_path)
cmd = [
"ffplay",
"-fflags", "nobuffer",
"-flags", "low_delay",
"-framedrop",
"-noborder",
"-i", str(input_path),
]
log.info("ffplay cmd: %s", " ".join(cmd))
self._gl_area.connect("realize", self._on_realize)
self._gl_area.connect("unrealize", self._on_unrealize)
self._gl_area.connect("render", self._on_render)
self._proc = subprocess.Popen(
cmd,
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()
self.append(self._gl_area)
log.info("MonitorWidget initialized (GLArea)")
def start_mpv(self, input_path):
log.info("Starting mpv with input: %s", input_path)
surface = self._drawing_area.get_native().get_surface()
xid = None
if hasattr(surface, "get_xid"):
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")
def _on_realize(self, gl_area):
"""GL context is ready — initialize mpv's render context."""
log.info("GLArea realized")
gl_area.make_current()
if gl_area.get_error():
log.error("GLArea error: %s", gl_area.get_error())
return
log.info("Attempting to reparent ffplay window (pid=%s)", self._proc.pid)
try:
result = subprocess.run(
["xdotool", "search", "--pid", str(self._proc.pid)],
capture_output=True,
text=True,
timeout=5,
)
windows = result.stdout.strip().split("\n")
log.info("xdotool found windows: %s", windows)
if windows and windows[0]:
ffplay_wid = windows[0]
surface = self._drawing_area.get_native().get_surface()
if hasattr(surface, "get_xid"):
parent_xid = surface.get_xid()
log.info("Reparenting ffplay %s into %s", ffplay_wid, parent_xid)
subprocess.run(
["xdotool", "windowreparent", ffplay_wid, str(parent_xid)],
timeout=5,
)
subprocess.run(
["xdotool", "windowsize", ffplay_wid, "100%", "100%"],
timeout=5,
)
log.info("Reparenting done")
else:
log.warning("No get_xid on surface, cannot reparent")
else:
log.warning("No windows found for ffplay pid=%s", self._proc.pid)
except FileNotFoundError:
log.error("xdotool not found, cannot reparent ffplay window")
except subprocess.TimeoutExpired:
log.error("xdotool timed out")
def _on_unrealize(self, gl_area):
"""Clean up mpv render context."""
log.info("GLArea unrealized")
if self._player:
self._player.terminate()
self._player = None
def _on_render(self, gl_area, gl_context):
"""Render mpv's current frame to the GLArea."""
if not self._player:
return True
# Get the default FBO that GTK4 GLArea renders to
fbo = gl_area.get_buffer() if hasattr(gl_area, 'get_buffer') else 0
width = gl_area.get_width()
height = gl_area.get_height()
# GTK4 GLArea uses its own FBO, get it from GL state
import ctypes
fbo_id = ctypes.c_int(0)
gl = ctypes.cdll.LoadLibrary("libGL.so.1")
gl.glGetIntegerv(0x8CA6, ctypes.byref(fbo_id)) # GL_DRAW_FRAMEBUFFER_BINDING
self._player.render(fbo_id.value, width, height)
return True
def _on_mpv_update(self):
"""Called by mpv when a new frame is ready. Triggers re-render."""
GLib.idle_add(self._gl_area.queue_render)
def start_stream(self, source, record_path=None):
"""Start playing from a URL and optionally record to disk.
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):
if self._proc and self._proc.poll() is None:
log.info("Stopping monitor process pid=%s", self._proc.pid)
self._proc.send_signal(signal.SIGINT)
try:
self._proc.wait(timeout=3)
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
"""Stop playback and release mpv."""
if self._player:
log.info("Stopping monitor")
self._player.terminate()
self._player = None
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