from __future__ import annotations import math from .models import Phrase, RevealEvent, Timeline, WordTiming def frame_at_or_after(seconds: float, fps: float) -> int: return max(0, math.ceil(seconds * fps - 1e-9)) def frame_at_or_before(seconds: float, fps: float) -> int: return max(0, math.floor(seconds * fps + 1e-9)) def build_timeline( source_media: str, language: str, words: list[WordTiming], phrases: list[Phrase], *, fps: float, desired_prereveal_ms: float = 90.0, pulse_ms: float = 110.0, metadata: dict | None = None, ) -> Timeline: """Create deterministic reveal events from measured word boundaries. For a real inter-word gap, place the reveal as late as practical in that gap. If there is no gap, reveal on the first frame at/after the new word's onset. This prevents future words from appearing while an earlier word is speaking. """ events: list[RevealEvent] = [] desired = desired_prereveal_ms / 1000.0 pulse_frames = max(1, round((pulse_ms / 1000.0) * fps)) for phrase in phrases: phrase_words = [words[i] for i in phrase.word_indices] for local_index, word in enumerate(phrase_words): if local_index == 0: previous_end = ( words[phrase.word_indices[0] - 1].end if phrase.word_indices[0] > 0 else 0.0 ) else: previous_end = phrase_words[local_index - 1].end gap = word.start - previous_end if gap > 0: reveal_time = max(previous_end, word.start - desired) reveal_frame = frame_at_or_after(reveal_time, fps) # Never quantize beyond the measured onset when a pre-onset frame exists. onset_floor = frame_at_or_before(word.start, fps) reveal_frame = min(reveal_frame, onset_floor) reveal_frame = max(reveal_frame, frame_at_or_after(previous_end, fps)) else: reveal_time = word.start reveal_frame = frame_at_or_after(word.start, fps) measured_end_frame = max( reveal_frame + 1, frame_at_or_after(word.end, fps), ) visible_text = " ".join(item.word for item in phrase_words[: local_index + 1]) events.append(RevealEvent( event_index=len(events), phrase_id=phrase.id, word_index=word.index, word=word.word, visible_text=visible_text, measured_start=word.start, measured_end=word.end, reveal_time=reveal_frame / fps, reveal_frame=reveal_frame, end_frame=measured_end_frame, pulse_frames=pulse_frames, confidence=word.score, review_required=word.review_required, )) # Each event remains active until the next reveal event. The final phrase holds # through its final measured word end. for i in range(len(events) - 1): events[i].end_frame = max(events[i].reveal_frame + 1, events[i + 1].reveal_frame) return Timeline( fps=fps, language=language, source_media=source_media, words=words, phrases=phrases, events=events, metadata=metadata or {}, )