Files
mitus/cht/ui/mpv.py
2026-04-03 00:25:14 -03:00

170 lines
5.1 KiB
Python

"""
MPV wrapper using python-mpv (libmpv bindings) with OpenGL render API.
Renders video frames to an OpenGL context provided by GTK4's GLArea.
Driven by the Timeline state machine — does not manage its own state.
"""
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.
Does not manage playback state — that's the Timeline's job.
Provides: load, play, pause, seek, show_frame_at, render.
"""
def __init__(self):
opts = {
"input_default_bindings": False,
"input_vo_keyboard": False,
"osc": False,
"vo": "libmpv",
"hwdec": "auto",
# Keep open at EOF so LIVE mode can wait for more data
"keep_open": "yes",
"keep_open_pause": "no",
}
self._player = libmpv.MPV(log_handler=self._mpv_log, loglevel="warn", **opts)
self._ctx = None
self._update_callback = None
log.info("mpv player created")
@staticmethod
def _mpv_log(loglevel, component, message):
msg = f"[mpv/{component}] {message.strip()}"
if loglevel in ("fatal", "error"):
log.error(msg)
elif loglevel == "warn":
log.warning(msg)
def init_gl(self, update_callback):
"""Initialize the OpenGL render context. Call with active GL context."""
self._update_callback = update_callback
self._ctx = libmpv.MpvRenderContext(
self._player,
"opengl",
opengl_init_params={
"get_proc_address": _get_proc_address,
},
)
self._get_proc_address_ref = _get_proc_address
self._ctx.update_cb = self._on_mpv_update
log.info("mpv GL render context initialized")
def _on_mpv_update(self):
if self._update_callback:
self._update_callback()
def render(self, fbo, width, height):
"""Render current frame to the given OpenGL FBO."""
if self._ctx:
self._ctx.render(
flip_y=True,
opengl_fbo={"fbo": fbo, "w": width, "h": height},
)
def load(self, path):
"""Load a recording file. Does not start playback."""
log.info("mpv load: %s", path)
self._player.loadfile(str(path), mode="replace")
def load_at(self, path, seconds, pause=True):
"""Load a file and seek to position atomically. Avoids async seek race."""
log.info("mpv load_at: %s at %.1fs pause=%s", path, seconds, pause)
self._player["pause"] = pause
self._player.loadfile(str(path), mode="replace", start=str(seconds))
def load_live(self, url):
"""Load a live stream URL with low-latency options."""
self._player["cache"] = "no"
self._player["demuxer-max-bytes"] = "512KiB"
self._player["audio-buffer"] = 0.2
log.info("mpv load_live: %s", url)
self._player.loadfile(str(url), mode="replace")
def command(self, *args):
"""Send a command to mpv."""
try:
self._player.command(*args)
except Exception:
pass
def play(self):
"""Resume/start playback."""
self._player.pause = False
def pause(self):
"""Pause playback."""
self._player.pause = True
def seek(self, seconds):
"""Seek to absolute position."""
try:
self._player.seek(seconds, reference="absolute")
except Exception:
pass # seek may fail if file not loaded yet
def show_frame_at(self, seconds):
"""Pause and show the frame at the given timestamp."""
self._player.pause = True
try:
self._player.seek(seconds, reference="absolute")
except Exception:
pass
@property
def time_pos(self):
try:
return self._player.time_pos
except Exception:
return None
@property
def paused(self):
return self._player.pause
@property
def idle(self):
return self._player.core_idle
def screenshot(self, path):
"""Save current frame as an image file."""
try:
self._player.screenshot_to_file(str(path), includes="video")
except Exception as e:
log.warning("Screenshot failed: %s", e)
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)