diff --git a/main.py b/main.py index e9a41dc..5f0cb1b 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/player_panel.py b/player_panel.py new file mode 100644 index 0000000..b645f9d --- /dev/null +++ b/player_panel.py @@ -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() diff --git a/scripts/smoke_test_arrow_seek.py b/scripts/smoke_test_arrow_seek.py index 0e8a25b..a9e9555 100644 --- a/scripts/smoke_test_arrow_seek.py +++ b/scripts/smoke_test_arrow_seek.py @@ -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") diff --git a/scripts/smoke_test_clap_detection.py b/scripts/smoke_test_clap_detection.py index c35cf09..56ea745 100644 --- a/scripts/smoke_test_clap_detection.py +++ b/scripts/smoke_test_clap_detection.py @@ -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") diff --git a/scripts/smoke_test_interactions.py b/scripts/smoke_test_interactions.py index ff9e56c..f57325b 100644 --- a/scripts/smoke_test_interactions.py +++ b/scripts/smoke_test_interactions.py @@ -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") diff --git a/scripts/smoke_test_preserve_play_state.py b/scripts/smoke_test_preserve_play_state.py index 6e8d82c..633eb10 100644 --- a/scripts/smoke_test_preserve_play_state.py +++ b/scripts/smoke_test_preserve_play_state.py @@ -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") diff --git a/scripts/smoke_test_preview.py b/scripts/smoke_test_preview.py index 430430d..ca53995 100644 --- a/scripts/smoke_test_preview.py +++ b/scripts/smoke_test_preview.py @@ -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 diff --git a/scripts/smoke_test_rightclick_filemenu.py b/scripts/smoke_test_rightclick_filemenu.py index ae352ae..bbc8bf5 100644 --- a/scripts/smoke_test_rightclick_filemenu.py +++ b/scripts/smoke_test_rightclick_filemenu.py @@ -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") diff --git a/scripts/smoke_test_ui.py b/scripts/smoke_test_ui.py index e31c079..07e594f 100644 --- a/scripts/smoke_test_ui.py +++ b/scripts/smoke_test_ui.py @@ -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") diff --git a/scripts/smoke_test_view_menu.py b/scripts/smoke_test_view_menu.py index f479619..3951ca2 100644 --- a/scripts/smoke_test_view_menu.py +++ b/scripts/smoke_test_view_menu.py @@ -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")