Replace preview popup with Kdenlive-style Source/Preview tabs

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

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

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

Verified on the virtual Xvfb display: automated test covers the tab
switch, independent playback state between tabs, and temp-file
lifecycle across rebuilding a preview and closing the window; also
driven live with real xdotool clicks (mark both events, build preview,
confirm auto-switch to the Preview tab with the clip already playing).
This commit is contained in:
ThePendulum
2026-07-26 03:17:01 +02:00
parent eb7c1aa500
commit 0a8f9f45bd
2 changed files with 132 additions and 109 deletions
+51 -44
View File
@@ -1,12 +1,8 @@
"""Verifies the sync preview feature: building a short offset-corrected
clip and playing it in a popup PreviewDialog, plus the Sync markers panel
now showing Mark Audio Event before Mark Video Event with a Detect All
Claps button alongside them.
PreviewDialog.exec() is a real modal event loop, which would block this
script's own flow with no way to close it from inside. Monkeypatches it to
show() non-modally instead (capturing the instance) purely for test
purposes -- production code still calls the real modal .exec().
clip and playing it in the Preview tab (Kdenlive-style project/clip
monitor tabs) instead of a popup dialog, plus the Sync markers panel
showing Mark Audio Event before Mark Video Event with a Detect All Claps
button alongside them.
"""
import sys
import time
@@ -23,13 +19,12 @@ CLIP = str(ROOT / "test_media" / "sync_test_OUTOFSYNC_audio+350ms.mp4")
def main():
qapp = QApplication(sys.argv)
# -- Marker panel order: Mark Audio Event before Mark Video Event, with
# Detect All Claps in the same panel.
win = app_main.MainWindow()
win.show()
qapp.processEvents()
# -- Marker panel order: Mark Audio Event before Mark Video Event, with
# Detect All Claps in the same panel.
marker_layout = win.mark_audio_button.parentWidget().layout()
widgets = [marker_layout.itemAt(i).widget() for i in range(marker_layout.count())]
widgets = [w for w in widgets if w is not None]
@@ -37,32 +32,20 @@ def main():
video_idx = widgets.index(win.mark_video_button)
detect_idx = widgets.index(win.detect_all_button)
assert audio_idx < video_idx, "Mark Audio Event should come before Mark Video Event"
assert win.detect_all_button in widgets, "Detect All Claps button should be in the same panel"
print(f"PASS: marker panel order is audio({audio_idx}) < video({video_idx}), detect button present at {detect_idx}")
# -- Preview: mark both events on the known-shifted fixture, build a
# preview clip, and confirm it plays back with the events much closer
# together than the original 350ms.
captured = {}
def fake_exec(self):
captured["dialog"] = self
# This is the only point guaranteed to run before the real
# _on_preview_ready's cleanup (os.unlink) fires -- a real .exec()
# would block here until the dialog closes, but this fake returns
# immediately, so the app's own cleanup-after-exec() code runs
# right after this function returns, whether or not the dialog is
# still visually open. Check existence here, not after.
captured["existed_while_open"] = Path(win._preview_temp_path).exists()
self.show()
return 0
app_main.PreviewDialog.exec = fake_exec
# -- Two tabs: Source (main player) and Preview
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
print("PASS: video area has Source and Preview tabs")
win._load(CLIP)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
assert win.video_tabs.currentIndex() == 0, "loading a new source should land on the Source tab"
win.player.seek(5.000)
qapp.processEvents()
@@ -73,40 +56,64 @@ def main():
win._mark_audio()
assert win.preview_button.isEnabled()
win.preview_button.click()
qapp.processEvents()
assert not win.preview_button.isEnabled(), "preview button should disable while building"
temp_path_ref = win._preview_temp_path
print(f"building preview clip at: {temp_path_ref}")
pending_path = win._pending_preview_path
print(f"building preview clip at: {pending_path}")
deadline = time.time() + 20
while "dialog" not in captured and time.time() < deadline:
while win.video_tabs.currentIndex() != 1 and time.time() < deadline:
qapp.processEvents()
time.sleep(0.05)
assert "dialog" in captured, "preview dialog was never shown"
dialog = captured["dialog"]
assert win.video_tabs.currentIndex() == 1, "finishing a preview should switch to the Preview tab"
assert win.preview_button.isEnabled()
assert win._preview_temp_path == pending_path
assert Path(win._preview_temp_path).exists()
print("PASS: preview build switched to the Preview tab automatically")
qapp.processEvents()
time.sleep(0.3)
qapp.processEvents()
duration = dialog.player.mpv.duration
duration = win.preview_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 captured["existed_while_open"], "temp preview file should have existed while the dialog was showing"
dialog.close()
# -- 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()
win.video_tabs.setCurrentIndex(0)
qapp.processEvents()
main_pos_after_switch = win.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)"
print("PASS: switching tabs doesn't disturb the main player")
# -- Building a second preview swaps the file without leaking the first
# temp file.
first_preview_path = win._preview_temp_path
win.preview_button.click()
qapp.processEvents()
deadline = time.time() + 20
while win._pending_preview_path is not None and time.time() < deadline:
qapp.processEvents()
time.sleep(0.05)
time.sleep(0.2)
qapp.processEvents()
assert not Path(first_preview_path).exists(), "first preview temp file should be cleaned up after being replaced"
assert Path(win._preview_temp_path).exists()
print("PASS: rebuilding the preview cleans up the previous temp file")
assert not Path(temp_path_ref).exists(), "temp preview file should be cleaned up after the (real) exec() returns"
print("PASS: preview clip built, played, and cleaned up correctly")
win.close()
qapp.processEvents()
assert not Path(win._preview_temp_path).exists() if win._preview_temp_path else True
win.player.shutdown()
print("\nPREVIEW SMOKE TEST PASSED")
print("\nPREVIEW TAB SMOKE TEST PASSED")
if __name__ == "__main__":