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.
46 lines
1.7 KiB
Python
46 lines
1.7 KiB
Python
"""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)
|