File size: 3,034 Bytes
9459cd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
"""
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",
    }