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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user