Skip to content

Commit c3f1f03

Browse files
committed
feat(linux)!: use experimental renderer by default
This commit introduces an experimental offscreen renderer for Linux that decouples video rendering from UI rendering to improve UI responsiveness. The new renderer is enabled by default on Linux and can be toggled via the MPVQC_LINUX_EXPERIMENTAL_RENDERER environment variable. Updated Video Rendering: - Windows: unchanged - mpvQC uses winId/HWND embedding as before - Linux: Offscreen renderer (experimental) by default. Set MPVQC_LINUX_EXPERIMENTAL_RENDERER=false to use the old renderer Technical Differences: - Legacy renderer: Renders directly into the UI's framebuffer, blocking the UI thread during video rendering operations. This constrains the entire UI to the video's framerate. - Offscreen renderer: Uses two framebuffers and renders video frames in a dedicated background thread. Qt Quick blits the latest completed frame whenever a new video frame is ready: mpv → _render_fbo → _display_fbo → Qt Quick's FBO → screen Background thread: _render_fbo → _display_fbo: pointer swap after mpv completes rendering UI thread: _display_fbo → Qt Quick's FBO: blit when new frame signal arrives This allows the UI to remain responsive at the display's refresh rate while video frames render independently in the background at the content's native framerate. Audio Delay Compensation: The offscreen renderer presents video frames as they become available rather than being tightly coupled to the display's vsync. This can cause audio desynchronization as video frame presentation timing differs from the legacy renderer. To address this, mpvQC now automatically adjusts the audio delay based on the current monitor's refresh rate. The delay is calculated as the frame time (1 / refresh_rate) rounded to millisecond precision. The adjustment updates dynamically when the window moves to a different monitor or when the refresh rate changes. Breaking Change: Users upgrading from previous versions of mpvQC on Linux must update their mpv settings via Options -> Edit mpv.conf: - When using the NEW offscreen renderer (default): Remove any video-timing-offset configuration. The new renderer handles timing automatically through audio delay compensation based on the monitor's refresh rate. - When using the OLD legacy renderer (MPVQC_LINUX_EXPERIMENTAL_RENDERER=false): Keep or add video-timing-offset=0.016 (or adjust based on your monitor's refresh rate). The legacy renderer requires this setting for proper synchronization.
1 parent ca41beb commit c3f1f03

File tree

10 files changed

+485
-14
lines changed

10 files changed

+485
-14
lines changed

data/config/mpv-linux.conf

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
# HQ preset, uses Spline36 for upscaling and Mitchell-Netravali for downscaling
66
profile=gpu-hq
77

8-
# Configure offset to match a frame rate of 60 fps; default 0.050
9-
# More info: https://github.com/mpvqc/mpvQC/issues/26
10-
video-timing-offset=0.016
11-
128
# Debanding is disabled because we don't want to alter the video while doing quality control
139
deband=no
1410

docs/configuration.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,10 @@ mpvQC can be configured using the following environment variables:
2121
- **Operating System:** All
2222
- **Description:** Enables mpv player logging
2323
- **Possible Values:** *not set* or *any value*
24+
25+
- **`MPVQC_LINUX_EXPERIMENTAL_RENDERER`**
26+
27+
- **Default Value:** true
28+
- **Operating System:** Linux
29+
- **Description:** Enables rendering in background thread
30+
- **Possible Values:** *false* to revert back to stable renderer, unset to enable

mpvqc/services/host_integration/__init__.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
#
33
# SPDX-License-Identifier: GPL-3.0-or-later
44

5+
from __future__ import annotations
6+
57
import platform
68
from dataclasses import dataclass
9+
from typing import TYPE_CHECKING
710

811
from PySide6.QtCore import QEvent, QObject, Signal, Slot
12+
from PySide6.QtGui import QScreen
13+
14+
if TYPE_CHECKING:
15+
from PySide6.QtGui import QWindow
916

1017

1118
@dataclass(frozen=True)
@@ -28,18 +35,28 @@ class HostIntegrationService(QObject):
2835
DEFAULT_WINDOW_BUTTON_PREFERENCE = WindowButtonPreference(minimize=True, maximize=True, close=True)
2936

3037
display_zoom_factor_changed = Signal(float)
38+
refresh_rate_changed = Signal(float)
3139

3240
def __init__(self):
3341
super().__init__()
3442
self._zoom_factor = get_display_zoom_factor()
43+
self._refresh_rate = get_refresh_rate()
3544

3645
self._zoom_factor_change_listener = ZoomFactorChangeEventListener()
3746
self._zoom_factor_change_listener.device_pixel_ratio_changed.connect(self._invalidate_zoom_factor)
3847

3948
from mpvqc.utility import get_main_window
4049

41-
self._window = get_main_window()
50+
self._window: QWindow = get_main_window()
4251
self._window.installEventFilter(self._zoom_factor_change_listener)
52+
self._window.screenChanged.connect(self._on_screen_changed)
53+
54+
self._screen: QScreen = self._window.screen()
55+
self._screen.refreshRateChanged.connect(self._invalidate_refresh_rate)
56+
57+
@property
58+
def display_zoom_factor(self) -> float:
59+
return self._zoom_factor
4360

4461
@Slot()
4562
def _invalidate_zoom_factor(self, *_) -> None:
@@ -48,8 +65,21 @@ def _invalidate_zoom_factor(self, *_) -> None:
4865
self.display_zoom_factor_changed.emit(zoom_factor)
4966

5067
@property
51-
def display_zoom_factor(self) -> float:
52-
return self._zoom_factor
68+
def refresh_rate(self) -> float:
69+
return self._refresh_rate
70+
71+
@Slot(QScreen)
72+
def _on_screen_changed(self, screen: QScreen) -> None:
73+
self._screen.refreshRateChanged.disconnect(self._invalidate_refresh_rate)
74+
self._screen = screen
75+
self._screen.refreshRateChanged.connect(self._invalidate_refresh_rate)
76+
self._invalidate_refresh_rate()
77+
78+
@Slot(float)
79+
def _invalidate_refresh_rate(self, *_) -> None:
80+
if (refresh_rate := get_refresh_rate()) != self._refresh_rate:
81+
self._refresh_rate = refresh_rate
82+
self.refresh_rate_changed.emit(refresh_rate)
5383

5484
def get_window_button_preference(self) -> WindowButtonPreference:
5585
match platform.system():
@@ -67,6 +97,12 @@ def get_display_zoom_factor() -> float:
6797
return get_main_window().devicePixelRatio()
6898

6999

100+
def get_refresh_rate() -> float:
101+
from mpvqc.utility import get_main_window
102+
103+
return get_main_window().screen().refreshRate()
104+
105+
70106
def read_linux_window_button_preference() -> WindowButtonPreference:
71107
from mpvqc.services.host_integration.portals import SettingsPortal
72108

mpvqc/services/player.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import logging
88
import os
9+
import sys
910
from dataclasses import dataclass
1011
from pathlib import Path
1112
from typing import TYPE_CHECKING
@@ -106,6 +107,10 @@ def init(self, win_id: int | None = None):
106107
self._mpv.observe_property("height", self._on_player_height_changed)
107108
self._mpv.observe_property("width", self._on_player_width_changed)
108109

110+
if sys.platform == "linux" and os.getenv("MPVQC_LINUX_EXPERIMENTAL_RENDERER", "").lower() != "false":
111+
self._host_integration.refresh_rate_changed.connect(self._on_refresh_rate_changed)
112+
self._on_refresh_rate_changed(self._host_integration.refresh_rate)
113+
109114
@property
110115
def mpv(self) -> MPV | None:
111116
return self._mpv
@@ -115,6 +120,11 @@ def _get_mpv_attr(self, attr: str) -> Any | None:
115120
return None
116121
return getattr(self._mpv, attr, None)
117122

123+
def _set_mpv_attr(self, attr: str, value: Any) -> None:
124+
if self._mpv is None:
125+
return
126+
setattr(self._mpv, attr, value)
127+
118128
@property
119129
def mpv_version(self) -> str:
120130
return self._get_mpv_attr("mpv_version") or ""
@@ -173,6 +183,14 @@ def current_time(self) -> int:
173183
def duration(self) -> float:
174184
return self._get_mpv_attr("duration") or 0.0
175185

186+
@property
187+
def is_paused(self) -> bool:
188+
return not self.is_playing
189+
190+
@property
191+
def is_playing(self) -> bool:
192+
return not self._mpv.pause and not self._mpv.idle_active if self._mpv else False
193+
176194
@property
177195
def _track_list(self) -> list[TrackListEntry]:
178196
if self._mpv is None:
@@ -230,6 +248,17 @@ def _on_player_width_changed(self, _, value: int) -> None:
230248
if value is not None:
231249
self.width_changed.emit(value)
232250

251+
@Slot(float)
252+
def _on_refresh_rate_changed(self, new_refresh_rate: float) -> None:
253+
if new_refresh_rate is None:
254+
return
255+
256+
refresh_rate = round(new_refresh_rate, 4)
257+
audio_delay = round(1 / refresh_rate, 4)
258+
259+
logger.info("Adjusting audio_delay to %s due to updated refresh rate %s", audio_delay, refresh_rate)
260+
self._set_mpv_attr("audio_delay", audio_delay)
261+
233262
def move_mouse(self, x: int, y: int) -> None:
234263
zoom_factor = self._host_integration.display_zoom_factor
235264
x = int(x * zoom_factor)

mpvqc/startup.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,19 @@ def perform_startup():
1717

1818

1919
def configure_qt_application_data():
20+
import platform
21+
2022
from PySide6.QtCore import QCoreApplication
2123

2224
QCoreApplication.setApplicationName("mpvQC")
2325
QCoreApplication.setOrganizationName("mpvQC")
2426
QCoreApplication.setApplicationVersion(">>>tag<<<")
2527

28+
if platform.system() == "Linux":
29+
from PySide6.QtGui import QGuiApplication, Qt
30+
31+
QGuiApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
32+
2633

2734
def configure_qt_style():
2835
from PySide6.QtQuickControls2 import QQuickStyle

mpvqc/views/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,21 @@
33
# SPDX-License-Identifier: GPL-3.0-or-later
44

55
# ruff: noqa: F401
6-
from .player_framebuffer_object import MpvqcMpvFrameBufferObjectPyObject
6+
7+
import logging
8+
import os
9+
import platform
10+
11+
logger = logging.getLogger(__name__)
12+
13+
if platform.system() == "Linux":
14+
if os.getenv("MPVQC_LINUX_EXPERIMENTAL_RENDERER", "").lower() == "false":
15+
logger.info("Using default renderer. Unset MPVQC_LINUX_EXPERIMENTAL_RENDERER to enable experimental renderer")
16+
from .player_framebuffer_object import MpvqcMpvFrameBufferObjectPyObject
17+
else:
18+
logger.info("Using experimental renderer. Set MPVQC_LINUX_EXPERIMENTAL_RENDERER=false to disable")
19+
from .player_framebuffer_object_offscreen import MpvqcMpvFrameBufferObjectPyObject
20+
else:
21+
from .player_framebuffer_object import MpvqcMpvFrameBufferObjectPyObject
22+
723
from .player_win_id import MpvWindowPyObject

0 commit comments

Comments
 (0)