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.QtGui import QShortcut, QKeySequence, QAction
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QPushButton, QLabel, QSlider, QCheckBox, QDoubleSpinBox, QFileDialog,
|
QPushButton, QLabel, QDoubleSpinBox, QFileDialog,
|
||||||
QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget,
|
QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from player_widget import PlayerWidget
|
from player_panel import PlayerPanel, format_timestamp
|
||||||
from sync_logic import compute_offset, SyncOffset
|
from sync_logic import compute_offset, SyncOffset
|
||||||
from ffmpeg_export import export_synced, FfmpegExportError, ExportResult
|
from ffmpeg_export import export_synced, FfmpegExportError, ExportResult
|
||||||
from clap_detection import detect_claps, ClapDetectionUnavailable, ClapEvent
|
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):
|
class ExportWorker(QThread):
|
||||||
@@ -100,22 +76,16 @@ class MainWindow(QMainWindow):
|
|||||||
self._export_worker: Optional[ExportWorker] = None
|
self._export_worker: Optional[ExportWorker] = None
|
||||||
self._detect_worker: Optional[DetectWorker] = None
|
self._detect_worker: Optional[DetectWorker] = None
|
||||||
self._preview_worker: Optional[ExportWorker] = 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._pending_preview_path: Optional[str] = None # being built by _preview_worker
|
||||||
self._slider_being_dragged = False
|
|
||||||
|
|
||||||
self.player = PlayerWidget()
|
# Two independent monitors (Kdenlive-style project/clip monitor
|
||||||
self.player.setMinimumHeight(400)
|
# tabs): each is a full player with its own timeline and transport,
|
||||||
self.player.fileDropped.connect(self._load)
|
# not just a shared video frame with one set of controls.
|
||||||
self.player.rightClicked.connect(self._toggle_play)
|
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
|
self.preview_panel = PlayerPanel(show_marker_strip=False)
|
||||||
# project/clip monitor tabs) -- kept alive for the whole session
|
|
||||||
# rather than created per-preview, so switching tabs back and forth
|
|
||||||
# doesn't tear down and rebuild an mpv instance each time.
|
|
||||||
self.preview_player = PlayerWidget()
|
|
||||||
self.preview_player.setMinimumHeight(320)
|
|
||||||
self.preview_player.rightClicked.connect(self._toggle_preview_play)
|
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._build_menu()
|
self._build_menu()
|
||||||
@@ -128,80 +98,18 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# -- UI construction ---------------------------------------------------
|
# -- 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):
|
def _build_ui(self):
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
layout = QVBoxLayout(central)
|
layout = QVBoxLayout(central)
|
||||||
|
|
||||||
self.video_tabs = QTabWidget()
|
self.video_tabs = QTabWidget()
|
||||||
self.video_tabs.addTab(self.player, "Source")
|
self.video_tabs.addTab(self.source_panel, "Source")
|
||||||
|
self.video_tabs.addTab(self.preview_panel, "Preview")
|
||||||
preview_tab = QWidget()
|
|
||||||
preview_tab_layout = QVBoxLayout(preview_tab)
|
|
||||||
preview_tab_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
preview_tab_layout.addWidget(self.preview_player, stretch=1)
|
|
||||||
|
|
||||||
preview_controls = QHBoxLayout()
|
|
||||||
self.preview_play_button = QPushButton("Play")
|
|
||||||
self.preview_play_button.clicked.connect(self._toggle_preview_play)
|
|
||||||
preview_controls.addWidget(self.preview_play_button)
|
|
||||||
self.preview_position_label = QLabel("00:00.000")
|
|
||||||
preview_controls.addWidget(self.preview_position_label)
|
|
||||||
preview_controls.addStretch(1)
|
|
||||||
preview_tab_layout.addLayout(preview_controls)
|
|
||||||
|
|
||||||
self.video_tabs.addTab(preview_tab, "Preview")
|
|
||||||
|
|
||||||
layout.addWidget(self.video_tabs, stretch=1)
|
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 row
|
||||||
marker_group = QGroupBox("Sync markers")
|
marker_group = QGroupBox("Sync markers")
|
||||||
marker_layout = QHBoxLayout(marker_group)
|
marker_layout = QHBoxLayout(marker_group)
|
||||||
@@ -288,13 +196,17 @@ class MainWindow(QMainWindow):
|
|||||||
tools_menu.addAction(self.detect_next_action)
|
tools_menu.addAction(self.detect_next_action)
|
||||||
|
|
||||||
def _wire_shortcuts(self):
|
def _wire_shortcuts(self):
|
||||||
QShortcut(QKeySequence(","), self, activated=lambda: self._step_frame(-1))
|
# These act on whichever tab (Source/Preview) is currently active --
|
||||||
QShortcut(QKeySequence("."), self, activated=lambda: self._step_frame(1))
|
# each is a fully independent monitor with its own transport, so the
|
||||||
QShortcut(QKeySequence(Qt.Key.Key_Space), self, activated=self._toggle_play)
|
# 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
|
# QShortcut auto-repeats by default while the key is held, so this
|
||||||
# keeps skipping for as long as the arrow key is down.
|
# 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_Left), self, activated=lambda: self._active_panel().seek_relative(-5.0))
|
||||||
QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=lambda: self._seek_relative(5.0))
|
QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=lambda: self._active_panel().seek_relative(5.0))
|
||||||
|
|
||||||
# -- transport -----------------------------------------------------
|
# -- transport -----------------------------------------------------
|
||||||
|
|
||||||
@@ -304,15 +216,15 @@ class MainWindow(QMainWindow):
|
|||||||
self._load(path)
|
self._load(path)
|
||||||
|
|
||||||
def _load(self, path: str):
|
def _load(self, path: str):
|
||||||
# player.load() blocks the GUI thread until mpv reports the file's
|
# source_panel.load() blocks the GUI thread until mpv reports the
|
||||||
# duration, so show the status message and force one repaint before
|
# file's duration, so show the status message and force one repaint
|
||||||
# starting it -- otherwise Qt wouldn't paint the message until after
|
# before starting it -- otherwise Qt wouldn't paint the message
|
||||||
# the blocking call already returned.
|
# until after the blocking call already returned.
|
||||||
self.statusBar().showMessage(f"Loading {Path(path).name}…")
|
self.statusBar().showMessage(f"Loading {Path(path).name}…")
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
|
|
||||||
self.input_path = path
|
self.input_path = path
|
||||||
self.player.load(path)
|
self.source_panel.load(path)
|
||||||
self.video_mark = None
|
self.video_mark = None
|
||||||
self.audio_mark = None
|
self.audio_mark = None
|
||||||
self.offset = None
|
self.offset = None
|
||||||
@@ -324,13 +236,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.override_spinbox.setEnabled(False)
|
self.override_spinbox.setEnabled(False)
|
||||||
self.log_view.clear()
|
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.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.statusBar().showMessage(f"Loaded {Path(path).name}", 4000)
|
||||||
self.detect_all_action.setEnabled(True)
|
self.detect_all_action.setEnabled(True)
|
||||||
self.detect_all_button.setEnabled(True)
|
self.detect_all_button.setEnabled(True)
|
||||||
@@ -342,73 +248,19 @@ class MainWindow(QMainWindow):
|
|||||||
self._preview_temp_path = None
|
self._preview_temp_path = None
|
||||||
self.video_tabs.setCurrentIndex(0)
|
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):
|
def _update_position_ui(self):
|
||||||
if self.input_path is not None:
|
self.source_panel.update_position()
|
||||||
pos = self.player.get_position()
|
self.preview_panel.update_position()
|
||||||
self.position_label.setText(format_timestamp(pos))
|
|
||||||
if not self._slider_being_dragged:
|
|
||||||
self.seek_slider.blockSignals(True)
|
|
||||||
self.seek_slider.setValue(int(pos * 1000))
|
|
||||||
self.seek_slider.blockSignals(False)
|
|
||||||
|
|
||||||
if self._preview_temp_path is not None:
|
|
||||||
self.preview_position_label.setText(format_timestamp(self.preview_player.get_position()))
|
|
||||||
|
|
||||||
# -- markers / offset -----------------------------------------------
|
# -- markers / offset -----------------------------------------------
|
||||||
|
|
||||||
def _mark_video(self):
|
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.video_mark_label.setText(f"video: {format_timestamp(self.video_mark)}")
|
||||||
self._recompute_offset()
|
self._recompute_offset()
|
||||||
|
|
||||||
def _mark_audio(self):
|
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.audio_mark_label.setText(f"audio: {format_timestamp(self.audio_mark)}")
|
||||||
self._recompute_offset()
|
self._recompute_offset()
|
||||||
|
|
||||||
@@ -466,15 +318,14 @@ class MainWindow(QMainWindow):
|
|||||||
if result.stderr.strip():
|
if result.stderr.strip():
|
||||||
self.log_view.appendPlainText("ffmpeg preview log:\n" + result.stderr)
|
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
|
# session), so swap files instead of spinning up a new player each
|
||||||
# time: load the new clip first, then delete the old temp file --
|
# 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.
|
# only safe to unlink it once mpv is no longer holding it open.
|
||||||
old_path = self._preview_temp_path
|
old_path = self._preview_temp_path
|
||||||
new_path = self._pending_preview_path
|
new_path = self._pending_preview_path
|
||||||
self.preview_player.load(new_path)
|
self.preview_panel.load(new_path)
|
||||||
self.preview_player.play()
|
self.preview_panel.force_play()
|
||||||
self.preview_play_button.setText("Pause")
|
|
||||||
self._preview_temp_path = new_path
|
self._preview_temp_path = new_path
|
||||||
self._pending_preview_path = None
|
self._pending_preview_path = None
|
||||||
self.video_tabs.setCurrentIndex(1)
|
self.video_tabs.setCurrentIndex(1)
|
||||||
@@ -493,15 +344,11 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# -- clap detection -----------------------------------------------------
|
# -- 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):
|
def _on_detect_all_clicked(self):
|
||||||
self._start_detection(start_offset=0.0, max_results=None, label="Scanning whole file for claps…")
|
self._start_detection(start_offset=0.0, max_results=None, label="Scanning whole file for claps…")
|
||||||
|
|
||||||
def _on_detect_next_clicked(self):
|
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…")
|
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):
|
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_all_button.setEnabled(True)
|
||||||
self.detect_next_action.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)
|
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:
|
if not events:
|
||||||
self.statusBar().showMessage("No claps found.", 5000)
|
self.statusBar().showMessage("No claps found.", 5000)
|
||||||
@@ -587,8 +434,8 @@ class MainWindow(QMainWindow):
|
|||||||
# get_position() on an already-terminated core and crashes
|
# get_position() on an already-terminated core and crashes
|
||||||
# (mpv.ShutdownError, observed as a hard crash in testing).
|
# (mpv.ShutdownError, observed as a hard crash in testing).
|
||||||
self._position_timer.stop()
|
self._position_timer.stop()
|
||||||
self.player.shutdown()
|
self.source_panel.shutdown()
|
||||||
self.preview_player.shutdown()
|
self.preview_panel.shutdown()
|
||||||
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
|
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
|
||||||
os.unlink(self._preview_temp_path)
|
os.unlink(self._preview_temp_path)
|
||||||
super().closeEvent(event)
|
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()
|
assert not win.toggle_log_action.isChecked()
|
||||||
print("PASS: log panel hidden by default")
|
print("PASS: log panel hidden by default")
|
||||||
|
|
||||||
win.player.seek(5.0)
|
win.source_panel.player.seek(5.0)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
QTest.keyClick(win, Qt.Key.Key_Right)
|
QTest.keyClick(win, Qt.Key.Key_Right)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
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")
|
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
|
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()
|
qapp.processEvents()
|
||||||
QTest.keyClick(win, Qt.Key.Key_Left)
|
QTest.keyClick(win, Qt.Key.Key_Left)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
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")
|
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"
|
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()
|
qapp.processEvents()
|
||||||
QTest.keyClick(win, Qt.Key.Key_Left)
|
QTest.keyClick(win, Qt.Key.Key_Left)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
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")
|
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"
|
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")
|
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")
|
print("\nARROW SEEK SMOKE TEST PASSED")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def main():
|
|||||||
ok = wait_for(lambda: win.detect_all_action.isEnabled(), qapp)
|
ok = wait_for(lambda: win.detect_all_action.isEnabled(), qapp)
|
||||||
assert ok, "detection did not finish in time"
|
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}")
|
print(f"detected markers: {markers}")
|
||||||
assert len(markers) >= 1, "expected at least one clap marker"
|
assert len(markers) >= 1, "expected at least one clap marker"
|
||||||
# ground truth beep is at t=5.350s in this fixture; onset detection has
|
# 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
|
# Simulate a real mouse click on the marker to prove click-to-seek works
|
||||||
# end to end, not just that the signal is wired.
|
# end to end, not just that the signal is wired.
|
||||||
target_time = min(markers, key=lambda m: abs(m - 5.350))
|
target_time = min(markers, key=lambda m: abs(m - 5.350))
|
||||||
strip = win.marker_strip
|
strip = win.source_panel.marker_strip
|
||||||
strip.resize(win.seek_slider.width(), strip.height())
|
strip.resize(win.source_panel.seek_slider.width(), strip.height())
|
||||||
x = strip._x_for_time(target_time)
|
x = strip._x_for_time(target_time)
|
||||||
click_pos = QPointF(x, 5)
|
click_pos = QPointF(x, 5)
|
||||||
|
|
||||||
before_pos = win.player.get_position()
|
before_pos = win.source_panel.player.get_position()
|
||||||
event = QMouseEvent(
|
event = QMouseEvent(
|
||||||
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
|
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
|
||||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||||
@@ -74,11 +74,12 @@ def main():
|
|||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
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")
|
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"
|
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")
|
print("\nCLAP DETECTION SMOKE TEST PASSED")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def main():
|
|||||||
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||||
QDropEvent.Type.Drop,
|
QDropEvent.Type.Drop,
|
||||||
)
|
)
|
||||||
win.player.dropEvent(drop_event)
|
win.source_panel.player.dropEvent(drop_event)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
@@ -58,42 +58,43 @@ def main():
|
|||||||
print(f"PASS: drag-and-drop loaded {Path(win.input_path).name}")
|
print(f"PASS: drag-and-drop loaded {Path(win.input_path).name}")
|
||||||
|
|
||||||
# -- Clicking directly on the slider track (not the handle) jumps there
|
# -- 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
|
assert duration_ms > 0
|
||||||
target_x = win.seek_slider.width() * 0.5
|
target_x = win.source_panel.seek_slider.width() * 0.5
|
||||||
click_pos = QPointF(target_x, win.seek_slider.height() / 2)
|
click_pos = QPointF(target_x, win.source_panel.seek_slider.height() / 2)
|
||||||
press_event = QMouseEvent(
|
press_event = QMouseEvent(
|
||||||
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
|
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
|
||||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||||
)
|
)
|
||||||
before = win.player.get_position()
|
before = win.source_panel.player.get_position()
|
||||||
win.seek_slider.mousePressEvent(press_event)
|
win.source_panel.seek_slider.mousePressEvent(press_event)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
after = win.player.get_position()
|
after = win.source_panel.player.get_position()
|
||||||
expected = 0.5 * (duration_ms / 1000.0)
|
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")
|
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"
|
assert abs(after - expected) < 0.5, "clicking the track should jump the player near that position"
|
||||||
print("PASS: click-anywhere-on-track seeking works")
|
print("PASS: click-anywhere-on-track seeking works")
|
||||||
|
|
||||||
# -- Dragging (sliderMoved) seeks live, without waiting for release
|
# -- Dragging (sliderMoved) seeks live, without waiting for release
|
||||||
win.seek_slider.sliderPressed.emit()
|
win.source_panel.seek_slider.sliderPressed.emit()
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
drag_value = int(0.2 * duration_ms)
|
drag_value = int(0.2 * duration_ms)
|
||||||
win.seek_slider.setValue(drag_value)
|
win.source_panel.seek_slider.setValue(drag_value)
|
||||||
win.seek_slider.sliderMoved.emit(drag_value)
|
win.source_panel.seek_slider.sliderMoved.emit(drag_value)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
live_pos = win.player.get_position()
|
live_pos = win.source_panel.player.get_position()
|
||||||
expected_drag = drag_value / 1000.0
|
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")
|
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"
|
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")
|
print("PASS: live-seek during drag works")
|
||||||
|
|
||||||
win.player.shutdown()
|
win.source_panel.shutdown()
|
||||||
|
win.preview_panel.shutdown()
|
||||||
print("\nALL INTERACTION SMOKE TESTS PASSED")
|
print("\nALL INTERACTION SMOKE TESTS PASSED")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Verifies arrow-key skips preserve whichever play/pause state the user
|
"""Verifies arrow-key skips preserve whichever play/pause state the user
|
||||||
was already in, instead of always forcing a pause.
|
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
|
simulating the QShortcut keypress: QTest.keyClick against this shortcut was
|
||||||
observed to be unreliable in this headless setup even with Qt's
|
observed to be unreliable in this headless setup even with Qt's
|
||||||
active-window state forced (see smoke_test_arrow_seek.py's note) --
|
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
|
sometimes the simulated key silently doesn't reach the shortcut at all, and
|
||||||
a debug script isolated that PlayerWidget.seek() itself works correctly
|
a debug script isolated that PlayerWidget.seek() itself works correctly
|
||||||
while playing, so the flakiness is in the synthetic-keypress dispatch, not
|
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
|
already independently confirmed with real xdotool keypresses against a
|
||||||
live window in an earlier session.
|
live window in an earlier session.
|
||||||
"""
|
"""
|
||||||
@@ -37,37 +37,38 @@ def main():
|
|||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
|
|
||||||
# -- Case 1: paused before the skip -> stays paused after
|
# -- Case 1: paused before the skip -> stays paused after
|
||||||
assert win.player.mpv.pause is True
|
assert win.source_panel.player.mpv.pause is True
|
||||||
before_pos = win.player.get_position()
|
before_pos = win.source_panel.player.get_position()
|
||||||
win._seek_relative(5.0)
|
win.source_panel.seek_relative(5.0)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
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")
|
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.source_panel.player.mpv.pause is True, "should still be paused after skip"
|
||||||
assert win.play_button.text() == "Play"
|
assert win.source_panel.play_button.text() == "Play"
|
||||||
assert after_pos > before_pos + 4.0, "should have actually skipped forward"
|
assert after_pos > before_pos + 4.0, "should have actually skipped forward"
|
||||||
print("PASS: stays paused across arrow-key skip when it started paused")
|
print("PASS: stays paused across arrow-key skip when it started paused")
|
||||||
|
|
||||||
# -- Case 2: playing before the skip -> stays playing after
|
# -- Case 2: playing before the skip -> stays playing after
|
||||||
win.player.seek(0.0)
|
win.source_panel.player.seek(0.0)
|
||||||
win.player.play()
|
win.source_panel.player.play()
|
||||||
win.play_button.setText("Pause")
|
win.source_panel.play_button.setText("Pause")
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
before_pos = win.player.get_position()
|
before_pos = win.source_panel.player.get_position()
|
||||||
win._seek_relative(5.0)
|
win.source_panel.seek_relative(5.0)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
assert win.player.mpv.pause is False, "should still be playing after skip"
|
assert win.source_panel.player.mpv.pause is False, "should still be playing after skip"
|
||||||
assert win.play_button.text() == "Pause"
|
assert win.source_panel.play_button.text() == "Pause"
|
||||||
after_pos = win.player.get_position()
|
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)")
|
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"
|
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")
|
print("\nPRESERVE PLAY STATE SMOKE TEST PASSED")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ def main():
|
|||||||
assert win.video_tabs.count() == 2
|
assert win.video_tabs.count() == 2
|
||||||
assert win.video_tabs.tabText(0) == "Source"
|
assert win.video_tabs.tabText(0) == "Source"
|
||||||
assert win.video_tabs.tabText(1) == "Preview"
|
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")
|
print("PASS: video area has Source and Preview tabs")
|
||||||
|
|
||||||
win._load(CLIP)
|
win._load(CLIP)
|
||||||
@@ -47,11 +48,11 @@ def main():
|
|||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
assert win.video_tabs.currentIndex() == 0, "loading a new source should land on the Source tab"
|
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()
|
qapp.processEvents()
|
||||||
win._mark_video()
|
win._mark_video()
|
||||||
|
|
||||||
win.player.seek(5.350)
|
win.source_panel.player.seek(5.350)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
win._mark_audio()
|
win._mark_audio()
|
||||||
|
|
||||||
@@ -77,21 +78,41 @@ def main():
|
|||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
qapp.processEvents()
|
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)")
|
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 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_panel.player.mpv.pause is False, "preview should auto-play"
|
||||||
assert win.preview_play_button.text() == "Pause"
|
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
|
# -- Switching back to Source shouldn't disturb the main player, and the
|
||||||
# preview player keeps running independently (two live mpv instances).
|
# 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)
|
win.video_tabs.setCurrentIndex(0)
|
||||||
qapp.processEvents()
|
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")
|
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 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")
|
print("PASS: switching tabs doesn't disturb the main player")
|
||||||
|
|
||||||
# -- Building a second preview swaps the file without leaking the first
|
# -- 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")
|
print("PASS: Load File lives in the File menu")
|
||||||
|
|
||||||
# -- Right-click on the player toggles play/pause
|
# -- Right-click on the player toggles play/pause
|
||||||
assert win.player.mpv.pause is True, "should start paused"
|
assert win.source_panel.player.mpv.pause is True, "should start paused"
|
||||||
win.player.mpv.command("keypress", "MBTN_RIGHT")
|
win.source_panel.player.mpv.command("keypress", "MBTN_RIGHT")
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
assert win.player.mpv.pause is False, "right-click should have started playback"
|
assert win.source_panel.player.mpv.pause is False, "right-click should have started playback"
|
||||||
assert win.play_button.text() == "Pause"
|
assert win.source_panel.play_button.text() == "Pause"
|
||||||
print("PASS: right-click on player started playback")
|
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()
|
qapp.processEvents()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
assert win.player.mpv.pause is True, "second right-click should pause again"
|
assert win.source_panel.player.mpv.pause is True, "second right-click should pause again"
|
||||||
assert win.play_button.text() == "Play"
|
assert win.source_panel.play_button.text() == "Play"
|
||||||
print("PASS: right-click on player paused again")
|
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")
|
print("\nRIGHT-CLICK / FILE MENU SMOKE TEST PASSED")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ def main():
|
|||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
qapp.processEvents()
|
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()
|
qapp.processEvents()
|
||||||
win._mark_video()
|
win._mark_video()
|
||||||
print(f"video mark: {win.video_mark:.3f}s -> label: {win.video_mark_label.text()}")
|
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()
|
qapp.processEvents()
|
||||||
win._mark_audio()
|
win._mark_audio()
|
||||||
print(f"audio mark: {win.audio_mark:.3f}s -> label: {win.audio_mark_label.text()}")
|
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()
|
assert Path(EXPORT_OUT).exists()
|
||||||
print(f"exported file exists: {EXPORT_OUT}, size={Path(EXPORT_OUT).stat().st_size} bytes")
|
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")
|
print("\nUI SMOKE TEST PASSED")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ def main():
|
|||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
assert not win.log_view.isVisible()
|
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")
|
print("PASS: View > Show ffmpeg Log toggles the log panel correctly")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user