diff --git a/.gitignore b/.gitignore index 00f2d38..13a9dad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc .venv/ +test_media/seek_validation/ diff --git a/player_widget.py b/player_widget.py new file mode 100644 index 0000000..fe8b5f0 --- /dev/null +++ b/player_widget.py @@ -0,0 +1,110 @@ +import locale +import os +import time + +# The dev machine has a locally-built libmpv in /usr/local/lib that isn't +# registered with ldconfig. LD_LIBRARY_PATH must be set in the environment +# *before this process starts* (dlopen reads it at exec time) -- see run +# scripts / README for the wrapper that does this. + +import mpv +from PyQt6.QtWidgets import QWidget +from PyQt6.QtCore import Qt + + +class PlayerWidget(QWidget): + """Embeds an mpv render surface in a Qt widget. + + Frame-accurate seeking relies on mpv's hr-seek (decode-and-discard to the + exact target instead of snapping to the nearest keyframe), which is why + this exists instead of a QMediaPlayer/HTML5-video-style backend. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors) + self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) + + # QApplication resets LC_NUMERIC to the system locale on construction, + # which undoes python-mpv's own import-time fix and makes libmpv abort + # with "Non-C locale detected" -- reset it again right before libmpv + # actually gets instantiated. + locale.setlocale(locale.LC_NUMERIC, "C") + + self.mpv = mpv.MPV( + wid=str(int(self.winId())), + vo="gpu", + hr_seek="yes", + hr_seek_framedrop="no", + keep_open="yes", + pause=True, + ) + self._fps = None + + def load(self, path: str): + # NOTE: we start the MPV instance with pause=True, so `core-idle` + # never flips to False and mpv.wait_until_playing() would block + # forever. Wait for `duration` to populate instead, which happens + # once the file is loaded regardless of pause state. + self.mpv.play(path) + self.mpv.wait_for_property("duration", timeout=10) + self.mpv.pause = True + self._fps = self.mpv.container_fps or self.mpv.estimated_vf_fps + + def play(self): + self.mpv.pause = False + + def pause(self): + self.mpv.pause = True + + def seek(self, seconds: float): + before = self.mpv.estimated_frame_number + self.mpv.seek(seconds, reference="absolute", precision="exact") + self._wait_for_frame_change(before) + + def step_frame(self, n: int = 1): + # frame-step is async: the command returns immediately and the + # actual decode/seek happens on mpv's internal thread. Without + # waiting for confirmation, rapid-fire commands get coalesced or + # dropped (observed: some steps silently no-op, others jump 2-3 + # frames at once when driven by a fixed sleep guess). + # + # `playback-restart` -- the event mpv's own docs point to for this -- + # turned out to never fire with our vo=gpu Qt-embedded surface (nor + # with vo=null in an isolated test), even though the underlying + # decode/seek completes correctly and quickly. So instead we poll + # `estimated_frame_number` directly until it changes, which was + # confirmed reliable in isolated testing (see scripts/mpv_bare_test.py). + cmd = "frame-step" if n >= 0 else "frame-back-step" + for _ in range(abs(n)): + before = self.mpv.estimated_frame_number + self.mpv.command(cmd) + self._wait_for_frame_change(before) + + def _wait_for_frame_change(self, before, timeout=1.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + current = self.mpv.estimated_frame_number + if current is not None and current != before: + return + time.sleep(0.005) + + def get_position(self) -> float: + return self.mpv.time_pos or 0.0 + + def get_estimated_frame(self) -> int: + return self.mpv.estimated_frame_number or 0 + + def fps(self) -> float: + return self._fps or 0.0 + + def screenshot(self, path: str): + # `estimated_frame_number` updates as soon as the demuxer/decoder + # reports the new position, slightly ahead of the vo=gpu surface + # actually finishing its render -- give it a couple frames' worth + # of slack so the screenshot doesn't grab the previous frame. + time.sleep(0.05) + self.mpv.screenshot_to_file(path, includes="video") + + def shutdown(self): + self.mpv.terminate() diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..13e1885 --- /dev/null +++ b/run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Wrapper to run this project's Python scripts with the right environment. +# +# This dev machine has a locally-built libmpv in /usr/local/lib that isn't +# registered with the system's ldconfig cache, so libmpv.so.2 can't be +# dlopen()'d unless LD_LIBRARY_PATH is set *before the process starts* +# (setting os.environ from inside Python is too late -- ctypes/dlopen reads +# it at exec time). `sudo ldconfig` after adding /usr/local/lib to +# /etc/ld.so.conf.d/ would fix this system-wide instead, if preferred. +set -e +cd "$(dirname "$0")" +export LD_LIBRARY_PATH="/usr/local/lib:${LD_LIBRARY_PATH}" +exec .venv/bin/python3 "$@" diff --git a/scripts/mpv_bare_test.py b/scripts/mpv_bare_test.py new file mode 100644 index 0000000..ad28f72 --- /dev/null +++ b/scripts/mpv_bare_test.py @@ -0,0 +1,34 @@ +import locale +locale.setlocale(locale.LC_NUMERIC, "C") +import mpv +import time +from pathlib import Path + +CLIP = str(Path(__file__).resolve().parent.parent / "test_media" / "sync_test_INSYNC.mp4") + +m = mpv.MPV(hr_seek="yes", hr_seek_framedrop="no", keep_open="yes", pause=True, vo="null") + +events_seen = [] +@m.event_callback('playback-restart', 'seek', 'file-loaded') +def cb(evt): + events_seen.append(str(evt)) + +m.play(CLIP) +m.wait_for_property("duration", timeout=10) +m.pause = True +print("loaded, duration=", m.duration, "fps=", m.container_fps) + +m.seek(4.9, reference="absolute", precision="exact") +time.sleep(0.5) +print("after seek: time_pos=", m.time_pos, "frame=", m.estimated_frame_number) + +for i in range(5): + before = m.time_pos + try: + with m.prepare_and_wait_for_event('playback-restart', timeout=2.0): + m.command('frame-step') + print(f"step {i}: OK via event, time_pos={m.time_pos} frame={m.estimated_frame_number}") + except TimeoutError: + print(f"step {i}: TIMEOUT waiting for playback-restart, time_pos={m.time_pos} frame={m.estimated_frame_number} (before={before})") + +m.terminate() diff --git a/scripts/validate_seek.py b/scripts/validate_seek.py new file mode 100644 index 0000000..95041de --- /dev/null +++ b/scripts/validate_seek.py @@ -0,0 +1,62 @@ +"""Standalone validation harness for build-order step 1: confirm python-mpv +gives frame-accurate seeking before any of the rest of the UI is built. + +Steps frame-by-frame across the known flash event in the synthetic test clip +(white flash at t=5.000s, frame 150 @ 30fps) and dumps a screenshot + reported +position/frame-number per step, so the results can be inspected directly +instead of trusted on faith. +""" +import sys +import time +from pathlib import Path + +from PyQt6.QtWidgets import QApplication + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from player_widget import PlayerWidget + +CLIP = str(Path(__file__).resolve().parent.parent / "test_media" / "sync_test_INSYNC.mp4") +OUT_DIR = Path(__file__).resolve().parent.parent / "test_media" / "seek_validation" + + +def main(): + OUT_DIR.mkdir(exist_ok=True) + app = QApplication(sys.argv) + + w = PlayerWidget() + w.resize(640, 480) + w.show() + app.processEvents() + + w.load(CLIP) + app.processEvents() + print(f"fps reported by mpv: {w.fps()}") + + # Land a few frames before the flash (frame 150 == t=5.000s), then + # single-step across it. + w.seek(4.900) + app.processEvents() + time.sleep(0.2) + app.processEvents() + + results = [] + for i in range(8): + pos = w.get_position() + frame = w.get_estimated_frame() + shot = OUT_DIR / f"step_{i:02d}_frame{frame}.png" + w.screenshot(str(shot)) + results.append((i, pos, frame, shot.name)) + print(f"step {i}: time_pos={pos:.4f}s estimated_frame={frame} -> {shot.name}") + w.step_frame(1) + app.processEvents() + + w.shutdown() + + print("\nExpected: flash frame is frame index 150 (t=5.000-5.033s).") + print("Check the dumped PNGs in test_media/seek_validation/ to confirm") + print("exactly one frame is white/bright and it lines up with the") + print("reported frame number close to 150.") + + +if __name__ == "__main__": + main()