refactor: replace QMediaPlayer with mpv for video playback
This commit is contained in:
1 parent
5fe90af246
commit
37f19c5fd3
2 files changed
+74
-107
No files matched your search
@@ -51,8 +51,8 @@ install:
|
|||||||
fi
|
fi
|
||||||
@echo "=> installing common deps..."
|
@echo "=> installing common deps..."
|
||||||
@$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed"
|
@$(PIP_INSTALL) soundfile scipy numpy pyqtgraph PyQt6 || echo " common deps failed"
|
||||||
@echo "=> optional: sounddevice (playback), openai-whisper (language detection)..."
|
@echo "=> optional: sounddevice (playback), openai-whisper (language detection), mpv (video player)..."
|
||||||
@$(PIP_INSTALL) sounddevice openai-whisper || echo " optional deps failed (sounddevice/whisper)"
|
@$(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)"
|
@$(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"
|
@echo "=> done. Activate: source $(VENV)/bin/activate"
|
||||||
|
|
||||||
|
|||||||
+72
-105
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Verify sync: QMediaPlayer video player + dual waveform display.
|
Verify sync: mpv video player + dual waveform display.
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
--mode short : extracted 30s parts (video_a + wav_a + wav_b)
|
--mode short : extracted 30s parts (video_a + wav_a + wav_b)
|
||||||
--mode full : synced output MKV (extracts audio streams from it)
|
--mode full : synced output MKV (extracts audio streams from it)
|
||||||
|
|
||||||
Features:
|
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
|
- Audio playback: A only, B only, or A+B mix via sounddevice
|
||||||
- Combined waveform overlay (A and B in different colors)
|
- Combined waveform overlay (A and B in different colors)
|
||||||
- Two separate waveform graphs with playhead
|
- Two separate waveform graphs with playhead
|
||||||
@@ -34,12 +34,20 @@ except ModuleNotFoundError:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PyQt6 import QtWidgets, QtCore, QtGui, QtMultimedia, QtMultimediaWidgets
|
from PyQt6 import QtWidgets, QtCore, QtGui
|
||||||
HAS_PG = True
|
HAS_PG = True
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr)
|
print("ERROR: pyqtgraph/PyQt6 not installed — run make install", file=sys.stderr)
|
||||||
sys.exit(1)
|
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:
|
try:
|
||||||
import sounddevice as sd
|
import sounddevice as sd
|
||||||
HAS_SD = True
|
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
|
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]:
|
def apply_lag(a: np.ndarray, b: np.ndarray, lag_samples: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
if lag_samples >= 0:
|
if lag_samples >= 0:
|
||||||
a_out = a[lag_samples:]
|
a_out = a[lag_samples:]
|
||||||
@@ -219,7 +196,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.audio_player = AudioPlayer()
|
self.audio_player = AudioPlayer()
|
||||||
self.default_volume = volume
|
self.default_volume = volume
|
||||||
self.duration = 0.0
|
self.duration = 0.0
|
||||||
self.playing_a_b = False # our custom audio mix playing
|
self.playing_a_b = False
|
||||||
|
|
||||||
title = "verify_sync"
|
title = "verify_sync"
|
||||||
if mode == "short":
|
if mode == "short":
|
||||||
@@ -242,9 +219,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
|
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
self._draw_waveforms()
|
self._draw_waveforms()
|
||||||
|
self._setup_mpv()
|
||||||
# Set up QMediaPlayer for video
|
|
||||||
self._setup_video_player()
|
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
central = QtWidgets.QWidget()
|
central = QtWidgets.QWidget()
|
||||||
@@ -252,7 +227,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
lay = QtWidgets.QVBoxLayout(central)
|
lay = QtWidgets.QVBoxLayout(central)
|
||||||
lay.setSpacing(2)
|
lay.setSpacing(2)
|
||||||
|
|
||||||
# ── Video display (QWidget container) ──
|
# ── Video display (QWidget container for mpv) ──
|
||||||
self.video_container = QtWidgets.QWidget()
|
self.video_container = QtWidgets.QWidget()
|
||||||
self.video_container.setMinimumHeight(300)
|
self.video_container.setMinimumHeight(300)
|
||||||
self.video_container.setStyleSheet("background: #000;")
|
self.video_container.setStyleSheet("background: #000;")
|
||||||
@@ -317,10 +292,6 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
|
|
||||||
self.p_a.setXLink(self.p_comb)
|
self.p_a.setXLink(self.p_comb)
|
||||||
self.p_b.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]:
|
for p in [self.p_comb, self.p_a, self.p_b]:
|
||||||
p.enableAutoRange(axis='x', enable=False)
|
p.enableAutoRange(axis='x', enable=False)
|
||||||
p.enableAutoRange(axis='y', 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;")
|
self.info_label.setStyleSheet("color: #aaa; font-size: 11px;")
|
||||||
ctrl.addWidget(self.info_label)
|
ctrl.addWidget(self.info_label)
|
||||||
|
|
||||||
def _setup_video_player(self):
|
def _setup_mpv(self):
|
||||||
"""Set up QMediaPlayer with QVideoWidget for smooth video playback."""
|
"""Set up mpv player embedded in the video container widget."""
|
||||||
self.media_player = QtMultimedia.QMediaPlayer()
|
self.mpv_player = mpv.MPV(
|
||||||
self.video_output = QtMultimediaWidgets.QVideoWidget()
|
wid=str(int(self.video_container.winId())),
|
||||||
# Place QVideoWidget inside our container
|
input_default_bindings=False,
|
||||||
layout = QtWidgets.QVBoxLayout(self.video_container)
|
input_vo_keyboard=False,
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
cursor_autohide=False,
|
||||||
layout.addWidget(self.video_output)
|
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)
|
self.mpv_player.play(os.path.abspath(self.video_path))
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Load the video file
|
# Timer to sync waveform playheads with mpv position
|
||||||
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
|
|
||||||
self.sync_timer = QtCore.QTimer()
|
self.sync_timer = QtCore.QTimer()
|
||||||
self.sync_timer.setInterval(33) # ~30fps
|
self.sync_timer.setInterval(33) # ~30fps
|
||||||
self.sync_timer.timeout.connect(self._sync_from_video)
|
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
|
def _wait_for_duration(self):
|
||||||
self.media_player.durationChanged.connect(self._on_duration_changed)
|
"""Poll mpv until duration is available."""
|
||||||
|
try:
|
||||||
def _on_duration_changed(self, dur_ms):
|
dur = self.mpv_player.duration
|
||||||
"""QMediaPlayer reports actual video duration in ms."""
|
if dur and dur > 0:
|
||||||
vid_dur = dur_ms / 1000.0
|
self._duration_timer.stop()
|
||||||
if vid_dur > 0:
|
vid_dur = float(dur)
|
||||||
self.info_label.setText(
|
self.info_label.setText(
|
||||||
self.info_label.text().replace(
|
self.info_label.text().replace(
|
||||||
f"Audio duration: {self.duration:.1f}s",
|
f"Audio duration: {self.duration:.1f}s",
|
||||||
f"Video: {vid_dur:.1f}s"
|
f"Video: {vid_dur:.1f}s"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
def _on_playback_state(self, state):
|
pass
|
||||||
if state == QtMultimedia.QMediaPlayer.PlaybackState.StoppedState:
|
|
||||||
self.sync_timer.stop()
|
|
||||||
self.playing_a_b = False
|
|
||||||
self.btn_play.setText("Play")
|
|
||||||
self.audio_player.stop()
|
|
||||||
|
|
||||||
def _draw_waveforms(self):
|
def _draw_waveforms(self):
|
||||||
max_pts = 200_000 if self.mode == "full" else 80_000
|
max_pts = 200_000 if self.mode == "full" else 80_000
|
||||||
@@ -474,29 +441,37 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
return f"{h:02d}:{m:02d}:{s:06.3f}"
|
return f"{h:02d}:{m:02d}:{s:06.3f}"
|
||||||
|
|
||||||
def _sync_from_video(self):
|
def _sync_from_video(self):
|
||||||
"""Timer callback: sync waveform playheads and seek bar to video position."""
|
"""Timer callback: sync waveform playheads and seek bar to mpv position."""
|
||||||
pos_ms = self.media_player.position()
|
try:
|
||||||
t = pos_ms / 1000.0
|
t = float(self.mpv_player.time_pos or 0)
|
||||||
|
except Exception:
|
||||||
|
t = 0
|
||||||
if t > self.duration:
|
if t > self.duration:
|
||||||
t = self.duration
|
t = self.duration
|
||||||
self._set_pos(t)
|
self._set_pos(t)
|
||||||
|
|
||||||
def toggle_play(self):
|
def toggle_play(self):
|
||||||
state = self.media_player.playbackState()
|
try:
|
||||||
if state == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState:
|
paused = self.mpv_player.pause
|
||||||
|
if paused is False:
|
||||||
self.stop_play()
|
self.stop_play()
|
||||||
else:
|
else:
|
||||||
self.start_play()
|
self.start_play()
|
||||||
|
except Exception:
|
||||||
|
self.start_play()
|
||||||
|
|
||||||
def start_play(self):
|
def start_play(self):
|
||||||
if self.duration <= 0:
|
if self.duration <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Start video playback
|
# Start video playback
|
||||||
self.media_player.play()
|
self.mpv_player.pause = False
|
||||||
|
|
||||||
# Start audio mix via sounddevice
|
# 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)
|
start_samp = int(t * self.sr)
|
||||||
if start_samp >= len(self.a_aligned):
|
if start_samp >= len(self.a_aligned):
|
||||||
start_samp = 0
|
start_samp = 0
|
||||||
@@ -520,7 +495,7 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.sync_timer.start()
|
self.sync_timer.start()
|
||||||
|
|
||||||
def stop_play(self):
|
def stop_play(self):
|
||||||
self.media_player.pause()
|
self.mpv_player.pause = True
|
||||||
self.audio_player.stop()
|
self.audio_player.stop()
|
||||||
self.playing_a_b = False
|
self.playing_a_b = False
|
||||||
self.btn_play.setText("Play")
|
self.btn_play.setText("Play")
|
||||||
@@ -528,21 +503,24 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
|
|
||||||
def _on_seek_start(self):
|
def _on_seek_start(self):
|
||||||
self._seeking = True
|
self._seeking = True
|
||||||
was_playing = self.media_player.playbackState() == QtMultimedia.QMediaPlayer.PlaybackState.PlayingState
|
try:
|
||||||
if was_playing:
|
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.stop_play()
|
||||||
self._was_playing = was_playing
|
|
||||||
|
|
||||||
def _on_seek_move(self, value):
|
def _on_seek_move(self, value):
|
||||||
frac = value / 1_000_000.0
|
frac = value / 1_000_000.0
|
||||||
t = frac * self.duration
|
t = frac * self.duration
|
||||||
self._set_pos(t)
|
self._set_pos(t)
|
||||||
self.media_player.setPosition(int(t * 1000))
|
self.mpv_player.seek(t, reference='absolute')
|
||||||
|
|
||||||
def _on_seek_end(self):
|
def _on_seek_end(self):
|
||||||
frac = self.seek.value() / 1_000_000.0
|
frac = self.seek.value() / 1_000_000.0
|
||||||
t = frac * self.duration
|
t = frac * self.duration
|
||||||
self.media_player.setPosition(int(t * 1000))
|
self.mpv_player.seek(t, reference='absolute')
|
||||||
self._set_pos(t)
|
self._set_pos(t)
|
||||||
self._seeking = False
|
self._seeking = False
|
||||||
if getattr(self, '_was_playing', False):
|
if getattr(self, '_was_playing', False):
|
||||||
@@ -559,7 +537,11 @@ class VerifyWindow(QtWidgets.QMainWindow):
|
|||||||
self.p_comb.setXRange(0, self.duration, padding=0)
|
self.p_comb.setXRange(0, self.duration, padding=0)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
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.audio_player.stop()
|
||||||
self.sync_timer.stop()
|
self.sync_timer.stop()
|
||||||
if self.temp_dir and os.path.isdir(self.temp_dir):
|
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)
|
print(f"WARNING: extracted video not found ({extracted_video}), using original", file=sys.stderr)
|
||||||
temp_dir = None
|
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"Mode: {args.mode}")
|
||||||
print(f"Video: {video_path}")
|
print(f"Video: {video_path}")
|
||||||
print(f"WAV A: {wav_a}")
|
print(f"WAV A: {wav_a}")
|
||||||
|
|||||||
Reference in new issue
Block a user