Files
WeAreSyncing/scripts/test_export_roundtrip.py
T
ThePendulum eb7c1aa500 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.
2026-07-26 03:01:06 +02:00

102 lines
4.3 KiB
Python

"""Integration test for ffmpeg_export.py against the synthetic ground-truth
fixture (build-order step 3, and a down payment on step 5).
Runs the real marker->offset->export pipeline against
test_media/sync_test_OUTOFSYNC_audio+350ms.mp4, then independently
re-detects where the flash and beep landed in the *output* file using
ffmpeg scene-change / silence detection (not the same code path used to
build the export, so this isn't circular) and checks they now line up.
"""
import re
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sync_logic import compute_offset
from ffmpeg_export import export_synced
ROOT = Path(__file__).resolve().parent.parent
OUT_DIR = ROOT / "test_media" / "seek_validation"
def find_video_event_time(path: Path) -> float:
"""Scene-change detection: the flash causes two sharp scene changes
(onset and offset), so the first hit is the flash's start time."""
proc = subprocess.run(
[
"ffmpeg", "-i", str(path),
"-vf", "select='gt(scene,0.15)',showinfo",
"-f", "null", "-",
],
capture_output=True, text=True,
)
times = [float(m) for m in re.findall(r"pts_time:([\d.]+)", proc.stderr)]
if not times:
raise RuntimeError(f"no scene change detected in {path}:\n{proc.stderr}")
return times[0]
def find_audio_event_time(path: Path) -> float:
"""silencedetect: the near-silent background audio going loud for the
beep produces a silence_end at (approximately) the beep's onset."""
proc = subprocess.run(
[
"ffmpeg", "-i", str(path),
"-af", "silencedetect=noise=-20dB:d=0.1",
"-f", "null", "-",
],
capture_output=True, text=True,
)
times = [float(m) for m in re.findall(r"silence_end: ([\d.]+)", proc.stderr)]
if not times:
raise RuntimeError(f"no silence_end detected in {path}:\n{proc.stderr}")
return times[0]
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}" + (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")
offset = compute_offset(video_mark_sec=video_mark, audio_mark_sec=audio_mark)
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), clip_start=clip_start, clip_duration=clip_duration)
print(f" ran: {' '.join(result.command)}")
after_video_t = find_video_event_time(output_path)
after_audio_t = find_audio_event_time(output_path)
print(f" post-export: flash at t={after_video_t:.3f}s, beep at t={after_audio_t:.3f}s")
drift_ms = abs(after_video_t - after_audio_t) * 1000
print(f" drift after export: {drift_ms:.1f}ms")
assert drift_ms < 34, f"export did not fix sync: {drift_ms:.1f}ms drift remains (expected < 1 frame ~33ms)"
print(f" PASS ({expected_target}-delay branch)\n")
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")
# 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__":
main()