Build the full UI (build order step 4)
main.py wires up PlayerWidget with transport controls (play/pause, frame-step buttons + ,/. shortcuts, seek slider), mute toggle, mark video/audio buttons, live offset readout with manual override, and an async export flow (QThread worker so ffmpeg doesn't block the UI) that surfaces ffmpeg's stderr log without treating stream-copy warnings as failures. Verified two ways, not just by reading the code: a headless smoke test that drives the real MainWindow/ExportWorker classes through a full mark -> offset -> export cycle on the synthetic fixture, and a live run (actual process, real X11 window, xdotool + import screenshot) confirming the embedded mpv surface actually renders video inside the Qt window rather than staying blank.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QShortcut, QKeySequence
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QSlider, QCheckBox, QDoubleSpinBox, QFileDialog,
|
||||
QMessageBox, QPlainTextEdit, QGroupBox,
|
||||
)
|
||||
|
||||
from player_widget import PlayerWidget
|
||||
from sync_logic import compute_offset, SyncOffset
|
||||
from ffmpeg_export import export_synced, FfmpegExportError, ExportResult
|
||||
|
||||
|
||||
def format_timestamp(seconds: float) -> str:
|
||||
m, s = divmod(max(seconds, 0.0), 60)
|
||||
return f"{int(m):02d}:{s:06.3f}"
|
||||
|
||||
|
||||
class ExportWorker(QThread):
|
||||
finished_ok = pyqtSignal(object) # ExportResult
|
||||
finished_err = pyqtSignal(str, str) # message, stderr
|
||||
|
||||
def __init__(self, input_path, delay_ms, output_path):
|
||||
super().__init__()
|
||||
self.input_path = input_path
|
||||
self.delay_ms = delay_ms
|
||||
self.output_path = output_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
result: ExportResult = export_synced(self.input_path, self.delay_ms, self.output_path)
|
||||
self.finished_ok.emit(result)
|
||||
except FfmpegExportError as e:
|
||||
self.finished_err.emit(str(e), e.stderr)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("A/V Sync Marker")
|
||||
self.resize(960, 720)
|
||||
|
||||
self.input_path: Optional[str] = None
|
||||
self.video_mark: Optional[float] = None
|
||||
self.audio_mark: Optional[float] = None
|
||||
self.offset: Optional[SyncOffset] = None
|
||||
self._export_worker: Optional[ExportWorker] = None
|
||||
self._slider_being_dragged = False
|
||||
|
||||
self.player = PlayerWidget()
|
||||
self.player.setMinimumHeight(400)
|
||||
|
||||
self._build_ui()
|
||||
self._wire_shortcuts()
|
||||
|
||||
self._position_timer = QTimer(self)
|
||||
self._position_timer.setInterval(100)
|
||||
self._position_timer.timeout.connect(self._update_position_ui)
|
||||
self._position_timer.start()
|
||||
|
||||
# -- UI construction ---------------------------------------------------
|
||||
|
||||
def _build_ui(self):
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
|
||||
layout.addWidget(self.player, stretch=1)
|
||||
|
||||
# Transport row: play/pause, frame step, seek slider, position label
|
||||
transport = QHBoxLayout()
|
||||
self.load_button = QPushButton("Load File…")
|
||||
self.load_button.clicked.connect(self._on_load_clicked)
|
||||
transport.addWidget(self.load_button)
|
||||
|
||||
self.play_button = QPushButton("Play")
|
||||
self.play_button.clicked.connect(self._toggle_play)
|
||||
transport.addWidget(self.play_button)
|
||||
|
||||
self.prev_frame_button = QPushButton("⏮ Frame (,)")
|
||||
self.prev_frame_button.clicked.connect(lambda: self._step_frame(-1))
|
||||
transport.addWidget(self.prev_frame_button)
|
||||
|
||||
self.next_frame_button = QPushButton("Frame ⏭ (.)")
|
||||
self.next_frame_button.clicked.connect(lambda: self._step_frame(1))
|
||||
transport.addWidget(self.next_frame_button)
|
||||
|
||||
self.mute_checkbox = QCheckBox("Mute audio")
|
||||
self.mute_checkbox.toggled.connect(self._on_mute_toggled)
|
||||
transport.addWidget(self.mute_checkbox)
|
||||
|
||||
layout.addLayout(transport)
|
||||
|
||||
seek_row = QHBoxLayout()
|
||||
self.position_label = QLabel("00:00.000")
|
||||
seek_row.addWidget(self.position_label)
|
||||
|
||||
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.seek_slider.setRange(0, 0)
|
||||
self.seek_slider.sliderPressed.connect(lambda: setattr(self, "_slider_being_dragged", True))
|
||||
self.seek_slider.sliderReleased.connect(self._on_slider_released)
|
||||
seek_row.addWidget(self.seek_slider, stretch=1)
|
||||
|
||||
self.duration_label = QLabel("00:00.000")
|
||||
seek_row.addWidget(self.duration_label)
|
||||
layout.addLayout(seek_row)
|
||||
|
||||
# Marker row
|
||||
marker_group = QGroupBox("Sync markers")
|
||||
marker_layout = QHBoxLayout(marker_group)
|
||||
|
||||
self.mark_video_button = QPushButton("Mark Video Event")
|
||||
self.mark_video_button.clicked.connect(self._mark_video)
|
||||
marker_layout.addWidget(self.mark_video_button)
|
||||
self.video_mark_label = QLabel("video: —")
|
||||
marker_layout.addWidget(self.video_mark_label)
|
||||
|
||||
self.mark_audio_button = QPushButton("Mark Audio Event")
|
||||
self.mark_audio_button.clicked.connect(self._mark_audio)
|
||||
marker_layout.addWidget(self.mark_audio_button)
|
||||
self.audio_mark_label = QLabel("audio: —")
|
||||
marker_layout.addWidget(self.audio_mark_label)
|
||||
|
||||
layout.addWidget(marker_group)
|
||||
|
||||
# Offset + export row
|
||||
export_group = QGroupBox("Offset and export")
|
||||
export_layout = QHBoxLayout(export_group)
|
||||
|
||||
self.offset_label = QLabel("Offset: — (mark both events)")
|
||||
export_layout.addWidget(self.offset_label, stretch=1)
|
||||
|
||||
export_layout.addWidget(QLabel("Override (ms):"))
|
||||
self.override_spinbox = QDoubleSpinBox()
|
||||
self.override_spinbox.setRange(-600000, 600000)
|
||||
self.override_spinbox.setDecimals(0)
|
||||
self.override_spinbox.setEnabled(False)
|
||||
export_layout.addWidget(self.override_spinbox)
|
||||
|
||||
self.export_button = QPushButton("Export…")
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_button.clicked.connect(self._on_export_clicked)
|
||||
export_layout.addWidget(self.export_button)
|
||||
|
||||
layout.addWidget(export_group)
|
||||
|
||||
self.log_view = QPlainTextEdit()
|
||||
self.log_view.setReadOnly(True)
|
||||
self.log_view.setMaximumHeight(120)
|
||||
self.log_view.setPlaceholderText("ffmpeg output / warnings will appear here after export.")
|
||||
layout.addWidget(self.log_view)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
|
||||
def _wire_shortcuts(self):
|
||||
QShortcut(QKeySequence(","), self, activated=lambda: self._step_frame(-1))
|
||||
QShortcut(QKeySequence("."), self, activated=lambda: self._step_frame(1))
|
||||
QShortcut(QKeySequence(Qt.Key.Key_Space), self, activated=self._toggle_play)
|
||||
|
||||
# -- transport -----------------------------------------------------
|
||||
|
||||
def _on_load_clicked(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Load video file", "", "Video files (*.mp4 *.mov *.mkv *.avi *.webm);;All files (*)")
|
||||
if path:
|
||||
self._load(path)
|
||||
|
||||
def _load(self, path: str):
|
||||
self.input_path = path
|
||||
self.player.load(path)
|
||||
self.video_mark = None
|
||||
self.audio_mark = None
|
||||
self.offset = None
|
||||
self.video_mark_label.setText("video: —")
|
||||
self.audio_mark_label.setText("audio: —")
|
||||
self.offset_label.setText("Offset: — (mark both events)")
|
||||
self.export_button.setEnabled(False)
|
||||
self.override_spinbox.setEnabled(False)
|
||||
self.log_view.clear()
|
||||
|
||||
duration = self.player.mpv.duration or 0.0
|
||||
self.seek_slider.setRange(0, int(duration * 1000))
|
||||
self.duration_label.setText(format_timestamp(duration))
|
||||
self.setWindowTitle(f"A/V Sync Marker — {Path(path).name}")
|
||||
|
||||
def _toggle_play(self):
|
||||
if self.player.mpv.pause:
|
||||
self.player.play()
|
||||
self.play_button.setText("Pause")
|
||||
else:
|
||||
self.player.pause()
|
||||
self.play_button.setText("Play")
|
||||
|
||||
def _step_frame(self, n: int):
|
||||
self.player.pause()
|
||||
self.play_button.setText("Play")
|
||||
self.player.step_frame(n)
|
||||
self._update_position_ui()
|
||||
|
||||
def _on_mute_toggled(self, checked: bool):
|
||||
self.player.mpv.mute = checked
|
||||
|
||||
def _on_slider_released(self):
|
||||
target_sec = self.seek_slider.value() / 1000.0
|
||||
self.player.seek(target_sec)
|
||||
self._slider_being_dragged = False
|
||||
self._update_position_ui()
|
||||
|
||||
def _update_position_ui(self):
|
||||
if self.input_path is None:
|
||||
return
|
||||
pos = self.player.get_position()
|
||||
self.position_label.setText(format_timestamp(pos))
|
||||
if not self._slider_being_dragged:
|
||||
self.seek_slider.blockSignals(True)
|
||||
self.seek_slider.setValue(int(pos * 1000))
|
||||
self.seek_slider.blockSignals(False)
|
||||
|
||||
# -- markers / offset -----------------------------------------------
|
||||
|
||||
def _mark_video(self):
|
||||
self.video_mark = self.player.get_position()
|
||||
self.video_mark_label.setText(f"video: {format_timestamp(self.video_mark)}")
|
||||
self._recompute_offset()
|
||||
|
||||
def _mark_audio(self):
|
||||
self.audio_mark = self.player.get_position()
|
||||
self.audio_mark_label.setText(f"audio: {format_timestamp(self.audio_mark)}")
|
||||
self._recompute_offset()
|
||||
|
||||
def _recompute_offset(self):
|
||||
if self.video_mark is None or self.audio_mark is None:
|
||||
return
|
||||
self.offset = compute_offset(self.video_mark, self.audio_mark)
|
||||
self.offset_label.setText(self.offset.describe())
|
||||
self.override_spinbox.setValue(self.offset.delay_ms)
|
||||
self.override_spinbox.setEnabled(True)
|
||||
self.export_button.setEnabled(True)
|
||||
|
||||
# -- export -----------------------------------------------------
|
||||
|
||||
def _on_export_clicked(self):
|
||||
if self.input_path is None or self.offset is None:
|
||||
return
|
||||
delay_ms = self.override_spinbox.value()
|
||||
|
||||
input_p = Path(self.input_path)
|
||||
suggested = str(input_p.with_name(f"{input_p.stem}_synced{input_p.suffix}"))
|
||||
output_path, _ = QFileDialog.getSaveFileName(self, "Export synced file", suggested)
|
||||
if not output_path:
|
||||
return
|
||||
|
||||
self.export_button.setEnabled(False)
|
||||
self.export_button.setText("Exporting…")
|
||||
self.log_view.appendPlainText(f"Exporting with delay_ms={delay_ms:.0f} …")
|
||||
|
||||
self._export_worker = ExportWorker(self.input_path, delay_ms, output_path)
|
||||
self._export_worker.finished_ok.connect(self._on_export_done)
|
||||
self._export_worker.finished_err.connect(self._on_export_failed)
|
||||
self._export_worker.start()
|
||||
|
||||
def _on_export_done(self, result: ExportResult):
|
||||
self.export_button.setEnabled(True)
|
||||
self.export_button.setText("Export…")
|
||||
self.log_view.appendPlainText("Export finished.")
|
||||
if result.stderr.strip():
|
||||
# ffmpeg logs things like "sps_id out of range" on stream-copy
|
||||
# of screen-recording-style sources; harmless, just surfaced
|
||||
# for visibility rather than treated as failure.
|
||||
self.log_view.appendPlainText("ffmpeg log:\n" + result.stderr)
|
||||
QMessageBox.information(self, "Export complete", "Export finished successfully.")
|
||||
|
||||
def _on_export_failed(self, message: str, stderr: str):
|
||||
self.export_button.setEnabled(True)
|
||||
self.export_button.setText("Export…")
|
||||
self.log_view.appendPlainText(f"Export FAILED: {message}\n{stderr}")
|
||||
QMessageBox.critical(self, "Export failed", f"{message}\n\n{stderr[-2000:]}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.player.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
if len(sys.argv) > 1:
|
||||
window._load(sys.argv[1])
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Launches the real MainWindow, loads the out-of-sync fixture, drives the
|
||||
actual marker -> offset -> export flow through the same code the UI buttons
|
||||
call (not a copy of the logic), and grabs screenshots so it can be visually
|
||||
checked instead of just trusting the app "should" work.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import main as app_main
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CLIP = str(ROOT / "test_media" / "sync_test_OUTOFSYNC_audio+350ms.mp4")
|
||||
OUT_DIR = ROOT / "test_media" / "seek_validation"
|
||||
EXPORT_OUT = str(OUT_DIR / "_ui_smoke_export.mp4")
|
||||
|
||||
|
||||
def main():
|
||||
OUT_DIR.mkdir(exist_ok=True)
|
||||
qapp = QApplication(sys.argv)
|
||||
|
||||
win = app_main.MainWindow()
|
||||
win.show()
|
||||
qapp.processEvents()
|
||||
|
||||
win._load(CLIP)
|
||||
qapp.processEvents()
|
||||
time.sleep(0.3)
|
||||
qapp.processEvents()
|
||||
print(f"loaded: duration slider range = {win.seek_slider.maximum()}ms")
|
||||
|
||||
win.player.seek(5.000)
|
||||
qapp.processEvents()
|
||||
win._mark_video()
|
||||
print(f"video mark: {win.video_mark:.3f}s -> label: {win.video_mark_label.text()}")
|
||||
|
||||
win.player.seek(5.350)
|
||||
qapp.processEvents()
|
||||
win._mark_audio()
|
||||
print(f"audio mark: {win.audio_mark:.3f}s -> label: {win.audio_mark_label.text()}")
|
||||
|
||||
print(f"offset label: {win.offset_label.text()}")
|
||||
print(f"export button enabled: {win.export_button.isEnabled()}")
|
||||
assert win.export_button.isEnabled()
|
||||
# hr-seek lands on the nearest frame boundary (33.3ms steps @ 30fps), so
|
||||
# marks won't hit 5.350 exactly -- check within one frame, not bit-exact.
|
||||
assert abs(win.offset.delay_ms - (-350.0)) < 34
|
||||
assert win.offset.target == "video"
|
||||
|
||||
win.grab().save(str(OUT_DIR / "ui_smoke_after_marking.png"))
|
||||
|
||||
# Drive export directly through the worker (skip the file-save dialog,
|
||||
# which needs a real user) but through the same ExportWorker class the
|
||||
# button uses.
|
||||
done = {"ok": False, "err": None}
|
||||
|
||||
def on_ok(result):
|
||||
done["ok"] = True
|
||||
print("export finished OK, ffmpeg log:", result.stderr[:200])
|
||||
|
||||
def on_err(msg, stderr):
|
||||
done["err"] = (msg, stderr)
|
||||
|
||||
worker = app_main.ExportWorker(win.input_path, win.offset.delay_ms, EXPORT_OUT)
|
||||
worker.finished_ok.connect(on_ok)
|
||||
worker.finished_err.connect(on_err)
|
||||
worker.start()
|
||||
|
||||
deadline = time.time() + 15
|
||||
while not done["ok"] and done["err"] is None and time.time() < deadline:
|
||||
qapp.processEvents()
|
||||
time.sleep(0.05)
|
||||
|
||||
if done["err"]:
|
||||
raise RuntimeError(f"export failed: {done['err']}")
|
||||
assert done["ok"], "export did not finish within timeout"
|
||||
assert Path(EXPORT_OUT).exists()
|
||||
print(f"exported file exists: {EXPORT_OUT}, size={Path(EXPORT_OUT).stat().st_size} bytes")
|
||||
|
||||
win.player.shutdown()
|
||||
print("\nUI SMOKE TEST PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user