Spaces:
Sleeping
Sleeping
| """ | |
| 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", | |
| } | |