""" Timeline Engine — the core abstraction the whole renderer is built around. Rule #1 from the PRD: never concatenate FFmpeg commands directly inside API handlers. Everything goes: raw request -> Timeline (this module) -> FilterGraph (compiler.py) -> ffmpeg. Adding a new animation/transition means touching compiler.py, never main.py or renderer.py. """ from dataclasses import dataclass, field from typing import Optional @dataclass class Clip: id: str kind: str # "video" | "image" local_path: str start: float # absolute start time on the timeline, seconds end: float # absolute end time on the timeline, seconds animation: str = "none" transition_in: str = "none" transition_duration: float = 0.5 @property def duration(self) -> float: return round(self.end - self.start, 3) @dataclass class Timeline: clips: list[Clip] = field(default_factory=list) voice_path: Optional[str] = None bgm_path: Optional[str] = None bgm_volume: float = 0.15 subtitle_path: Optional[str] = None subtitle_style: str = "default" @property def total_duration(self) -> float: return max((c.end for c in self.clips), default=0.0) def add_clip(self, clip_id: str, kind: str, local_path: str, duration: float, animation: str = "none", transition_in: str = "none", transition_duration: float = 0.5): start = self.total_duration end = start + duration self.clips.append(Clip( id=clip_id, kind=kind, local_path=local_path, start=start, end=end, animation=animation, transition_in=transition_in, transition_duration=transition_duration, )) def build_timeline(resolved_clips: list[dict], voice_path=None, bgm_path=None, bgm_volume=0.15, subtitle_path=None, subtitle_style="default") -> Timeline: """ resolved_clips: list of dicts already downloaded to local disk, each with keys: id, type, local_path, duration (probed for video, given for image), animation, transition_in, transition_duration. """ tl = Timeline( voice_path=voice_path, bgm_path=bgm_path, bgm_volume=bgm_volume, subtitle_path=subtitle_path, subtitle_style=subtitle_style, ) for c in resolved_clips: tl.add_clip( clip_id=c["id"], kind=c["type"], local_path=c["local_path"], duration=c["duration"], animation=c.get("animation", "none"), transition_in=c.get("transition_in", "none"), transition_duration=c.get("transition_duration", 0.5), ) return tl