"""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)