Skip to main content

renderbox_sdk/ops/
timeline.rs

1use std::rc::Rc;
2
3use crate::ops::{add_video_filter, params};
4use crate::ops::combine::{xfade, XfadeTransition};
5use crate::ops::video::{scale, pad, format};
6use crate::sorts::Video;
7use crate::graph::Stream;
8
9#[derive(Debug, Clone, Copy)]
10pub struct Transition {
11    pub kind: XfadeTransition,
12    pub duration: f64,
13}
14
15impl Transition {
16    pub fn dissolve(duration: f64) -> Self {
17        Self { kind: XfadeTransition::Dissolve, duration }
18    }
19
20    pub fn fade(duration: f64) -> Self {
21        Self { kind: XfadeTransition::Fade, duration }
22    }
23
24    pub fn wipeleft(duration: f64) -> Self {
25        Self { kind: XfadeTransition::Wipeleft, duration }
26    }
27
28    pub fn wiperight(duration: f64) -> Self {
29        Self { kind: XfadeTransition::Wiperight, duration }
30    }
31
32    pub fn radial(duration: f64) -> Self {
33        Self { kind: XfadeTransition::Radial, duration }
34    }
35
36    pub fn pixelize(duration: f64) -> Self {
37        Self { kind: XfadeTransition::Pixelize, duration }
38    }
39
40    pub fn circleopen(duration: f64) -> Self {
41        Self { kind: XfadeTransition::Circleopen, duration }
42    }
43
44    pub fn circleclose(duration: f64) -> Self {
45        Self { kind: XfadeTransition::Circleclose, duration }
46    }
47}
48
49#[derive(Debug, Clone, Copy)]
50pub enum KenBurnsPreset {
51    GentleZoomIn,
52    GentleZoomOut,
53    GentlePan,
54    ZoomIn,
55    ZoomOut,
56    PanRight,
57    PanLeft,
58    PunchIn,
59    PullBack,
60    Spiral,
61    Diagonal,
62    Parallax,
63}
64
65const CENTER_X: &str = "iw/2-(iw/zoom/2)";
66const CENTER_Y: &str = "ih/2-(ih/zoom/2)";
67const EDGE_X: &str = "iw-(iw/zoom)";
68const EDGE_Y: &str = "ih-(ih/zoom)";
69const DRIFT_X: &str = "iw/6";
70const DRIFT_Y: &str = "ih/6";
71
72fn lerp_expr(frames: u32, from: &str, to: &str) -> String {
73    format!("{from}+({to}-({from}))*on/{frames}")
74}
75
76fn ease_in_out_expr(frames: u32, from: &str, to: &str) -> String {
77    format!(
78        "{from}+({to}-({from}))*(3*pow(on/{frames},2)-2*pow(on/{frames},3))"
79    )
80}
81
82struct KenBurnsExprs {
83    zoom: String,
84    x: String,
85    y: String,
86}
87
88fn preset_exprs(preset: KenBurnsPreset, frames: u32) -> KenBurnsExprs {
89    match preset {
90        KenBurnsPreset::GentleZoomIn => KenBurnsExprs {
91            zoom: lerp_expr(frames, "1", "1.15"),
92            x: CENTER_X.into(),
93            y: CENTER_Y.into(),
94        },
95        KenBurnsPreset::GentleZoomOut => KenBurnsExprs {
96            zoom: lerp_expr(frames, "1.15", "1"),
97            x: CENTER_X.into(),
98            y: CENTER_Y.into(),
99        },
100        KenBurnsPreset::GentlePan => KenBurnsExprs {
101            zoom: "1.05".into(),
102            x: ease_in_out_expr(frames, "0", CENTER_X),
103            y: CENTER_Y.into(),
104        },
105        KenBurnsPreset::ZoomIn => KenBurnsExprs {
106            zoom: ease_in_out_expr(frames, "1", "1.5"),
107            x: CENTER_X.into(),
108            y: CENTER_Y.into(),
109        },
110        KenBurnsPreset::ZoomOut => KenBurnsExprs {
111            zoom: ease_in_out_expr(frames, "1.5", "1"),
112            x: CENTER_X.into(),
113            y: CENTER_Y.into(),
114        },
115        KenBurnsPreset::PanRight => KenBurnsExprs {
116            zoom: "1.1".into(),
117            x: ease_in_out_expr(frames, "0", EDGE_X),
118            y: CENTER_Y.into(),
119        },
120        KenBurnsPreset::PanLeft => KenBurnsExprs {
121            zoom: "1.1".into(),
122            x: ease_in_out_expr(frames, EDGE_X, "0"),
123            y: CENTER_Y.into(),
124        },
125        KenBurnsPreset::PunchIn => KenBurnsExprs {
126            zoom: ease_in_out_expr(frames, "1", "2"),
127            x: CENTER_X.into(),
128            y: CENTER_Y.into(),
129        },
130        KenBurnsPreset::PullBack => KenBurnsExprs {
131            zoom: ease_in_out_expr(frames, "2", "1"),
132            x: CENTER_X.into(),
133            y: CENTER_Y.into(),
134        },
135        KenBurnsPreset::Spiral => KenBurnsExprs {
136            zoom: ease_in_out_expr(frames, "1", "1.4"),
137            x: format!(
138                "{CENTER_X}+{DRIFT_X}*sin(2*PI*on/{frames})"
139            ),
140            y: format!(
141                "{CENTER_Y}+{DRIFT_Y}*cos(2*PI*on/{frames})"
142            ),
143        },
144        KenBurnsPreset::Diagonal => KenBurnsExprs {
145            zoom: ease_in_out_expr(frames, "1", "1.3"),
146            x: ease_in_out_expr(frames, "0", EDGE_X),
147            y: ease_in_out_expr(frames, "0", EDGE_Y),
148        },
149        KenBurnsPreset::Parallax => KenBurnsExprs {
150            zoom: "1.2".into(),
151            x: ease_in_out_expr(frames, DRIFT_X, &format!("{EDGE_X}-{DRIFT_X}")),
152            y: CENTER_Y.into(),
153        },
154    }
155}
156
157fn apply_ken_burns(
158    input: Stream<Video>,
159    preset: KenBurnsPreset,
160    duration: f64,
161    w: u32,
162    h: u32,
163    fps: u32,
164) -> Stream<Video> {
165    let frames = (duration * fps as f64) as u32;
166    let exprs = preset_exprs(preset, frames);
167    let s = format!("{w}x{h}");
168
169    let doubled_w = w * 2;
170    let doubled_h = h * 2;
171
172    let stream = input
173        .pipe(scale(doubled_w, doubled_h))
174        .pipe(pad(doubled_w, doubled_h));
175
176    let stream = add_video_filter(
177        stream,
178        "zoompan",
179        params! {
180            "zoom" => exprs.zoom,
181            "x" => exprs.x,
182            "y" => exprs.y,
183            "d" => frames,
184            "s" => s,
185            "fps" => fps
186        },
187    );
188
189    stream.pipe(format("yuv420p"))
190}
191
192pub fn ken_burns(
193    preset: KenBurnsPreset,
194    duration: f64,
195    w: u32,
196    h: u32,
197) -> impl Fn(Stream<Video>) -> Stream<Video> {
198    move |input| apply_ken_burns(input, preset, duration, w, h, 25)
199}
200
201pub fn ken_burns_with_fps(
202    preset: KenBurnsPreset,
203    duration: f64,
204    w: u32,
205    h: u32,
206    fps: u32,
207) -> impl Fn(Stream<Video>) -> Stream<Video> {
208    move |input| apply_ken_burns(input, preset, duration, w, h, fps)
209}
210
211pub fn timeline(
212    clips: Vec<Stream<Video>>,
213    clip_durations: &[f64],
214    transition: &Transition,
215) -> Stream<Video> {
216    assert!(
217        clips.len() >= 2,
218        "timeline requires at least 2 clips"
219    );
220    assert_eq!(
221        clips.len(),
222        clip_durations.len(),
223        "clips and clip_durations must have the same length"
224    );
225    let graph = clips[0].graph.clone();
226    for s in &clips[1..] {
227        assert!(
228            Rc::ptr_eq(&graph, &s.graph),
229            "all clips must belong to the same graph"
230        );
231    }
232
233    let mut acc = clips.into_iter();
234    let mut result = acc.next().unwrap();
235    let mut elapsed_content = clip_durations[0];
236    let mut elapsed_transitions = 0.0;
237
238    for (i, clip) in acc.enumerate() {
239        let offset = elapsed_content - elapsed_transitions;
240        result = xfade(result, clip, transition.kind, transition.duration, offset);
241        elapsed_content += clip_durations[i + 1];
242        elapsed_transitions += transition.duration;
243    }
244
245    result
246}
247
248pub fn slideshow(
249    images: Vec<Stream<Video>>,
250    segment_duration: f64,
251    transition: &Transition,
252    preset: KenBurnsPreset,
253    w: u32,
254    h: u32,
255) -> Stream<Video> {
256    assert!(
257        images.len() >= 2,
258        "slideshow requires at least 2 images"
259    );
260    let graph = images[0].graph.clone();
261    for s in &images[1..] {
262        assert!(
263            Rc::ptr_eq(&graph, &s.graph),
264            "all images must belong to the same graph"
265        );
266    }
267
268    let clips: Vec<Stream<Video>> = images
269        .into_iter()
270        .map(|img| img.pipe(ken_burns(preset, segment_duration, w, h)))
271        .collect();
272
273    let durations: Vec<f64> = vec![segment_duration; clips.len()];
274    timeline(clips, &durations, transition)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::graph::input;
281    use renderbox_dsl::{OpNode, ParamValue};
282
283    #[test]
284    fn transition_constructors() {
285        let t = Transition::dissolve(1.5);
286        assert!(matches!(t.kind, XfadeTransition::Dissolve));
287        assert_eq!(t.duration, 1.5);
288
289        let t = Transition::fade(0.5);
290        assert!(matches!(t.kind, XfadeTransition::Fade));
291        assert_eq!(t.duration, 0.5);
292
293        let t = Transition::radial(2.0);
294        assert!(matches!(t.kind, XfadeTransition::Radial));
295        assert_eq!(t.duration, 2.0);
296    }
297
298    #[test]
299    fn ken_burns_preset_expressions() {
300        let exprs = preset_exprs(KenBurnsPreset::GentleZoomIn, 125);
301        assert_eq!(exprs.zoom, "1+(1.15-(1))*on/125");
302        assert_eq!(exprs.x, CENTER_X);
303        assert_eq!(exprs.y, CENTER_Y);
304
305        let exprs = preset_exprs(KenBurnsPreset::ZoomIn, 250);
306        assert_eq!(
307            exprs.zoom,
308            "1+(1.5-(1))*(3*pow(on/250,2)-2*pow(on/250,3))"
309        );
310
311        let exprs = preset_exprs(KenBurnsPreset::PanRight, 100);
312        assert_eq!(exprs.zoom, "1.1");
313        assert_eq!(
314            exprs.x,
315            format!("0+({EDGE_X}-(0))*(3*pow(on/100,2)-2*pow(on/100,3))")
316        );
317
318        let exprs = preset_exprs(KenBurnsPreset::Spiral, 200);
319        assert!(exprs.x.contains("sin(2*PI*on/200)"));
320        assert!(exprs.y.contains("cos(2*PI*on/200)"));
321    }
322
323    #[test]
324    fn ken_burns_node_chain() {
325        let (v, _a) = input("photo.jpg");
326        let result = v.pipe(ken_burns(KenBurnsPreset::GentleZoomIn, 5.0, 1920, 1080));
327        let graph = result.graph.borrow();
328
329        // Walk backwards: format <- zoompan <- pad <- scale <- input
330        match graph.arena.get(result.node_id) {
331            OpNode::Filter { name, .. } => assert_eq!(name, "format"),
332            other => panic!("expected format Filter, got {:?}", other),
333        }
334
335        // Find zoompan by checking node count: input(0) + scale(1) + pad(2) + zoompan(3) + format(4)
336        assert_eq!(graph.arena.len(), 5);
337    }
338
339    #[test]
340    fn ken_burns_zoompan_params() {
341        let (v, _a) = input("photo.jpg");
342        let result = v.pipe(ken_burns(KenBurnsPreset::GentleZoomIn, 5.0, 1920, 1080));
343        let graph = result.graph.borrow();
344
345        // format node's input is the zoompan node
346        let format_input = match graph.arena.get(result.node_id) {
347            OpNode::Filter { input, .. } => *input,
348            other => panic!("expected format Filter, got {:?}", other),
349        };
350        match graph.arena.get(format_input) {
351            OpNode::Filter { name, params, .. } => {
352                assert_eq!(name, "zoompan");
353                assert_eq!(params["s"], ParamValue::String("1920x1080".into()));
354                assert_eq!(params["d"], ParamValue::Number(125.0));
355                assert_eq!(params["fps"], ParamValue::Number(25.0));
356            }
357            other => panic!("expected zoompan Filter, got {:?}", other),
358        }
359    }
360
361    #[test]
362    fn timeline_two_clips() {
363        let (v, _a) = input("test.mp4");
364        let v1 = v.clone();
365        let v2 = v.clone();
366        let t = Transition::dissolve(1.0);
367        let result = timeline(vec![v1, v2], &[5.0, 5.0], &t);
368
369        let graph = result.graph.borrow();
370        match graph.arena.get(result.node_id) {
371            OpNode::Combine2 { op, params, .. } => {
372                assert_eq!(op, "xfade");
373                assert_eq!(params["transition"], ParamValue::String("dissolve".into()));
374                assert_eq!(params["duration"], ParamValue::Number(1.0));
375                assert_eq!(params["offset"], ParamValue::Number(5.0));
376            }
377            other => panic!("expected Combine2 xfade, got {:?}", other),
378        }
379    }
380
381    #[test]
382    fn timeline_three_clips() {
383        let (v, _a) = input("test.mp4");
384        let v1 = v.clone();
385        let v2 = v.clone();
386        let v3 = v.clone();
387        let t = Transition::dissolve(1.0);
388        let result = timeline(vec![v1, v2, v3], &[5.0, 5.0, 5.0], &t);
389
390        let graph = result.graph.borrow();
391        // Final node is the second xfade
392        match graph.arena.get(result.node_id) {
393            OpNode::Combine2 { op, params, .. } => {
394                assert_eq!(op, "xfade");
395                // offset for clip 3: (5.0 + 5.0) - 1.0 = 9.0
396                assert_eq!(params["offset"], ParamValue::Number(9.0));
397            }
398            other => panic!("expected Combine2 xfade, got {:?}", other),
399        }
400
401        // Should have: input(0), xfade1(1), xfade2(2) = 3 nodes
402        assert_eq!(graph.arena.len(), 3);
403    }
404
405    #[test]
406    fn slideshow_produces_ken_burns_and_xfade() {
407        let (v, _a) = input("test.mp4");
408        let images: Vec<Stream<Video>> = (0..3).map(|_| v.clone()).collect();
409        let t = Transition::dissolve(1.0);
410        let result = slideshow(images, 5.0, &t, KenBurnsPreset::GentleZoomIn, 1920, 1080);
411
412        let graph = result.graph.borrow();
413        // Final node should be an xfade
414        match graph.arena.get(result.node_id) {
415            OpNode::Combine2 { op, .. } => assert_eq!(op, "xfade"),
416            other => panic!("expected Combine2 xfade, got {:?}", other),
417        }
418
419        // 1 input + 3*(scale+pad+zoompan+format) + 2 xfades = 1 + 12 + 2 = 15
420        assert_eq!(graph.arena.len(), 15);
421    }
422
423    #[test]
424    #[should_panic(expected = "timeline requires at least 2 clips")]
425    fn timeline_requires_at_least_2() {
426        let (v, _a) = input("test.mp4");
427        let t = Transition::dissolve(1.0);
428        timeline(vec![v], &[5.0], &t);
429    }
430
431    #[test]
432    #[should_panic(expected = "slideshow requires at least 2 images")]
433    fn slideshow_requires_at_least_2() {
434        let (v, _a) = input("test.mp4");
435        let t = Transition::dissolve(1.0);
436        slideshow(vec![v], 5.0, &t, KenBurnsPreset::GentleZoomIn, 1920, 1080);
437    }
438
439    #[test]
440    fn lerp_expr_format() {
441        assert_eq!(lerp_expr(100, "1", "2"), "1+(2-(1))*on/100");
442    }
443
444    #[test]
445    fn ease_in_out_expr_format() {
446        assert_eq!(
447            ease_in_out_expr(100, "1", "2"),
448            "1+(2-(1))*(3*pow(on/100,2)-2*pow(on/100,3))"
449        );
450    }
451}