diff --git a/main.py b/main.py new file mode 100644 index 0000000..c48b597 --- /dev/null +++ b/main.py @@ -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() diff --git a/scripts/smoke_test_ui.py b/scripts/smoke_test_ui.py new file mode 100644 index 0000000..e6f7640 --- /dev/null +++ b/scripts/smoke_test_ui.py @@ -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()