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:
@@ -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)
|
||||
Reference in New Issue
Block a user