Skip to main content

ffcine_comp/templates/
layout.rs

1//! The `layout` template (`ffcine.comp.layout/1`): split-screen geometry, and a comp of panels
2//! placed by it. The timeline's split `Stack`s use [`Layout`] (`ffcine-assembly`); a comp file
3//! uses the template as `"template": {"layout": {...}, "panels": [...], "size": [W, H], "fps":
4//! ..., "duration": ...}`.
5
6use serde_json::{Map, Value, json};
7
8use super::{item_layer, jnum, num};
9use crate::model::err;
10use ffcine_core::Result;
11
12fn f(m: &Map<String, Value>, k: &str) -> Option<f64> {
13    m.get(k).and_then(Value::as_f64)
14}
15
16/// The split-screen backdrop's colour, (22, 20, 18). The timeline's split also darkens its
17/// corners (vignette 0.35), which a comp's canvas background can't express.
18pub const BACKDROP: &str = "#161412";
19
20/// A rectangle in output pixels.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Rect {
23    pub x: f64,
24    pub y: f64,
25    pub w: f64,
26    pub h: f64,
27}
28
29/// Split-screen layout (`ffcine.comp.layout/1`), `render.py` `layout_geom` and `layout_chain`.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Layout {
32    /// `split`/`row` (side by side), `column` (stacked), `1+2` (one left, two stacked right).
33    pub kind: String,
34    pub gap: f64,
35    pub margin: f64,
36    pub radius: f64,
37    pub weights: Vec<f64>,
38    pub stagger: f64,
39    pub slide: f64,
40    pub enter: bool,
41    pub first_static: bool,
42}
43
44impl Layout {
45    pub fn parse(m: &Map<String, Value>) -> Self {
46        Self {
47            kind: m
48                .get("layout")
49                .and_then(Value::as_str)
50                .unwrap_or("split")
51                .to_owned(),
52            gap: f(m, "gap").unwrap_or(0.008),
53            margin: f(m, "margin").unwrap_or(0.0),
54            radius: f(m, "radius").unwrap_or(0.012),
55            weights: m
56                .get("weights")
57                .and_then(Value::as_array)
58                .map(|a| a.iter().filter_map(Value::as_f64).collect())
59                .unwrap_or_default(),
60            stagger: f(m, "stagger").unwrap_or(0.22),
61            slide: f(m, "slide").unwrap_or(0.42),
62            enter: m.get("enter").and_then(Value::as_str) != Some("none"),
63            first_static: m
64                .get("first_static")
65                .and_then(Value::as_bool)
66                .unwrap_or(true),
67        }
68    }
69
70    /// Corner radius in pixels for a frame `h` tall.
71    pub fn radius_px(&self, h: u32) -> f64 {
72        (self.radius * f64::from(h)).round()
73    }
74
75    /// Panel rectangles for `k` panels in a `w x h` frame (even sizes, as `render.py`).
76    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
77    #[allow(clippy::many_single_char_names)]
78    pub fn rects(&self, k: usize, w: u32, h: u32) -> Vec<Rect> {
79        let (fw, fh) = (f64::from(w), f64::from(h));
80        let gap = (self.gap * fh).round();
81        let mar = (self.margin * fh).round();
82        let even = |x: f64| (x / 2.0).floor() * 2.0;
83        // Sizes along one axis: `avail` split by the weights, the last one taking the rest.
84        let split = |avail: f64, k: usize, weights: &[f64]| -> Vec<f64> {
85            let wts: Vec<f64> = if weights.len() == k {
86                weights.to_vec()
87            } else {
88                vec![1.0; k]
89            };
90            let sum: f64 = wts.iter().sum();
91            let mut v: Vec<f64> = wts
92                .iter()
93                .map(|x| even((avail * x / sum).floor()))
94                .collect();
95            if v.pop().is_some() {
96                let rest: f64 = v.iter().sum();
97                v.push(even(avail - rest));
98            }
99            v
100        };
101        let row = |x0: f64, y0: f64, avail_w: f64, ph: f64, k: usize, wts: &[f64]| {
102            let mut x = x0;
103            split(avail_w, k, wts)
104                .into_iter()
105                .map(|pw| {
106                    let r = Rect {
107                        x,
108                        y: y0,
109                        w: pw,
110                        h: ph,
111                    };
112                    x += pw + gap;
113                    r
114                })
115                .collect::<Vec<_>>()
116        };
117        let col = |x0: f64, y0: f64, pw: f64, avail_h: f64, k: usize, wts: &[f64]| {
118            let mut y = y0;
119            split(avail_h, k, wts)
120                .into_iter()
121                .map(|ph| {
122                    let r = Rect {
123                        x: x0,
124                        y,
125                        w: pw,
126                        h: ph,
127                    };
128                    y += ph + gap;
129                    r
130                })
131                .collect::<Vec<_>>()
132        };
133        let kf = k.max(1) as f64;
134        match self.kind.as_str() {
135            "column" => col(
136                mar,
137                mar,
138                even(fw - 2.0 * mar),
139                fh - 2.0 * mar - gap * (kf - 1.0),
140                k,
141                &self.weights,
142            ),
143            "1+2" if k == 3 => {
144                let wts = if self.weights.len() == 2 {
145                    self.weights.clone()
146                } else {
147                    vec![1.0, 1.0]
148                };
149                let cols = row(
150                    mar,
151                    mar,
152                    fw - 2.0 * mar - gap,
153                    even(fh - 2.0 * mar),
154                    2,
155                    &wts,
156                );
157                let mut v = vec![cols[0]];
158                v.extend(col(cols[1].x, mar, cols[1].w, fh - 2.0 * mar - gap, 2, &[]));
159                v
160            }
161            _ => row(
162                mar,
163                mar,
164                fw - 2.0 * mar - gap * (kf - 1.0),
165                even(fh - 2.0 * mar),
166                k,
167                &self.weights,
168            ),
169        }
170    }
171
172    /// Panel `i`'s slide-in offset in pixels at `t` seconds into the layout: panels enter
173    /// `stagger` apart, alternating from below and above (from the right and left in a column),
174    /// easing out over `slide` (`1 − (1 − u)³`).
175    #[allow(clippy::many_single_char_names)]
176    pub fn enter_offset(&self, i: usize, t: f64, w: u32, h: u32) -> [f64; 2] {
177        if !self.enter || (i == 0 && self.first_static) {
178            return [0.0; 2];
179        }
180        #[allow(clippy::cast_precision_loss)]
181        let t0 = i as f64 * self.stagger - if self.first_static { 0.0 } else { 0.16 };
182        let u = ((t - t0) / self.slide.max(1e-6)).clamp(0.0, 1.0);
183        let e = 1.0 - (1.0 - u).powi(3);
184        let d = if i.is_multiple_of(2) { 1.0 } else { -1.0 };
185        if self.kind == "column" {
186            [d * f64::from(w) * (1.0 - e), 0.0]
187        } else {
188            [0.0, d * f64::from(h) * (1.0 - e)]
189        }
190    }
191}
192
193/// `a + b` with `b`'s sign folded in.
194fn plus(a: &str, b: f64) -> String {
195    if b < 0.0 {
196        format!("{a} - {}", num(-b))
197    } else {
198        format!("{a} + {}", num(b))
199    }
200}
201
202/// Expands `{"layout": {...}, "panels": [...], "size", "fps", "duration"}` into a comp: the
203/// canvas the backdrop colour, each panel a layer at [`Layout::rects`] with the layout's radius,
204/// sliding in as [`Layout::enter_offset`] does (`ease_out` is the same cubic).
205///
206/// # Errors
207/// A missing or malformed key.
208#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
209pub fn expand(t: &Map<String, Value>) -> Result<Value> {
210    const P: &str = "template";
211    for k in t.keys() {
212        if !["layout", "panels", "size", "fps", "duration"].contains(&k.as_str()) {
213            return Err(err(format!("{P}.{k}"), "unknown key"));
214        }
215    }
216    let empty = Map::new();
217    let l = match t.get("layout") {
218        Some(Value::Object(m)) => Layout::parse(m),
219        None => Layout::parse(&empty),
220        Some(v) => {
221            return Err(err(
222                format!("{P}.layout"),
223                format!("expected an object, got {v}"),
224            ));
225        }
226    };
227    let size = super::size(t.get("size"), &format!("{P}.size"))?;
228    let panels = t
229        .get("panels")
230        .and_then(Value::as_array)
231        .filter(|a| !a.is_empty())
232        .ok_or_else(|| err(format!("{P}.panels"), "expected a list of panels"))?;
233    let (w, h) = (size[0], size[1]);
234    let rects = l.rects(panels.len(), w, h);
235    let mut layers = Vec::new();
236    let mut ids = super::Ids::default();
237    for (i, (p, r)) in panels.iter().zip(&rects).enumerate() {
238        let path = format!("{P}.panels[{i}]");
239        let mut m = item_layer(p, &path)?;
240        let id = ids.take(m.get("id").and_then(Value::as_str), &format!("p{i}"));
241        m.insert("id".into(), id.into());
242        m.insert("size".into(), json!([jnum(r.w), jnum(r.h)]));
243        let radius = l.radius_px(h);
244        if radius > 0.0 {
245            m.insert("radius".into(), jnum(radius));
246        }
247        // `enter_offset` written as an expression over `t`.
248        let at = l.enter_offset(i, -1e9, w, h);
249        let pos = |base: f64, amp: f64| -> Value {
250            if amp == 0.0 {
251                return jnum(base);
252            }
253            let t0 = i as f64 * l.stagger - if l.first_static { 0.0 } else { 0.16 };
254            let u = format!("({}) / {}", plus("t", -t0), num(l.slide.max(1e-6)));
255            let e = format!("{} * (1 - ease_out({u}))", num(amp));
256            if base == 0.0 {
257                e.into()
258            } else {
259                format!("{} + {e}", num(base)).into()
260            }
261        };
262        m.insert(
263            "transform".into(),
264            json!({"anchor": [0, 0], "position": [pos(r.x, at[0]), pos(r.y, at[1])]}),
265        );
266        layers.push(Value::Object(m));
267    }
268    let mut canvas = Map::new();
269    canvas.insert("size".into(), json!([w, h]));
270    for k in ["fps", "duration"] {
271        if let Some(v) = t.get(k) {
272            canvas.insert(k.into(), v.clone());
273        }
274    }
275    canvas.insert("background".into(), BACKDROP.into());
276    Ok(json!({"canvas": canvas, "layers": layers}))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[allow(clippy::needless_pass_by_value)]
284    fn obj(v: Value) -> Map<String, Value> {
285        v.as_object().cloned().unwrap()
286    }
287
288    #[test]
289    fn layout_rects_match_render_py() {
290        let l = Layout::parse(&obj(json!({"layout": "split"})));
291        // render.py: gap round(0.008 * 1080) = 9, avail = 1920 - 9 = 1911, 954 + 956.
292        let r = l.rects(2, 1920, 1080);
293        assert_eq!((r[0].x, r[0].w, r[0].h), (0.0, 954.0, 1080.0));
294        assert_eq!((r[1].x, r[1].w), (963.0, 956.0));
295        let r3 = l.rects(3, 1920, 1080);
296        assert_eq!(r3.len(), 3);
297        assert!(r3[2].x + r3[2].w <= 1920.0);
298        let one_two =
299            Layout::parse(&obj(json!({"layout": "1+2", "gap": 0.01}))).rects(3, 1920, 1080);
300        assert_eq!(one_two[0].h, 1080.0);
301        assert_eq!(one_two[1].x, one_two[2].x);
302        assert!(one_two[2].y > one_two[1].y);
303        let col = Layout::parse(&obj(json!({"layout": "column"}))).rects(2, 1080, 1920);
304        assert_eq!(col[0].w, 1080.0);
305        assert!(col[1].y > col[0].y);
306    }
307
308    #[test]
309    fn panels_slide_in() {
310        let l = Layout::parse(&obj(json!({})));
311        assert_eq!(l.enter_offset(0, 0.0, 1920, 1080), [0.0; 2]);
312        let start = l.enter_offset(1, 0.0, 1920, 1080);
313        assert_eq!(start, [0.0, -1080.0]);
314        assert_eq!(l.enter_offset(1, 0.22 + 0.42, 1920, 1080), [0.0, 0.0]);
315        let mid = l.enter_offset(2, 0.44 + 0.21, 1920, 1080)[1];
316        assert!(mid > 0.0 && mid < 1080.0 * 0.2);
317    }
318
319    #[test]
320    fn template_places_panels_at_the_rects() {
321        let v = expand(&obj(json!({
322            "layout": {"layout": "split"}, "size": [1920, 1080], "fps": 24, "duration": 2,
323            "panels": [{"video": "a.mp4"}, {"still": "b.png"}]
324        })))
325        .expect("expand");
326        assert_eq!(v["canvas"]["background"], BACKDROP);
327        let l = &v["layers"];
328        assert_eq!(l[0]["source"], json!({"video": "a.mp4"}));
329        assert_eq!(l[0]["size"], json!([954, 1080]));
330        assert_eq!(l[0]["radius"], json!(13));
331        assert_eq!(l[0]["transform"]["position"], json!([0, 0]));
332        assert_eq!(l[1]["id"], "p1");
333        assert_eq!(
334            l[1]["transform"]["position"],
335            json!([963, "-1080 * (1 - ease_out((t - 0.22) / 0.42))"])
336        );
337    }
338}