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