From 37f19c5fd3e2688dc8e0c5419d6d1b9b5f7eba77 Mon Sep 17 00:00:00 2001 From: Honney OC Date: Sun, 13 Sep 2026 23:28:15 +0000 Subject: [PATCH] refactor: replace QMediaPlayer with mpv for video playback --- Makefile | 4 +- verify_sync.py | 191 ++++++++++++++++++++----------------------------- 2 files changed, 81 insertions(+), 114 deletions(-) diff --git a/Makefile b/Makefile index 967df04..52b1713 100644 --- a/Makefile +++ b/Makefile @@ -51,8 +51,8 @@ install: fi @echo "=> installing common deps..." @$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed" - @echo "=> optional: sounddevice (playback), openai-whisper (language detection)..." - @$(PIP_INSTALL) sounddevice openai-whisper || echo " optional deps failed (sounddevice/whisper)" + @echo "=> optional: sounddevice (playback), openai-whisper (language detection), mpv (video player)..." + @$(PIP_INSTALL) sounddevice openai-whisper python-mpv || echo " optional deps failed (sounddevice/whisper/mpv)" @$(PY) -c "import torch, sys; print(f'torch {torch.__version__} cuda_available={torch.cuda.is_available()}', end=''); print(f' device={torch.cuda.get_device_name(0)}' if torch.cuda.is_available() else ' (cpu)')" 2>&1 || echo "torch not available, using numpy CPU fallback (still functional for 90s xcorr)" @echo "=> done. Activate: source $(VENV)/bin/activate" diff --git a/verify_sync.py b/verify_sync.py index eaf1bbf..5e38537 100644 --- a/verify_sync.py +++ b/verify_sync.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """ -Verify sync: QMediaPlayer video player + dual waveform display. +Verify sync: mpv video player + dual waveform display. Modes: --mode short : extracted 30s parts (video_a + wav_a + wav_b) --mode full : synced output MKV (extracts audio streams from it) Features: - - Smooth video playback via QMediaPlayer/QVideoWidget + - Smooth video playback via mpv (handles all codecs natively) - Audio playback: A only, B only, or A+B mix via sounddevice - Combined waveform overlay (A and B in different colors) - Two separate waveform graphs with playhead @@ -34,12 +34,20 @@ except ModuleNotFoundError: try: import pyqtgraph as pg - from PyQt6 import QtWidgets, QtCore, QtGui, QtMultimedia, QtMultimediaWidgets + from PyQt6 import QtWidgets, QtCore, QtGui HAS_PG = True except ImportError as e: print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr) sys.exit(1) +try: + import mpv + HAS_MPV = True +except ImportError: + HAS_MPV = False + print("ERROR: python-mpv not installed — pip install python-mpv", file=sys.stderr) + sys.exit(1) + try: import sounddevice as sd HAS_SD = True @@ -108,37 +116,6 @@ def extract_audio_from_mkv(mkv_path: str, stream_index: int, out_wav: str, sr: i return False -def transcode_to_h264_sdr(src: str, dst: str, duration: float = 0) -> bool: - """Transcode video to H.264 SDR MP4 for QMediaPlayer compatibility. - Tries VAAPI hardware encoding first, falls back to CPU x264.""" - for encoder in ["h264_vaapi", "libx264"]: - cmd = ["ffmpeg", "-y", "-i", src] - if duration > 0: - cmd += ["-t", f"{duration:.3f}"] - cmd += ["-map", "0:v:0", "-an"] - if encoder == "h264_vaapi": - cmd += ["-vaapi_device", "/dev/dri/renderD128", - "-vf", "format=nv12,hwupload", - "-c:v", "h264_vaapi", "-qp", "20"] - else: - cmd += ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "20", - "-pix_fmt", "yuv420p", - "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"] - cmd += ["-movflags", "+faststart", dst] - try: - r = subprocess.run(cmd, capture_output=True, timeout=600) - if r.returncode == 0 and os.path.exists(dst): - print(f" encoded with {encoder}") - return True - print(f" {encoder} failed, trying next...", file=sys.stderr) - if os.path.exists(dst): - os.remove(dst) - except Exception: - if os.path.exists(dst): - os.remove(dst) - return False - - def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]: if lag_samples >= 0: a_out = a[lag_samples:] @@ -219,7 +196,7 @@ class VerifyWindow(QtWidgets.QMainWindow): self.audio_player = AudioPlayer() self.default_volume = volume self.duration = 0.0 - self.playing_a_b = False # our custom audio mix playing + self.playing_a_b = False title = "verify_sync" if mode == "short": @@ -242,9 +219,7 @@ class VerifyWindow(QtWidgets.QMainWindow): self._setup_ui() self._draw_waveforms() - - # Set up QMediaPlayer for video - self._setup_video_player() + self._setup_mpv() def _setup_ui(self): central = QtWidgets.QWidget() @@ -252,7 +227,7 @@ class VerifyWindow(QtWidgets.QMainWindow): lay = QtWidgets.QVBoxLayout(central) lay.setSpacing(2) - # ── Video display (QWidget container) ── + # ── Video display (QWidget container for mpv) ── self.video_container = QtWidgets.QWidget() self.video_container.setMinimumHeight(300) self.video_container.setStyleSheet("background: #000;") @@ -317,10 +292,6 @@ class VerifyWindow(QtWidgets.QMainWindow): self.p_a.setXLink(self.p_comb) self.p_b.setXLink(self.p_comb) - # Disable auto-range on all plots to prevent overflow on linked axes - self.p_comb.getViewBox().setAutoVisible(y=False) - self.p_a.getViewBox().setAutoVisible(y=False) - self.p_b.getViewBox().setAutoVisible(y=False) for p in [self.p_comb, self.p_a, self.p_b]: p.enableAutoRange(axis='x', enable=False) p.enableAutoRange(axis='y', enable=False) @@ -379,52 +350,48 @@ class VerifyWindow(QtWidgets.QMainWindow): self.info_label.setStyleSheet("color: #aaa; font-size: 11px;") ctrl.addWidget(self.info_label) - def _setup_video_player(self): - """Set up QMediaPlayer with QVideoWidget for smooth video playback.""" - self.media_player = QtMultimedia.QMediaPlayer() - self.video_output = QtMultimediaWidgets.QVideoWidget() - # Place QVideoWidget inside our container - layout = QtWidgets.QVBoxLayout(self.video_container) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.video_output) + def _setup_mpv(self): + """Set up mpv player embedded in the video container widget.""" + self.mpv_player = mpv.MPV( + wid=str(int(self.video_container.winId())), + input_default_bindings=False, + input_vo_keyboard=False, + cursor_autohide=False, + keep_open=True, + osc=False, + osd_bar=False, + ) + # Mute mpv audio — we play audio via sounddevice + self.mpv_player.audio = "no" - self.media_player.setVideoOutput(self.video_output) - # Mute the video player's own audio — we play audio via sounddevice - self.audio_output = QtMultimedia.QAudioOutput() - self.audio_output.setMuted(True) - self.media_player.setAudioOutput(self.audio_output) + self.mpv_player.play(os.path.abspath(self.video_path)) - # Load the video file - from PyQt6.QtCore import QUrl - self.media_player.setSource(QUrl.fromLocalFile(os.path.abspath(self.video_path))) - - # Timer to sync waveform playheads with video position + # Timer to sync waveform playheads with mpv position self.sync_timer = QtCore.QTimer() self.sync_timer.setInterval(33) # ~30fps self.sync_timer.timeout.connect(self._sync_from_video) - self.media_player.playbackStateChanged.connect(self._on_playback_state) + # Wait for mpv to load duration + self._duration_timer = QtCore.QTimer() + self._duration_timer.setInterval(100) + self._duration_timer.timeout.connect(self._wait_for_duration) + self._duration_timer.start() - # Wait for media loaded to get actual duration - self.media_player.durationChanged.connect(self._on_duration_changed) - - def _on_duration_changed(self, dur_ms): - """QMediaPlayer reports actual video duration in ms.""" - vid_dur = dur_ms / 1000.0 - if vid_dur > 0: - self.info_label.setText( - self.info_label.text().replace( - f"Audio duration: {self.duration:.1f}s", - f"Video: {vid_dur:.1f}s" + def _wait_for_duration(self): + """Poll mpv until duration is available.""" + try: + dur = self.mpv_player.duration + if dur and dur > 0: + self._duration_timer.stop() + vid_dur = float(dur) + self.info_label.setText( + self.info_label.text().replace( + f"Audio duration: {self.duration:.1f}s", + f"Video: {vid_dur:.1f}s" + ) ) - ) - - def _on_playback_state(self, state): - if state == QtMultimedia.QMediaPlayer.PlaybackState.StoppedState: - self.sync_timer.stop() - self.playing_a_b = False - self.btn_play.setText("Play") - self.audio_player.stop() + except Exception: + pass def _draw_waveforms(self): max_pts = 200_000 if self.mode == "full" else 80_000 @@ -474,18 +441,23 @@ class VerifyWindow(QtWidgets.QMainWindow): return f"{h:02d}:{m:02d}:{s:06.3f}" def _sync_from_video(self): - """Timer callback: sync waveform playheads and seek bar to video position.""" - pos_ms = self.media_player.position() - t = pos_ms / 1000.0 + """Timer callback: sync waveform playheads and seek bar to mpv position.""" + try: + t = float(self.mpv_player.time_pos or 0) + except Exception: + t = 0 if t > self.duration: t = self.duration self._set_pos(t) def toggle_play(self): - state = self.media_player.playbackState() - if state == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState: - self.stop_play() - else: + try: + paused = self.mpv_player.pause + if paused is False: + self.stop_play() + else: + self.start_play() + except Exception: self.start_play() def start_play(self): @@ -493,10 +465,13 @@ class VerifyWindow(QtWidgets.QMainWindow): return # Start video playback - self.media_player.play() + self.mpv_player.pause = False # Start audio mix via sounddevice - t = self.media_player.position() / 1000.0 + try: + t = float(self.mpv_player.time_pos or 0) + except Exception: + t = 0 start_samp = int(t * self.sr) if start_samp >= len(self.a_aligned): start_samp = 0 @@ -520,7 +495,7 @@ class VerifyWindow(QtWidgets.QMainWindow): self.sync_timer.start() def stop_play(self): - self.media_player.pause() + self.mpv_player.pause = True self.audio_player.stop() self.playing_a_b = False self.btn_play.setText("Play") @@ -528,21 +503,24 @@ class VerifyWindow(QtWidgets.QMainWindow): def _on_seek_start(self): self._seeking = True - was_playing = self.media_player.playbackState() == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState - if was_playing: + try: + paused = self.mpv_player.pause + self._was_playing = (paused is False) + except Exception: + self._was_playing = False + if self._was_playing: self.stop_play() - self._was_playing = was_playing def _on_seek_move(self, value): frac = value / 1_000_000.0 t = frac * self.duration self._set_pos(t) - self.media_player.setPosition(int(t * 1000)) + self.mpv_player.seek(t, reference='absolute') def _on_seek_end(self): frac = self.seek.value() / 1_000_000.0 t = frac * self.duration - self.media_player.setPosition(int(t * 1000)) + self.mpv_player.seek(t, reference='absolute') self._set_pos(t) self._seeking = False if getattr(self, '_was_playing', False): @@ -559,7 +537,11 @@ class VerifyWindow(QtWidgets.QMainWindow): self.p_comb.setXRange(0, self.duration, padding=0) def closeEvent(self, event): - self.media_player.stop() + try: + self.mpv_player.stop() + self.mpv_player.terminate() + except Exception: + pass self.audio_player.stop() self.sync_timer.stop() if self.temp_dir and os.path.isdir(self.temp_dir): @@ -663,21 +645,6 @@ def main(): print(f"WARNING: extracted video not found ({extracted_video}), using original", file=sys.stderr) temp_dir = None - # Always transcode to H.264 SDR — QMediaPlayer can't reliably decode - # HEVC HDR/DV, FFV1 yuv444p, or other exotic codecs - temp_dir = temp_dir or tempfile.mkdtemp(prefix="verify_sync_") - preview_path = os.path.join(temp_dir, "preview.mp4") - if not os.path.exists(preview_path): - transcode_dur = 30.0 if args.mode == "short" else 0 - print(f"Transcoding to H.264 SDR ({'%.1fs' % transcode_dur if transcode_dur else 'full'})...") - if not transcode_to_h264_sdr(video_path, preview_path, transcode_dur): - print(f"ERROR: transcode failed", file=sys.stderr) - sys.exit(1) - print(f"Preview ready: {preview_path}") - else: - print(f"Using cached preview: {preview_path}") - video_path = preview_path - print(f"Mode: {args.mode}") print(f"Video: {video_path}") print(f"WAV A: {wav_a}")