refactor: replace QMediaPlayer with mpv for video playback
This commit is contained in:
1 parent
5fe90af246
commit
37f19c5fd3
2 files changed
+81
-114
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
+79
-112
@@ -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}")
|
||||
|
||||
Reference in new issue
Block a user