Implement sync_logic.py and ffmpeg_export.py (build order steps 2-3)

sync_logic.py: pure delay_ms computation with the documented sign
convention. Caught and fixed a sign error in my own test-fixture
ground-truth doc while writing the unit tests: the audio+350ms fixture
actually requires delay_ms=-350 (delay video), not +350, since the
audio is already the late one.

ffmpeg_export.py: ports ffdelay's two-branch command structure
(offset video input vs audio input depending on delay_ms sign) plus
its documented quirks (leading-zero float formatting, -ss/-t applied
to both inputs for clip mode, non-fatal stream-copy warnings surfaced
via stderr rather than swallowed).

Added an end-to-end integration test (scripts/test_export_roundtrip.py)
that independently re-detects event timing in the exported output via
ffmpeg scene-change/silence detection (not the same code path used to
build the export) and confirms both the positive- and negative-delay
branches bring drift down from ~350ms to under 1ms.
This commit is contained in:
ThePendulum
2026-07-26 00:50:57 +02:00
parent 91390f751d
commit f634dda5bb
6 changed files with 274 additions and 4 deletions
+45
View File
@@ -0,0 +1,45 @@
"""Pure marker/offset math -- no UI, no ffmpeg, no mpv. Easily testable
in isolation per the project's suggested build order.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class SyncOffset:
delay_ms: float
@property
def target(self) -> str:
"""Which track the export step needs to delay to fix sync."""
if self.delay_ms > 0:
return "audio"
if self.delay_ms < 0:
return "video"
return "none"
def describe(self) -> str:
if self.delay_ms == 0:
return "Offset: 0 ms (already in sync)"
sign = "+" if self.delay_ms > 0 else ""
action = "audio will be delayed" if self.delay_ms > 0 else "video will be delayed"
return f"Offset: {sign}{self.delay_ms:.0f} ms ({action})"
def compute_offset(video_mark_sec: float, audio_mark_sec: float) -> SyncOffset:
"""video_mark_sec / audio_mark_sec are timeline positions (seconds) in
the *currently loaded* file where the same real-world event (clap/flash/
beep) was observed on each track.
delay_ms = (video_mark - audio_mark) * 1000
Sign convention (matches PROJECT_BRIEF.md and ffmpeg_export.py):
- delay_ms > 0: the audio event appears *earlier* in the timeline than
the video event, i.e. audio is ahead -- it needs to be delayed
(shifted later) to line up with video.
- delay_ms < 0: the audio event appears *later* than the video event,
i.e. audio is already behind -- delaying it further would be wrong.
Instead the video gets delayed by the same amount, which catches
video up to where the (late) audio already is.
- delay_ms == 0: already in sync.
"""
return SyncOffset(delay_ms=(video_mark_sec - audio_mark_sec) * 1000.0)