From aacae0640650029de7f47968b15ecec3105af649 Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 13:58:17 -0500 Subject: [PATCH] Move _MpvGLWidget + _MpvOpenGLSurface from preview.py to media/mpv_gl.py (no behavior change) Step 4 of the gui/app.py + gui/preview.py structural refactor. Pure move: the OpenGL render-context host and its concrete QOpenGLWidget companion are now in their own module under media/. The mid-file `from PySide6.QtOpenGLWidgets import QOpenGLWidget as _QOpenGLWidget` import that used to sit between the two classes moves with them to the new module's import header. preview.py grows another re-export shim line so VideoPlayer (still in preview.py) can keep constructing _MpvGLWidget unchanged. Shim removed in commit 14. --- booru_viewer/gui/media/mpv_gl.py | 152 +++++++++++++++++++++++++++++++ booru_viewer/gui/preview.py | 147 +----------------------------- 2 files changed, 153 insertions(+), 146 deletions(-) create mode 100644 booru_viewer/gui/media/mpv_gl.py diff --git a/booru_viewer/gui/media/mpv_gl.py b/booru_viewer/gui/media/mpv_gl.py new file mode 100644 index 0000000..216ac76 --- /dev/null +++ b/booru_viewer/gui/media/mpv_gl.py @@ -0,0 +1,152 @@ +"""mpv OpenGL render context host widgets.""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtOpenGLWidgets import QOpenGLWidget as _QOpenGLWidget +from PySide6.QtWidgets import QWidget, QVBoxLayout + +import mpv as mpvlib + + +class _MpvGLWidget(QWidget): + """OpenGL widget that hosts mpv rendering via the render API. + + Subclasses QOpenGLWidget so initializeGL/paintGL are dispatched + correctly by Qt's C++ virtual method mechanism. + Works on both X11 and Wayland. + """ + + _frame_ready = Signal() # mpv thread → main thread repaint trigger + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._gl: _MpvOpenGLSurface = _MpvOpenGLSurface(self) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._gl) + self._ctx: mpvlib.MpvRenderContext | None = None + self._gl_inited = False + self._proc_addr_fn = None + self._frame_ready.connect(self._gl.update) + # Create mpv eagerly on the main thread. + # + # `ao=pulse` is critical for Linux Discord screen-share audio + # capture. Discord on Linux only enumerates audio clients via + # the libpulse API; it does not see clients that talk to + # PipeWire natively (which is mpv's default `ao=pipewire`). + # Forcing the pulseaudio output here makes mpv go through + # PipeWire's pulseaudio compatibility layer, which Discord + # picks up the same way it picks up Firefox. Without this, + # videos play locally but the audio is silently dropped from + # any Discord screen share. See: + # https://github.com/mpv-player/mpv/issues/11100 + # https://github.com/edisionnano/Screenshare-with-audio-on-Discord-with-Linux + # On Windows mpv ignores `ao=pulse` and falls through to the + # next entry, so listing `wasapi` second keeps Windows playback + # working without a platform branch here. + # + # `audio_client_name` is the name mpv registers with the audio + # backend. Sets `application.name` and friends so capture tools + # group mpv's audio under the booru-viewer app identity instead + # of the default "mpv Media Player". + self._mpv = mpvlib.MPV( + vo="libmpv", + hwdec="auto", + keep_open="yes", + ao="pulse,wasapi,", + audio_client_name="booru-viewer", + input_default_bindings=False, + input_vo_keyboard=False, + osc=False, + # Fast-load options: shave ~50-100ms off first-frame decode + # for h264/hevc by skipping a few bitstream-correctness checks + # (`vd-lavc-fast`) and the in-loop filter on non-keyframes + # (`vd-lavc-skiploopfilter=nonkey`). The artifacts are only + # visible on the first few frames before the decoder steady- + # state catches up, and only on degraded sources. mpv + # documents these as safe for "fast load" use cases like + # ours where we want the first frame on screen ASAP and + # don't care about a tiny quality dip during ramp-up. + vd_lavc_fast="yes", + vd_lavc_skiploopfilter="nonkey", + ) + # Wire up the GL surface's callbacks to us + self._gl._owner = self + + def _init_gl(self) -> None: + if self._gl_inited or self._mpv is None: + return + from PySide6.QtGui import QOpenGLContext + ctx = QOpenGLContext.currentContext() + if not ctx: + return + + def _get_proc_address(_ctx, name): + if isinstance(name, bytes): + name_str = name + else: + name_str = name.encode('utf-8') + addr = ctx.getProcAddress(name_str) + if addr is not None: + return int(addr) + return 0 + + self._proc_addr_fn = mpvlib.MpvGlGetProcAddressFn(_get_proc_address) + self._ctx = mpvlib.MpvRenderContext( + self._mpv, 'opengl', + opengl_init_params={'get_proc_address': self._proc_addr_fn}, + ) + self._ctx.update_cb = self._on_mpv_frame + self._gl_inited = True + + def _on_mpv_frame(self) -> None: + """Called from mpv thread when a new frame is ready.""" + self._frame_ready.emit() + + def _paint_gl(self) -> None: + if self._ctx is None: + self._init_gl() + if self._ctx is None: + return + ratio = self._gl.devicePixelRatioF() + w = int(self._gl.width() * ratio) + h = int(self._gl.height() * ratio) + self._ctx.render( + opengl_fbo={'w': w, 'h': h, 'fbo': self._gl.defaultFramebufferObject()}, + flip_y=True, + ) + + def ensure_gl_init(self) -> None: + """Force GL context creation and render context setup. + + Needed when the widget is hidden (e.g. inside a QStackedWidget) + but mpv needs a render context before loadfile(). + """ + if not self._gl_inited: + self._gl.makeCurrent() + self._init_gl() + + def cleanup(self) -> None: + if self._ctx: + self._ctx.free() + self._ctx = None + if self._mpv: + self._mpv.terminate() + self._mpv = None + + +class _MpvOpenGLSurface(_QOpenGLWidget): + """QOpenGLWidget subclass — delegates initializeGL/paintGL to _MpvGLWidget.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._owner: _MpvGLWidget | None = None + + def initializeGL(self) -> None: + if self._owner: + self._owner._init_gl() + + def paintGL(self) -> None: + if self._owner: + self._owner._paint_gl() diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py index 4975a61..c33fd7f 100644 --- a/booru_viewer/gui/preview.py +++ b/booru_viewer/gui/preview.py @@ -1092,152 +1092,6 @@ class _ClickSeekSlider(QSlider): # -- Video Player (mpv backend via OpenGL render API) -- -class _MpvGLWidget(QWidget): - """OpenGL widget that hosts mpv rendering via the render API. - - Subclasses QOpenGLWidget so initializeGL/paintGL are dispatched - correctly by Qt's C++ virtual method mechanism. - Works on both X11 and Wayland. - """ - - _frame_ready = Signal() # mpv thread → main thread repaint trigger - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._gl: _MpvOpenGLSurface = _MpvOpenGLSurface(self) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._gl) - self._ctx: mpvlib.MpvRenderContext | None = None - self._gl_inited = False - self._proc_addr_fn = None - self._frame_ready.connect(self._gl.update) - # Create mpv eagerly on the main thread. - # - # `ao=pulse` is critical for Linux Discord screen-share audio - # capture. Discord on Linux only enumerates audio clients via - # the libpulse API; it does not see clients that talk to - # PipeWire natively (which is mpv's default `ao=pipewire`). - # Forcing the pulseaudio output here makes mpv go through - # PipeWire's pulseaudio compatibility layer, which Discord - # picks up the same way it picks up Firefox. Without this, - # videos play locally but the audio is silently dropped from - # any Discord screen share. See: - # https://github.com/mpv-player/mpv/issues/11100 - # https://github.com/edisionnano/Screenshare-with-audio-on-Discord-with-Linux - # On Windows mpv ignores `ao=pulse` and falls through to the - # next entry, so listing `wasapi` second keeps Windows playback - # working without a platform branch here. - # - # `audio_client_name` is the name mpv registers with the audio - # backend. Sets `application.name` and friends so capture tools - # group mpv's audio under the booru-viewer app identity instead - # of the default "mpv Media Player". - self._mpv = mpvlib.MPV( - vo="libmpv", - hwdec="auto", - keep_open="yes", - ao="pulse,wasapi,", - audio_client_name="booru-viewer", - input_default_bindings=False, - input_vo_keyboard=False, - osc=False, - # Fast-load options: shave ~50-100ms off first-frame decode - # for h264/hevc by skipping a few bitstream-correctness checks - # (`vd-lavc-fast`) and the in-loop filter on non-keyframes - # (`vd-lavc-skiploopfilter=nonkey`). The artifacts are only - # visible on the first few frames before the decoder steady- - # state catches up, and only on degraded sources. mpv - # documents these as safe for "fast load" use cases like - # ours where we want the first frame on screen ASAP and - # don't care about a tiny quality dip during ramp-up. - vd_lavc_fast="yes", - vd_lavc_skiploopfilter="nonkey", - ) - # Wire up the GL surface's callbacks to us - self._gl._owner = self - - def _init_gl(self) -> None: - if self._gl_inited or self._mpv is None: - return - from PySide6.QtGui import QOpenGLContext - ctx = QOpenGLContext.currentContext() - if not ctx: - return - - def _get_proc_address(_ctx, name): - if isinstance(name, bytes): - name_str = name - else: - name_str = name.encode('utf-8') - addr = ctx.getProcAddress(name_str) - if addr is not None: - return int(addr) - return 0 - - self._proc_addr_fn = mpvlib.MpvGlGetProcAddressFn(_get_proc_address) - self._ctx = mpvlib.MpvRenderContext( - self._mpv, 'opengl', - opengl_init_params={'get_proc_address': self._proc_addr_fn}, - ) - self._ctx.update_cb = self._on_mpv_frame - self._gl_inited = True - - def _on_mpv_frame(self) -> None: - """Called from mpv thread when a new frame is ready.""" - self._frame_ready.emit() - - def _paint_gl(self) -> None: - if self._ctx is None: - self._init_gl() - if self._ctx is None: - return - ratio = self._gl.devicePixelRatioF() - w = int(self._gl.width() * ratio) - h = int(self._gl.height() * ratio) - self._ctx.render( - opengl_fbo={'w': w, 'h': h, 'fbo': self._gl.defaultFramebufferObject()}, - flip_y=True, - ) - - def ensure_gl_init(self) -> None: - """Force GL context creation and render context setup. - - Needed when the widget is hidden (e.g. inside a QStackedWidget) - but mpv needs a render context before loadfile(). - """ - if not self._gl_inited: - self._gl.makeCurrent() - self._init_gl() - - def cleanup(self) -> None: - if self._ctx: - self._ctx.free() - self._ctx = None - if self._mpv: - self._mpv.terminate() - self._mpv = None - - -from PySide6.QtOpenGLWidgets import QOpenGLWidget as _QOpenGLWidget - - -class _MpvOpenGLSurface(_QOpenGLWidget): - """QOpenGLWidget subclass — delegates initializeGL/paintGL to _MpvGLWidget.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._owner: _MpvGLWidget | None = None - - def initializeGL(self) -> None: - if self._owner: - self._owner._init_gl() - - def paintGL(self) -> None: - if self._owner: - self._owner._paint_gl() - - class VideoPlayer(QWidget): """Video player with transport controls, powered by mpv.""" @@ -2093,3 +1947,4 @@ class ImagePreview(QWidget): from .media.constants import VIDEO_EXTENSIONS, _is_video # re-export for refactor compat from .popout.viewport import Viewport, _DRIFT_TOLERANCE # re-export for refactor compat from .media.image_viewer import ImageViewer # re-export for refactor compat +from .media.mpv_gl import _MpvGLWidget, _MpvOpenGLSurface # re-export for refactor compat