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