""" Compiles a Timeline into an ffmpeg filter_complex graph + input args. This is the one place that knows what "zoom_in" or "fade" means in ffmpeg filter syntax. Everything upstream (API, renderer) only ever talks about the Timeline's abstract Scene/Layer/Clip model. """ from app.core.timeline import Timeline, Clip OUT_W, OUT_H, FPS = 1920, 1080, 30 def _image_animation_filter(clip: Clip, idx: int) -> str: """Ken Burns-style zoompan for a still image input.""" frames = int(clip.duration * FPS) zp = "zoompan=z='min(zoom+0.0015,1.5)':d={frames}:s={w}x{h}:fps={fps}" pan_left = "zoompan=z='1.2':x='iw*0.2-on/({frames}/(iw*0.2))':d={frames}:s={w}x{h}:fps={fps}" pan_right = "zoompan=z='1.2':x='on*(iw*0.2)/{frames}':d={frames}:s={w}x{h}:fps={fps}" animations = { "zoom_in": zp, "zoom_out": "zoompan=z='if(lte(zoom,1.0),1.5,max(1.001,zoom-0.0015))':d={frames}:s={w}x{h}:fps={fps}", "pan_left": pan_left, "pan_right": pan_right, "fade": zp, # base motion + fade handled separately via transition filter "blur": zp, "rotate": zp, "parallax": zp, "none": "scale={w}:{h}", } template = animations.get(clip.animation, animations["none"]) return template.format(frames=frames, w=OUT_W, h=OUT_H, fps=FPS) def _transition_filter(clip: Clip, stream_label: str) -> str: """Wraps a stream with its entry transition (fade-in style transitions for v1; xfade-based cross-clip transitions are the natural v2 extension once multiple concurrent streams are common).""" d = clip.transition_duration if clip.transition_in in ("fade", "cross"): return f"[{stream_label}]fade=t=in:st=0:d={d}[{stream_label}tr]" return f"[{stream_label}]null[{stream_label}tr]" def compile_filter_graph(timeline: Timeline) -> dict: """ Returns {"inputs": [...ffmpeg -i args...], "filter_complex": str, "video_out_label": str, "map_args": [...]} """ input_args = [] filter_lines = [] concat_labels = [] for idx, clip in enumerate(timeline.clips): stream = f"v{idx}" if clip.kind == "image": input_args += ["-loop", "1", "-t", str(clip.duration), "-i", clip.local_path] vf = _image_animation_filter(clip, idx) filter_lines.append(f"[{idx}:v]{vf},setsar=1[{stream}]") else: # video input_args += ["-i", clip.local_path] filter_lines.append( f"[{idx}:v]scale={OUT_W}:{OUT_H}:force_original_aspect_ratio=decrease," f"pad={OUT_W}:{OUT_H}:(ow-iw)/2:(oh-ih)/2,setsar=1,trim=duration={clip.duration}[{stream}]" ) filter_lines.append(_transition_filter(clip, stream)) concat_labels.append(f"[{stream}tr]") n = len(timeline.clips) filter_lines.append(f"{''.join(concat_labels)}concat=n={n}:v=1:a=0[vout]") return { "input_args": input_args, "filter_complex": ";".join(filter_lines), "video_out_label": "vout", }