Files
WeAreSyncing/main.py
T
ThePendulum 660275bf02 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.
2026-07-26 00:53:51 +02:00

297 lines
11 KiB
Python

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()