Add sync preview, restore Detect All Claps button, reorder markers panel

- Preview Sync button builds a short (8s) lossless clip around the
  marked events with the current offset applied, and plays it in a
  small popup PreviewDialog (its own independent PlayerWidget/mpv
  instance) -- lets you check the fix sounds/looks right before
  committing to a full export. ExportWorker gained optional
  clip_start/clip_duration so both the real export and this preview
  share the same underlying export_synced() call.
- Detect All Claps is back as a button in the Sync markers panel
  (alongside Mark Audio/Video Event), in addition to staying in the
  Tools menu -- both widgets' enabled state are kept in sync.
- Sync markers panel now shows Mark Audio Event before Mark Video
  Event.

The clip-mode branch of ffmpeg_export.py (the -ss/-t-on-both-inputs
path ffdelay's own notes flag as easy to get subtly wrong) hadn't been
covered by the existing roundtrip test, which only exercised full-file
export -- added a clip-mode case there, independently verified via the
same scene-change/silence-detection technique as the other cases:
29.4ms drift after the fix, well under one frame.

All of this was built and verified entirely on a virtual Xvfb display
(DISPLAY=:99) per the user's request to stop interrupting their real
desktop -- real mouse clicks and a real (non-monkeypatched) preview
dialog were exercised there, screenshotted, and confirmed correct,
without ever touching the real screen.
This commit is contained in:
ThePendulum
2026-07-26 03:01:06 +02:00
parent 6f76b3f15a
commit eb7c1aa500
4 changed files with 277 additions and 18 deletions
+143 -11
View File
@@ -1,4 +1,6 @@
import os
import sys
import tempfile
from pathlib import Path
from typing import Optional
@@ -7,7 +9,7 @@ from PyQt6.QtGui import QShortcut, QKeySequence, QAction
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QSlider, QCheckBox, QDoubleSpinBox, QFileDialog,
QMessageBox, QPlainTextEdit, QGroupBox,
QMessageBox, QPlainTextEdit, QGroupBox, QDialog,
)
from player_widget import PlayerWidget
@@ -44,15 +46,20 @@ class ExportWorker(QThread):
finished_ok = pyqtSignal(object) # ExportResult
finished_err = pyqtSignal(str, str) # message, stderr
def __init__(self, input_path, delay_ms, output_path):
def __init__(self, input_path, delay_ms, output_path, clip_start=None, clip_duration=None):
super().__init__()
self.input_path = input_path
self.delay_ms = delay_ms
self.output_path = output_path
self.clip_start = clip_start
self.clip_duration = clip_duration
def run(self):
try:
result: ExportResult = export_synced(self.input_path, self.delay_ms, self.output_path)
result: ExportResult = export_synced(
self.input_path, self.delay_ms, self.output_path,
clip_start=self.clip_start, clip_duration=self.clip_duration,
)
self.finished_ok.emit(result)
except FfmpegExportError as e:
self.finished_err.emit(str(e), e.stderr)
@@ -80,6 +87,51 @@ class DetectWorker(QThread):
self.finished_err.emit(str(e))
class PreviewDialog(QDialog):
"""Small popup player for a short, already-synced clip (see
MainWindow._on_preview_clicked) -- a second, independent PlayerWidget/mpv
instance, not a reuse of the main one, so the user can flip back and
forth between the main timeline and the preview without one tearing
down the other.
"""
def __init__(self, path: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Preview — sync-corrected clip")
self.resize(480, 420)
self.player = PlayerWidget()
self.player.setMinimumHeight(320)
layout = QVBoxLayout(self)
layout.addWidget(self.player, stretch=1)
controls = QHBoxLayout()
self.play_button = QPushButton("Pause")
self.play_button.clicked.connect(self._toggle_play)
controls.addWidget(self.play_button)
controls.addStretch(1)
close_button = QPushButton("Close")
close_button.clicked.connect(self.close)
controls.addWidget(close_button)
layout.addLayout(controls)
self.player.load(path)
self.player.play()
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 closeEvent(self, event):
self.player.shutdown()
super().closeEvent(event)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
@@ -92,6 +144,8 @@ class MainWindow(QMainWindow):
self.offset: Optional[SyncOffset] = None
self._export_worker: Optional[ExportWorker] = None
self._detect_worker: Optional[DetectWorker] = None
self._preview_worker: Optional[ExportWorker] = None
self._preview_temp_path: Optional[str] = None
self._slider_being_dragged = False
self.player = PlayerWidget()
@@ -169,22 +223,29 @@ class MainWindow(QMainWindow):
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)
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)
marker_layout.addStretch(1)
self.detect_all_button = QPushButton("Detect All Claps")
self.detect_all_button.setEnabled(False)
self.detect_all_button.clicked.connect(self._on_detect_all_clicked)
marker_layout.addWidget(self.detect_all_button)
layout.addWidget(marker_group)
# Offset + export row
export_group = QGroupBox("Offset and export")
# Offset + preview + export row
export_group = QGroupBox("Offset, preview and export")
export_layout = QHBoxLayout(export_group)
self.offset_label = QLabel("Offset: — (mark both events)")
@@ -197,6 +258,11 @@ class MainWindow(QMainWindow):
self.override_spinbox.setEnabled(False)
export_layout.addWidget(self.override_spinbox)
self.preview_button = QPushButton("Preview Sync…")
self.preview_button.setEnabled(False)
self.preview_button.clicked.connect(self._on_preview_clicked)
export_layout.addWidget(self.preview_button)
self.export_button = QPushButton("Export…")
self.export_button.setEnabled(False)
self.export_button.clicked.connect(self._on_export_clicked)
@@ -271,6 +337,7 @@ class MainWindow(QMainWindow):
self.audio_mark_label.setText("audio: —")
self.offset_label.setText("Offset: — (mark both events)")
self.export_button.setEnabled(False)
self.preview_button.setEnabled(False)
self.override_spinbox.setEnabled(False)
self.log_view.clear()
@@ -283,6 +350,7 @@ class MainWindow(QMainWindow):
self.marker_strip.clear()
self.statusBar().showMessage(f"Loaded {Path(path).name}", 4000)
self.detect_all_action.setEnabled(True)
self.detect_all_button.setEnabled(True)
self.detect_next_action.setEnabled(True)
def _toggle_play(self):
@@ -353,6 +421,67 @@ class MainWindow(QMainWindow):
self.override_spinbox.setValue(self.offset.delay_ms)
self.override_spinbox.setEnabled(True)
self.export_button.setEnabled(True)
self.preview_button.setEnabled(True)
# -- preview -----------------------------------------------------
PREVIEW_MARGIN_SEC = 3.0
PREVIEW_DURATION_SEC = 8.0
def _on_preview_clicked(self):
if self.input_path is None or self.offset is None:
return
delay_ms = self.override_spinbox.value()
# Build just a short clip around the marked events rather than the
# whole file -- lossless stream-copy of a few seconds is fast
# regardless of how long the source file is, unlike previewing the
# full result. Centering on the midpoint of the two original marks
# keeps both the video and audio events (and wherever the offset
# moves them to) inside the window for typical human-scale marking
# errors.
center = (self.video_mark + self.audio_mark) / 2.0
clip_start = max(center - self.PREVIEW_MARGIN_SEC, 0.0)
input_p = Path(self.input_path)
fd, temp_path = tempfile.mkstemp(suffix=input_p.suffix, prefix="avsync_preview_")
os.close(fd)
self._preview_temp_path = temp_path
self.preview_button.setEnabled(False)
self.preview_button.setText("Building preview…")
self.statusBar().showMessage("Building preview clip…")
self._preview_worker = ExportWorker(
self.input_path, delay_ms, temp_path,
clip_start=f"{clip_start:.3f}", clip_duration=self.PREVIEW_DURATION_SEC,
)
self._preview_worker.finished_ok.connect(self._on_preview_ready)
self._preview_worker.finished_err.connect(self._on_preview_failed)
self._preview_worker.start()
def _on_preview_ready(self, result: ExportResult):
self.preview_button.setEnabled(True)
self.preview_button.setText("Preview Sync…")
self.statusBar().showMessage("Preview ready.", 4000)
if result.stderr.strip():
self.log_view.appendPlainText("ffmpeg preview log:\n" + result.stderr)
dialog = PreviewDialog(self._preview_temp_path, parent=self)
dialog.exec()
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
os.unlink(self._preview_temp_path)
self._preview_temp_path = None
def _on_preview_failed(self, message: str, stderr: str):
self.preview_button.setEnabled(True)
self.preview_button.setText("Preview Sync…")
self.statusBar().showMessage("Preview failed (see log).", 8000)
self.log_view.appendPlainText(f"Preview FAILED: {message}\n{stderr}")
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
os.unlink(self._preview_temp_path)
self._preview_temp_path = None
# -- clap detection -----------------------------------------------------
@@ -371,6 +500,7 @@ class MainWindow(QMainWindow):
if self.input_path is None:
return
self.detect_all_action.setEnabled(False)
self.detect_all_button.setEnabled(False)
self.detect_next_action.setEnabled(False)
self.statusBar().showMessage(label)
@@ -381,6 +511,7 @@ class MainWindow(QMainWindow):
def _on_detect_done(self, events: list):
self.detect_all_action.setEnabled(True)
self.detect_all_button.setEnabled(True)
self.detect_next_action.setEnabled(True)
existing = set(self.marker_strip.markers())
@@ -398,6 +529,7 @@ class MainWindow(QMainWindow):
def _on_detect_failed(self, message: str):
self.detect_all_action.setEnabled(True)
self.detect_all_button.setEnabled(True)
self.detect_next_action.setEnabled(True)
self.statusBar().showMessage("Detection failed (see log).", 8000)
self.log_view.appendPlainText(f"Clap detection FAILED: {message}")