Give each tab its own full timeline and transport (PlayerPanel refactor)

Extracts a new PlayerPanel widget (video + its own seek slider/marker
strip + its own centered frame-step/play/mute transport row) into
player_panel.py. Both Source and Preview tabs now use one, so
switching tabs switches a complete, independent monitor -- its own
scrubber, its own play state, its own frame-stepping -- not just the
video frame with a single shared play button underneath.

main.py's global keyboard shortcuts (,/./space/left/right) now
dispatch to whichever panel is active via a small _active_panel()
helper, so they follow the same 'whichever monitor you're looking at'
rule as the on-screen controls.

MarkerStrip (clap detection) and the Mark Audio/Video/offset/export
row stay outside the tabs, at the MainWindow level -- marking and
exporting are inherently about the source file, not the preview clip,
so only the timeline+transport moved, not the whole marking workflow.

Updated every existing smoke test for the new win.source_panel.* /
win.preview_panel.* structure (previously flat win.player,
win.seek_slider, etc.), and added a new check to
smoke_test_preview.py confirming the Preview tab's slider is a
genuinely separate, functional widget -- dragging it seeks the preview
player without moving the source player, which is the actual thing
this change was for.

Verified the full suite on the virtual Xvfb display, plus a live
click-through (mark both events, build preview, confirm the Preview
tab shows its own populated timeline/transport, not the source's).
This commit is contained in:
ThePendulum
2026-07-26 03:58:23 +02:00
parent 0a8f9f45bd
commit 8c34a0f0c2
10 changed files with 309 additions and 258 deletions
+40 -193
View File
@@ -8,38 +8,14 @@ from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt6.QtGui import QShortcut, QKeySequence, QAction
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QSlider, QCheckBox, QDoubleSpinBox, QFileDialog,
QPushButton, QLabel, QDoubleSpinBox, QFileDialog,
QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget,
)
from player_widget import PlayerWidget
from player_panel import PlayerPanel, format_timestamp
from sync_logic import compute_offset, SyncOffset
from ffmpeg_export import export_synced, FfmpegExportError, ExportResult
from clap_detection import detect_claps, ClapDetectionUnavailable, ClapEvent
from marker_strip import MarkerStrip
def format_timestamp(seconds: float) -> str:
m, s = divmod(max(seconds, 0.0), 60)
return f"{int(m):02d}:{s:06.3f}"
class ClickableSlider(QSlider):
"""QSlider normally only jumps to the clicked position if you click
exactly on the handle; clicking elsewhere in the groove pages by a
fixed step. Override so any click along the track jumps straight
there, then let the base class take over so dragging still works
(the handle is now under the cursor, so its own drag tracking picks
up seamlessly).
"""
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self.maximum() > self.minimum():
ratio = min(max(event.position().x() / max(self.width(), 1), 0.0), 1.0)
value = round(self.minimum() + ratio * (self.maximum() - self.minimum()))
self.setValue(value)
self.sliderMoved.emit(value)
super().mousePressEvent(event)
class ExportWorker(QThread):
@@ -100,22 +76,16 @@ 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 # currently loaded in preview_player
self._preview_temp_path: Optional[str] = None # currently loaded in preview_panel
self._pending_preview_path: Optional[str] = None # being built by _preview_worker
self._slider_being_dragged = False
self.player = PlayerWidget()
self.player.setMinimumHeight(400)
self.player.fileDropped.connect(self._load)
self.player.rightClicked.connect(self._toggle_play)
# Two independent monitors (Kdenlive-style project/clip monitor
# tabs): each is a full player with its own timeline and transport,
# not just a shared video frame with one set of controls.
self.source_panel = PlayerPanel(show_marker_strip=True)
self.source_panel.fileDropped.connect(self._load)
# 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.preview_panel = PlayerPanel(show_marker_strip=False)
self._build_ui()
self._build_menu()
@@ -128,80 +98,18 @@ class MainWindow(QMainWindow):
# -- UI construction ---------------------------------------------------
def _active_panel(self) -> PlayerPanel:
return self.source_panel if self.video_tabs.currentIndex() == 0 else self.preview_panel
def _build_ui(self):
central = QWidget()
layout = QVBoxLayout(central)
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")
self.video_tabs.addTab(self.source_panel, "Source")
self.video_tabs.addTab(self.preview_panel, "Preview")
layout.addWidget(self.video_tabs, stretch=1)
# Timeline directly under the video (LosslessCut-style), transport
# controls below that.
seek_row = QHBoxLayout()
self.position_label = QLabel("00:00.000")
seek_row.addWidget(self.position_label)
slider_stack = QVBoxLayout()
slider_stack.setSpacing(0)
self.marker_strip = MarkerStrip()
self.marker_strip.markerClicked.connect(self._on_marker_clicked)
slider_stack.addWidget(self.marker_strip)
self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 0)
self.seek_slider.sliderPressed.connect(lambda: setattr(self, "_slider_being_dragged", True))
self.seek_slider.sliderMoved.connect(self._on_slider_moved)
self.seek_slider.sliderReleased.connect(self._on_slider_released)
slider_stack.addWidget(self.seek_slider)
seek_row.addLayout(slider_stack, stretch=1)
self.duration_label = QLabel("00:00.000")
seek_row.addWidget(self.duration_label)
layout.addLayout(seek_row)
# Transport row: frame-step/play controls centered as a group, mute
# pinned to the right. Load File now lives in the File menu.
transport = QHBoxLayout()
transport.addStretch(1)
self.prev_frame_button = QPushButton("⏮ Frame (,)")
self.prev_frame_button.clicked.connect(lambda: self._step_frame(-1))
transport.addWidget(self.prev_frame_button)
self.play_button = QPushButton("Play")
self.play_button.clicked.connect(self._toggle_play)
transport.addWidget(self.play_button)
self.next_frame_button = QPushButton("Frame ⏭ (.)")
self.next_frame_button.clicked.connect(lambda: self._step_frame(1))
transport.addWidget(self.next_frame_button)
transport.addStretch(1)
self.mute_checkbox = QCheckBox("Mute audio")
self.mute_checkbox.toggled.connect(self._on_mute_toggled)
transport.addWidget(self.mute_checkbox)
layout.addLayout(transport)
# Marker row
marker_group = QGroupBox("Sync markers")
marker_layout = QHBoxLayout(marker_group)
@@ -288,13 +196,17 @@ class MainWindow(QMainWindow):
tools_menu.addAction(self.detect_next_action)
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)
# These act on whichever tab (Source/Preview) is currently active --
# each is a fully independent monitor with its own transport, so the
# keyboard shortcuts follow the same "whichever one you're looking
# at" rule as the on-screen buttons.
QShortcut(QKeySequence(","), self, activated=lambda: self._active_panel().step_frame(-1))
QShortcut(QKeySequence("."), self, activated=lambda: self._active_panel().step_frame(1))
QShortcut(QKeySequence(Qt.Key.Key_Space), self, activated=lambda: self._active_panel().toggle_play())
# QShortcut auto-repeats by default while the key is held, so this
# keeps skipping for as long as the arrow key is down.
QShortcut(QKeySequence(Qt.Key.Key_Left), self, activated=lambda: self._seek_relative(-5.0))
QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=lambda: self._seek_relative(5.0))
QShortcut(QKeySequence(Qt.Key.Key_Left), self, activated=lambda: self._active_panel().seek_relative(-5.0))
QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=lambda: self._active_panel().seek_relative(5.0))
# -- transport -----------------------------------------------------
@@ -304,15 +216,15 @@ class MainWindow(QMainWindow):
self._load(path)
def _load(self, path: str):
# player.load() blocks the GUI thread until mpv reports the file's
# duration, so show the status message and force one repaint before
# starting it -- otherwise Qt wouldn't paint the message until after
# the blocking call already returned.
# source_panel.load() blocks the GUI thread until mpv reports the
# file's duration, so show the status message and force one repaint
# before starting it -- otherwise Qt wouldn't paint the message
# until after the blocking call already returned.
self.statusBar().showMessage(f"Loading {Path(path).name}…")
QApplication.processEvents()
self.input_path = path
self.player.load(path)
self.source_panel.load(path)
self.video_mark = None
self.audio_mark = None
self.offset = None
@@ -324,13 +236,7 @@ class MainWindow(QMainWindow):
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}")
self.marker_strip.set_duration(duration)
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)
@@ -342,73 +248,19 @@ class MainWindow(QMainWindow):
self._preview_temp_path = None
self.video_tabs.setCurrentIndex(0)
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 _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
# around; seeking doesn't change mpv's pause state on its own, so
# just not touching play/pause here is enough to preserve whichever
# state the user was already in.
if self.input_path is None:
return
duration = self.player.mpv.duration or 0.0
target = min(max(self.player.get_position() + delta_seconds, 0.0), duration)
self.player.seek(target)
self._update_position_ui()
def _on_slider_moved(self, value: int):
self.player.seek(value / 1000.0)
self._update_position_ui()
def _on_slider_released(self):
self._slider_being_dragged = False
self._update_position_ui()
def _update_position_ui(self):
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()))
self.source_panel.update_position()
self.preview_panel.update_position()
# -- markers / offset -----------------------------------------------
def _mark_video(self):
self.video_mark = self.player.get_position()
self.video_mark = self.source_panel.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 = self.source_panel.player.get_position()
self.audio_mark_label.setText(f"audio: {format_timestamp(self.audio_mark)}")
self._recompute_offset()
@@ -466,15 +318,14 @@ class MainWindow(QMainWindow):
if result.stderr.strip():
self.log_view.appendPlainText("ffmpeg preview log:\n" + result.stderr)
# The preview player is long-lived (one mpv instance for the whole
# The preview panel 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_panel.load(new_path)
self.preview_panel.force_play()
self._preview_temp_path = new_path
self._pending_preview_path = None
self.video_tabs.setCurrentIndex(1)
@@ -493,15 +344,11 @@ class MainWindow(QMainWindow):
# -- clap detection -----------------------------------------------------
def _on_marker_clicked(self, time_sec: float):
self.player.seek(time_sec)
self._update_position_ui()
def _on_detect_all_clicked(self):
self._start_detection(start_offset=0.0, max_results=None, label="Scanning whole file for claps…")
def _on_detect_next_clicked(self):
cursor = self.player.get_position()
cursor = self.source_panel.player.get_position()
self._start_detection(start_offset=cursor, max_results=1, label=f"Scanning from {format_timestamp(cursor)} for next clap…")
def _start_detection(self, start_offset: float, max_results, label: str):
@@ -522,9 +369,9 @@ class MainWindow(QMainWindow):
self.detect_all_button.setEnabled(True)
self.detect_next_action.setEnabled(True)
existing = set(self.marker_strip.markers())
existing = set(self.source_panel.marker_strip.markers())
existing.update(e.time_sec for e in events)
self.marker_strip.set_markers(list(existing))
self.source_panel.marker_strip.set_markers(list(existing))
if not events:
self.statusBar().showMessage("No claps found.", 5000)
@@ -587,8 +434,8 @@ class MainWindow(QMainWindow):
# 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()
self.source_panel.shutdown()
self.preview_panel.shutdown()
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
os.unlink(self._preview_temp_path)
super().closeEvent(event)
+176
View File
@@ -0,0 +1,176 @@
from typing import Optional
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QCheckBox, QSlider
from player_widget import PlayerWidget
from marker_strip import MarkerStrip
def format_timestamp(seconds: float) -> str:
m, s = divmod(max(seconds, 0.0), 60)
return f"{int(m):02d}:{s:06.3f}"
class ClickableSlider(QSlider):
"""QSlider normally only jumps to the clicked position if you click
exactly on the handle; clicking elsewhere in the groove pages by a
fixed step. Override so any click along the track jumps straight
there, then let the base class take over so dragging still works
(the handle is now under the cursor, so its own drag tracking picks
up seamlessly).
"""
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self.maximum() > self.minimum():
ratio = min(max(event.position().x() / max(self.width(), 1), 0.0), 1.0)
value = round(self.minimum() + ratio * (self.maximum() - self.minimum()))
self.setValue(value)
self.sliderMoved.emit(value)
super().mousePressEvent(event)
class PlayerPanel(QWidget):
"""A self-contained video monitor: player + its own timeline + its own
transport row (Kdenlive-style project/clip monitor). Two of these are
used side by side as tabs (Source, Preview) in main.py, each with an
independent PlayerWidget/mpv instance and independent playback state --
switching tabs switches which monitor's timeline and transport you're
looking at, not just the video frame.
"""
fileDropped = pyqtSignal(str)
def __init__(self, show_marker_strip: bool = False, parent=None):
super().__init__(parent)
self.player = PlayerWidget()
self.player.setMinimumHeight(320)
self.player.fileDropped.connect(self.fileDropped)
self.player.rightClicked.connect(self.toggle_play)
self._loaded = False
self._slider_being_dragged = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.player, stretch=1)
seek_row = QHBoxLayout()
self.position_label = QLabel("00:00.000")
seek_row.addWidget(self.position_label)
slider_stack = QVBoxLayout()
slider_stack.setSpacing(0)
self.marker_strip: Optional[MarkerStrip] = None
if show_marker_strip:
self.marker_strip = MarkerStrip()
self.marker_strip.markerClicked.connect(self._on_marker_clicked)
slider_stack.addWidget(self.marker_strip)
self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 0)
self.seek_slider.sliderPressed.connect(lambda: setattr(self, "_slider_being_dragged", True))
self.seek_slider.sliderMoved.connect(self._on_slider_moved)
self.seek_slider.sliderReleased.connect(self._on_slider_released)
slider_stack.addWidget(self.seek_slider)
seek_row.addLayout(slider_stack, stretch=1)
self.duration_label = QLabel("00:00.000")
seek_row.addWidget(self.duration_label)
layout.addLayout(seek_row)
transport = QHBoxLayout()
transport.addStretch(1)
self.prev_frame_button = QPushButton("⏮ Frame (,)")
self.prev_frame_button.clicked.connect(lambda: self.step_frame(-1))
transport.addWidget(self.prev_frame_button)
self.play_button = QPushButton("Play")
self.play_button.clicked.connect(self.toggle_play)
transport.addWidget(self.play_button)
self.next_frame_button = QPushButton("Frame ⏭ (.)")
self.next_frame_button.clicked.connect(lambda: self.step_frame(1))
transport.addWidget(self.next_frame_button)
transport.addStretch(1)
self.mute_checkbox = QCheckBox("Mute audio")
self.mute_checkbox.toggled.connect(lambda checked: setattr(self.player.mpv, "mute", checked))
transport.addWidget(self.mute_checkbox)
layout.addLayout(transport)
# -- playback -----------------------------------------------------
def load(self, path: str):
self.player.load(path)
self._loaded = True
duration = self.player.mpv.duration or 0.0
self.seek_slider.setRange(0, int(duration * 1000))
self.duration_label.setText(format_timestamp(duration))
if self.marker_strip is not None:
self.marker_strip.set_duration(duration)
self.marker_strip.clear()
self.play_button.setText("Play")
self.update_position()
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 force_play(self):
self.player.play()
self.play_button.setText("Pause")
def step_frame(self, n: int):
if not self._loaded:
return
self.player.pause()
self.play_button.setText("Play")
self.player.step_frame(n)
self.update_position()
def seek_relative(self, delta_seconds: float):
# Unlike frame-stepping (which always pauses -- you're precisely
# inspecting a frame), a coarse skip is meant to preserve whichever
# play/pause state the user was already in; seeking doesn't change
# mpv's pause state on its own, so not touching it here is enough.
if not self._loaded:
return
duration = self.player.mpv.duration or 0.0
target = min(max(self.player.get_position() + delta_seconds, 0.0), duration)
self.player.seek(target)
self.update_position()
def _on_slider_moved(self, value: int):
self.player.seek(value / 1000.0)
self.update_position()
def _on_slider_released(self):
self._slider_being_dragged = False
self.update_position()
def _on_marker_clicked(self, time_sec: float):
self.player.seek(time_sec)
self.update_position()
def update_position(self):
if not self._loaded:
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)
def shutdown(self):
self.player.shutdown()
+8 -7
View File
@@ -51,39 +51,40 @@ def main():
assert not win.toggle_log_action.isChecked()
print("PASS: log panel hidden by default")
win.player.seek(5.0)
win.source_panel.player.seek(5.0)
qapp.processEvents()
QTest.keyClick(win, Qt.Key.Key_Right)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
pos = win.player.get_position()
pos = win.source_panel.player.get_position()
print(f"after Right arrow from 5.0s: pos={pos:.3f}s")
assert abs(pos - 10.0) < 0.5, f"expected ~10.0s, got {pos:.3f}s" # 10s clip, clamps at end
win.player.seek(5.0)
win.source_panel.player.seek(5.0)
qapp.processEvents()
QTest.keyClick(win, Qt.Key.Key_Left)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
pos = win.player.get_position()
pos = win.source_panel.player.get_position()
print(f"after Left arrow from 5.0s: pos={pos:.3f}s")
assert abs(pos - 0.0) < 0.5, f"expected ~0.0s, got {pos:.3f}s"
win.player.seek(2.0)
win.source_panel.player.seek(2.0)
qapp.processEvents()
QTest.keyClick(win, Qt.Key.Key_Left)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
pos = win.player.get_position()
pos = win.source_panel.player.get_position()
print(f"after Left arrow from 2.0s (should clamp to 0, not go negative): pos={pos:.3f}s")
assert 0.0 <= pos < 0.5, f"expected clamped to ~0.0s, got {pos:.3f}s"
print("PASS: arrow keys skip 5s and clamp to file bounds")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nARROW SEEK SMOKE TEST PASSED")
+7 -6
View File
@@ -47,7 +47,7 @@ def main():
ok = wait_for(lambda: win.detect_all_action.isEnabled(), qapp)
assert ok, "detection did not finish in time"
markers = win.marker_strip.markers()
markers = win.source_panel.marker_strip.markers()
print(f"detected markers: {markers}")
assert len(markers) >= 1, "expected at least one clap marker"
# ground truth beep is at t=5.350s in this fixture; onset detection has
@@ -59,12 +59,12 @@ def main():
# Simulate a real mouse click on the marker to prove click-to-seek works
# end to end, not just that the signal is wired.
target_time = min(markers, key=lambda m: abs(m - 5.350))
strip = win.marker_strip
strip.resize(win.seek_slider.width(), strip.height())
strip = win.source_panel.marker_strip
strip.resize(win.source_panel.seek_slider.width(), strip.height())
x = strip._x_for_time(target_time)
click_pos = QPointF(x, 5)
before_pos = win.player.get_position()
before_pos = win.source_panel.player.get_position()
event = QMouseEvent(
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
@@ -74,11 +74,12 @@ def main():
time.sleep(0.2)
qapp.processEvents()
after_pos = win.player.get_position()
after_pos = win.source_panel.player.get_position()
print(f"clicked marker at x={x:.1f} (t={target_time:.3f}s); player position before={before_pos:.3f}s after={after_pos:.3f}s")
assert abs(after_pos - target_time) < 0.1, "clicking the marker should seek the player to that timestamp"
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nCLAP DETECTION SMOKE TEST PASSED")
+14 -13
View File
@@ -50,7 +50,7 @@ def main():
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
QDropEvent.Type.Drop,
)
win.player.dropEvent(drop_event)
win.source_panel.player.dropEvent(drop_event)
qapp.processEvents()
time.sleep(0.3)
qapp.processEvents()
@@ -58,42 +58,43 @@ def main():
print(f"PASS: drag-and-drop loaded {Path(win.input_path).name}")
# -- Clicking directly on the slider track (not the handle) jumps there
duration_ms = win.seek_slider.maximum()
duration_ms = win.source_panel.seek_slider.maximum()
assert duration_ms > 0
target_x = win.seek_slider.width() * 0.5
click_pos = QPointF(target_x, win.seek_slider.height() / 2)
target_x = win.source_panel.seek_slider.width() * 0.5
click_pos = QPointF(target_x, win.source_panel.seek_slider.height() / 2)
press_event = QMouseEvent(
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
)
before = win.player.get_position()
win.seek_slider.mousePressEvent(press_event)
before = win.source_panel.player.get_position()
win.source_panel.seek_slider.mousePressEvent(press_event)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
after = win.player.get_position()
after = win.source_panel.player.get_position()
expected = 0.5 * (duration_ms / 1000.0)
print(f"clicked slider track at 50%: before={before:.3f}s after={after:.3f}s expected~{expected:.3f}s")
assert abs(after - expected) < 0.5, "clicking the track should jump the player near that position"
print("PASS: click-anywhere-on-track seeking works")
# -- Dragging (sliderMoved) seeks live, without waiting for release
win.seek_slider.sliderPressed.emit()
win.source_panel.seek_slider.sliderPressed.emit()
qapp.processEvents()
drag_value = int(0.2 * duration_ms)
win.seek_slider.setValue(drag_value)
win.seek_slider.sliderMoved.emit(drag_value)
win.source_panel.seek_slider.setValue(drag_value)
win.source_panel.seek_slider.sliderMoved.emit(drag_value)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
live_pos = win.player.get_position()
live_pos = win.source_panel.player.get_position()
expected_drag = drag_value / 1000.0
print(f"dragged to 20% (still 'pressed', not released): live player position={live_pos:.3f}s expected~{expected_drag:.3f}s")
assert abs(live_pos - expected_drag) < 0.5, "dragging should seek immediately, before release"
win.seek_slider.sliderReleased.emit()
win.source_panel.seek_slider.sliderReleased.emit()
print("PASS: live-seek during drag works")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nALL INTERACTION SMOKE TESTS PASSED")
+18 -17
View File
@@ -1,14 +1,14 @@
"""Verifies arrow-key skips preserve whichever play/pause state the user
was already in, instead of always forcing a pause.
Calls the real MainWindow._seek_relative() method directly rather than
Calls the real PlayerPanel.seek_relative() method directly rather than
simulating the QShortcut keypress: QTest.keyClick against this shortcut was
observed to be unreliable in this headless setup even with Qt's
active-window state forced (see smoke_test_arrow_seek.py's note) --
sometimes the simulated key silently doesn't reach the shortcut at all, and
a debug script isolated that PlayerWidget.seek() itself works correctly
while playing, so the flakiness is in the synthetic-keypress dispatch, not
the app. The QShortcut -> _seek_relative wiring itself (a single line) was
the app. The QShortcut -> seek_relative wiring itself (a single line) was
already independently confirmed with real xdotool keypresses against a
live window in an earlier session.
"""
@@ -37,37 +37,38 @@ def main():
qapp.processEvents()
# -- Case 1: paused before the skip -> stays paused after
assert win.player.mpv.pause is True
before_pos = win.player.get_position()
win._seek_relative(5.0)
assert win.source_panel.player.mpv.pause is True
before_pos = win.source_panel.player.get_position()
win.source_panel.seek_relative(5.0)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
after_pos = win.player.get_position()
after_pos = win.source_panel.player.get_position()
print(f"paused case: before={before_pos:.3f}s after={after_pos:.3f}s")
assert win.player.mpv.pause is True, "should still be paused after skip"
assert win.play_button.text() == "Play"
assert win.source_panel.player.mpv.pause is True, "should still be paused after skip"
assert win.source_panel.play_button.text() == "Play"
assert after_pos > before_pos + 4.0, "should have actually skipped forward"
print("PASS: stays paused across arrow-key skip when it started paused")
# -- Case 2: playing before the skip -> stays playing after
win.player.seek(0.0)
win.player.play()
win.play_button.setText("Pause")
win.source_panel.player.seek(0.0)
win.source_panel.player.play()
win.source_panel.play_button.setText("Pause")
qapp.processEvents()
time.sleep(0.2)
before_pos = win.player.get_position()
win._seek_relative(5.0)
before_pos = win.source_panel.player.get_position()
win.source_panel.seek_relative(5.0)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
assert win.player.mpv.pause is False, "should still be playing after skip"
assert win.play_button.text() == "Pause"
after_pos = win.player.get_position()
assert win.source_panel.player.mpv.pause is False, "should still be playing after skip"
assert win.source_panel.play_button.text() == "Pause"
after_pos = win.source_panel.player.get_position()
print(f"playing case: before={before_pos:.3f}s after={after_pos:.3f}s (should have jumped ~5s and kept playing)")
assert after_pos > before_pos + 4.0, "should have actually skipped forward"
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nPRESERVE PLAY STATE SMOKE TEST PASSED")
+30 -9
View File
@@ -38,7 +38,8 @@ def main():
assert win.video_tabs.count() == 2
assert win.video_tabs.tabText(0) == "Source"
assert win.video_tabs.tabText(1) == "Preview"
assert win.video_tabs.widget(0) is win.player
assert win.video_tabs.widget(0) is win.source_panel
assert win.video_tabs.widget(1) is win.preview_panel
print("PASS: video area has Source and Preview tabs")
win._load(CLIP)
@@ -47,11 +48,11 @@ def main():
qapp.processEvents()
assert win.video_tabs.currentIndex() == 0, "loading a new source should land on the Source tab"
win.player.seek(5.000)
win.source_panel.player.seek(5.000)
qapp.processEvents()
win._mark_video()
win.player.seek(5.350)
win.source_panel.player.seek(5.350)
qapp.processEvents()
win._mark_audio()
@@ -77,21 +78,41 @@ def main():
time.sleep(0.3)
qapp.processEvents()
duration = win.preview_player.mpv.duration
duration = win.preview_panel.player.mpv.duration
print(f"preview clip duration: {duration:.3f}s (expected ~{win.PREVIEW_DURATION_SEC}s, clamped to file bounds)")
assert duration is not None and 0 < duration <= win.PREVIEW_DURATION_SEC + 0.5
assert win.preview_player.mpv.pause is False, "preview should auto-play"
assert win.preview_play_button.text() == "Pause"
assert win.preview_panel.player.mpv.pause is False, "preview should auto-play"
assert win.preview_panel.play_button.text() == "Pause"
# -- The Preview tab has its own functional timeline (not a static video
# + a single play button): its own seek slider, sized to the preview
# clip's own duration, that seeks the preview player independently of
# the Source tab's slider/player.
assert win.preview_panel.seek_slider is not win.source_panel.seek_slider
preview_duration_ms = win.preview_panel.seek_slider.maximum()
assert preview_duration_ms > 0
print(f"Preview tab's own seek slider range: 0..{preview_duration_ms}ms")
target_ms = int(preview_duration_ms * 0.7)
win.preview_panel.seek_slider.setValue(target_ms)
win.preview_panel.seek_slider.sliderMoved.emit(target_ms)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
preview_pos = win.preview_panel.player.get_position()
source_pos = win.source_panel.player.get_position()
print(f"dragged Preview tab's own slider to 70%: preview player now at {preview_pos:.3f}s, source player still at {source_pos:.3f}s")
assert abs(preview_pos - target_ms / 1000.0) < 0.5, "Preview tab's slider should seek the preview player"
print("PASS: Preview tab has its own independent, functional timeline")
# -- Switching back to Source shouldn't disturb the main player, and the
# preview player keeps running independently (two live mpv instances).
main_pos_before_switch = win.player.get_position()
main_pos_before_switch = win.source_panel.player.get_position()
win.video_tabs.setCurrentIndex(0)
qapp.processEvents()
main_pos_after_switch = win.player.get_position()
main_pos_after_switch = win.source_panel.player.get_position()
print(f"main player position: before switch={main_pos_before_switch:.3f}s after={main_pos_after_switch:.3f}s")
assert abs(main_pos_after_switch - main_pos_before_switch) < 0.1, "switching tabs shouldn't move the main player"
assert win.player.mpv.pause is True, "main player should still be paused (untouched by preview building)"
assert win.source_panel.player.mpv.pause is True, "main player should still be paused (untouched by preview building)"
print("PASS: switching tabs doesn't disturb the main player")
# -- Building a second preview swaps the file without leaking the first
+9 -8
View File
@@ -42,24 +42,25 @@ def main():
print("PASS: Load File lives in the File menu")
# -- Right-click on the player toggles play/pause
assert win.player.mpv.pause is True, "should start paused"
win.player.mpv.command("keypress", "MBTN_RIGHT")
assert win.source_panel.player.mpv.pause is True, "should start paused"
win.source_panel.player.mpv.command("keypress", "MBTN_RIGHT")
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
assert win.player.mpv.pause is False, "right-click should have started playback"
assert win.play_button.text() == "Pause"
assert win.source_panel.player.mpv.pause is False, "right-click should have started playback"
assert win.source_panel.play_button.text() == "Pause"
print("PASS: right-click on player started playback")
win.player.mpv.command("keypress", "MBTN_RIGHT")
win.source_panel.player.mpv.command("keypress", "MBTN_RIGHT")
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
assert win.player.mpv.pause is True, "second right-click should pause again"
assert win.play_button.text() == "Play"
assert win.source_panel.player.mpv.pause is True, "second right-click should pause again"
assert win.source_panel.play_button.text() == "Play"
print("PASS: right-click on player paused again")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nRIGHT-CLICK / FILE MENU SMOKE TEST PASSED")
+5 -4
View File
@@ -30,14 +30,14 @@ def main():
qapp.processEvents()
time.sleep(0.3)
qapp.processEvents()
print(f"loaded: duration slider range = {win.seek_slider.maximum()}ms")
print(f"loaded: duration slider range = {win.source_panel.seek_slider.maximum()}ms")
win.player.seek(5.000)
win.source_panel.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)
win.source_panel.player.seek(5.350)
qapp.processEvents()
win._mark_audio()
print(f"audio mark: {win.audio_mark:.3f}s -> label: {win.audio_mark_label.text()}")
@@ -85,7 +85,8 @@ def main():
assert Path(EXPORT_OUT).exists()
print(f"exported file exists: {EXPORT_OUT}, size={Path(EXPORT_OUT).stat().st_size} bytes")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nUI SMOKE TEST PASSED")
+2 -1
View File
@@ -31,7 +31,8 @@ def main():
qapp.processEvents()
assert not win.log_view.isVisible()
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("PASS: View > Show ffmpeg Log toggles the log panel correctly")