Add sync preview, restore Detect All Claps button, reorder markers panel

- Preview Sync button builds a short (8s) lossless clip around the
  marked events with the current offset applied, and plays it in a
  small popup PreviewDialog (its own independent PlayerWidget/mpv
  instance) -- lets you check the fix sounds/looks right before
  committing to a full export. ExportWorker gained optional
  clip_start/clip_duration so both the real export and this preview
  share the same underlying export_synced() call.
- Detect All Claps is back as a button in the Sync markers panel
  (alongside Mark Audio/Video Event), in addition to staying in the
  Tools menu -- both widgets' enabled state are kept in sync.
- Sync markers panel now shows Mark Audio Event before Mark Video
  Event.

The clip-mode branch of ffmpeg_export.py (the -ss/-t-on-both-inputs
path ffdelay's own notes flag as easy to get subtly wrong) hadn't been
covered by the existing roundtrip test, which only exercised full-file
export -- added a clip-mode case there, independently verified via the
same scene-change/silence-detection technique as the other cases:
29.4ms drift after the fix, well under one frame.

All of this was built and verified entirely on a virtual Xvfb display
(DISPLAY=:99) per the user's request to stop interrupting their real
desktop -- real mouse clicks and a real (non-monkeypatched) preview
dialog were exercised there, screenshotted, and confirmed correct,
without ever touching the real screen.
This commit is contained in:
ThePendulum
2026-07-26 03:01:06 +02:00
parent 6f76b3f15a
commit eb7c1aa500
4 changed files with 277 additions and 18 deletions
+14 -4
View File
@@ -54,11 +54,11 @@ def find_audio_event_time(path: Path) -> float:
return times[0]
def run_case(fixture_name: str, expected_target: str, output_name: str):
def run_case(fixture_name: str, expected_target: str, output_name: str, clip_start=None, clip_duration=None):
input_path = ROOT / "test_media" / fixture_name
output_path = OUT_DIR / output_name
print(f"Input fixture: {fixture_name}")
print(f"Input fixture: {fixture_name}" + (f" (clip {clip_start}+{clip_duration}s)" if clip_start else ""))
video_mark = find_video_event_time(input_path)
audio_mark = find_audio_event_time(input_path)
print(f" detected flash at t={video_mark:.3f}s, beep at t={audio_mark:.3f}s")
@@ -67,7 +67,7 @@ def run_case(fixture_name: str, expected_target: str, output_name: str):
print(f" {offset.describe()}")
assert offset.target == expected_target, f"expected {expected_target} branch, got {offset.target}"
result = export_synced(str(input_path), offset.delay_ms, str(output_path))
result = export_synced(str(input_path), offset.delay_ms, str(output_path), clip_start=clip_start, clip_duration=clip_duration)
print(f" ran: {' '.join(result.command)}")
after_video_t = find_video_event_time(output_path)
@@ -84,7 +84,17 @@ def main():
OUT_DIR.mkdir(exist_ok=True)
run_case("sync_test_OUTOFSYNC_audio+350ms.mp4", "video", "_export_roundtrip_negative.mp4")
run_case("sync_test_OUTOFSYNC_video+350ms.mp4", "audio", "_export_roundtrip_positive.mp4")
print("PASS: both ffmpeg command branches (positive and negative delay) round-trip correctly.")
# Clip mode (used by the GUI's preview feature) is a genuinely different
# code path -- ffdelay's own documented quirk is that -ss/-t must be
# applied to BOTH inputs, in the right position relative to -itsoffset,
# or the clip covers different real-world windows per input and the
# "preview" would silently show wrong sync. Exercise it explicitly
# rather than assuming the full-file case generalizes.
run_case(
"sync_test_OUTOFSYNC_audio+350ms.mp4", "video", "_export_roundtrip_clip_mode.mp4",
clip_start="2.000", clip_duration=8,
)
print("PASS: both ffmpeg command branches (positive and negative delay) round-trip correctly, including clip mode.")
if __name__ == "__main__":