Replace preview popup with Kdenlive-style Source/Preview tabs

Removes PreviewDialog; the video area is now a QTabWidget with two
tabs, each backed by its own independent, long-lived PlayerWidget/mpv
instance:
- Source: the main player (unchanged transport/marking controls below
  it still apply here).
- Preview: a second player with its own minimal transport (play/pause
  + position label), auto-plays and switches to this tab as soon as a
  preview clip finishes building.

The preview player persists for the whole session instead of being
created per-preview -- building a new preview loads the new clip into
the same mpv instance and only deletes the old temp file afterward
(unsafe to unlink while mpv still has it open). Loading a new source
file drops any existing preview, since it belonged to the previous
file's offset.

Found and fixed a real crash bug while testing the close-window path
(not something the earlier popup-based tests exercised): closeEvent
shut down both mpv instances but left the 100ms position-update QTimer
running, so a tick landing between shutdown and process exit called
get_position() on an already-terminated core and hard-crashed the
process (native abort, not a catchable Python exception). Fixed by
stopping the timer first in closeEvent.

Verified on the virtual Xvfb display: automated test covers the tab
switch, independent playback state between tabs, and temp-file
lifecycle across rebuilding a preview and closing the window; also
driven live with real xdotool clicks (mark both events, build preview,
confirm auto-switch to the Preview tab with the clip already playing).
This commit is contained in:
ThePendulum
2026-07-26 03:17:01 +02:00
parent eb7c1aa500
commit 0a8f9f45bd
2 changed files with 132 additions and 109 deletions
+81 -65
View File
@@ -9,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, QDialog,
QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget,
)
from player_widget import PlayerWidget
@@ -87,51 +87,6 @@ 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__()
@@ -145,7 +100,8 @@ class MainWindow(QMainWindow):
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._preview_temp_path: Optional[str] = None # currently loaded in preview_player
self._pending_preview_path: Optional[str] = None # being built by _preview_worker
self._slider_being_dragged = False
self.player = PlayerWidget()
@@ -153,6 +109,14 @@ class MainWindow(QMainWindow):
self.player.fileDropped.connect(self._load)
self.player.rightClicked.connect(self._toggle_play)
# Independent second mpv instance for the Preview tab (Kdenlive-style
# project/clip monitor tabs) -- kept alive for the whole session
# rather than created per-preview, so switching tabs back and forth
# doesn't tear down and rebuild an mpv instance each time.
self.preview_player = PlayerWidget()
self.preview_player.setMinimumHeight(320)
self.preview_player.rightClicked.connect(self._toggle_preview_play)
self._build_ui()
self._build_menu()
self._wire_shortcuts()
@@ -168,7 +132,26 @@ class MainWindow(QMainWindow):
central = QWidget()
layout = QVBoxLayout(central)
layout.addWidget(self.player, stretch=1)
self.video_tabs = QTabWidget()
self.video_tabs.addTab(self.player, "Source")
preview_tab = QWidget()
preview_tab_layout = QVBoxLayout(preview_tab)
preview_tab_layout.setContentsMargins(0, 0, 0, 0)
preview_tab_layout.addWidget(self.preview_player, stretch=1)
preview_controls = QHBoxLayout()
self.preview_play_button = QPushButton("Play")
self.preview_play_button.clicked.connect(self._toggle_preview_play)
preview_controls.addWidget(self.preview_play_button)
self.preview_position_label = QLabel("00:00.000")
preview_controls.addWidget(self.preview_position_label)
preview_controls.addStretch(1)
preview_tab_layout.addLayout(preview_controls)
self.video_tabs.addTab(preview_tab, "Preview")
layout.addWidget(self.video_tabs, stretch=1)
# Timeline directly under the video (LosslessCut-style), transport
# controls below that.
@@ -353,6 +336,12 @@ class MainWindow(QMainWindow):
self.detect_all_button.setEnabled(True)
self.detect_next_action.setEnabled(True)
# A loaded preview belongs to the *previous* source/offset -- drop it.
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
os.unlink(self._preview_temp_path)
self._preview_temp_path = None
self.video_tabs.setCurrentIndex(0)
def _toggle_play(self):
if self.player.mpv.pause:
self.player.play()
@@ -370,6 +359,14 @@ class MainWindow(QMainWindow):
def _on_mute_toggled(self, checked: bool):
self.player.mpv.mute = checked
def _toggle_preview_play(self):
if self.preview_player.mpv.pause:
self.preview_player.play()
self.preview_play_button.setText("Pause")
else:
self.preview_player.pause()
self.preview_play_button.setText("Play")
def _seek_relative(self, delta_seconds: float):
# Unlike frame-stepping (which always pauses -- you're precisely
# inspecting a frame), a 5s skip is a coarse nudge while scrubbing
@@ -392,14 +389,16 @@ class MainWindow(QMainWindow):
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)
if self.input_path is not None:
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)
if self._preview_temp_path is not None:
self.preview_position_label.setText(format_timestamp(self.preview_player.get_position()))
# -- markers / offset -----------------------------------------------
@@ -446,7 +445,7 @@ class MainWindow(QMainWindow):
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._pending_preview_path = temp_path
self.preview_button.setEnabled(False)
self.preview_button.setText("Building preview…")
@@ -467,21 +466,30 @@ class MainWindow(QMainWindow):
if result.stderr.strip():
self.log_view.appendPlainText("ffmpeg preview log:\n" + result.stderr)
dialog = PreviewDialog(self._preview_temp_path, parent=self)
dialog.exec()
# The preview player is long-lived (one mpv instance for the whole
# session), so swap files instead of spinning up a new player each
# time: load the new clip first, then delete the old temp file --
# only safe to unlink it once mpv is no longer holding it open.
old_path = self._preview_temp_path
new_path = self._pending_preview_path
self.preview_player.load(new_path)
self.preview_player.play()
self.preview_play_button.setText("Pause")
self._preview_temp_path = new_path
self._pending_preview_path = None
self.video_tabs.setCurrentIndex(1)
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
os.unlink(self._preview_temp_path)
self._preview_temp_path = None
if old_path and old_path != new_path and os.path.exists(old_path):
os.unlink(old_path)
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
if self._pending_preview_path and os.path.exists(self._pending_preview_path):
os.unlink(self._pending_preview_path)
self._pending_preview_path = None
# -- clap detection -----------------------------------------------------
@@ -574,7 +582,15 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Export failed", f"{message}\n\n{stderr[-2000:]}")
def closeEvent(self, event):
# Stop the timer before tearing down mpv -- otherwise a tick that
# fires between shutdown() and the process actually exiting calls
# get_position() on an already-terminated core and crashes
# (mpv.ShutdownError, observed as a hard crash in testing).
self._position_timer.stop()
self.player.shutdown()
self.preview_player.shutdown()
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
os.unlink(self._preview_temp_path)
super().closeEvent(event)