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 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 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 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 match graph.arena.get(result.node_id) {
393 OpNode::Combine2 { op, params, .. } => {
394 assert_eq!(op, "xfade");
395 assert_eq!(params["offset"], ParamValue::Number(9.0));
397 }
398 other => panic!("expected Combine2 xfade, got {:?}", other),
399 }
400
401 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 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 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}