Skip to main content

ffcine_assembly/
comp.rs

1//! Compositing maths for assembly, backend-neutral and per frame: the per-shot finish
2//! (`ffcine.fx.vignette/1`, `fade/1`, `flash/1`, `darken/1`), transition curves
3//! (`ffcine.transition/1`), split-screen geometry (`ffcine.comp.layout/1`) and overlay placement
4//! (`ffcine.geo.transform/1` + `ffcine.comp.blend/1`). `exec` turns these into GPU hooks and
5//! layers.
6
7use std::f64::consts::FRAC_PI_2;
8
9use ffcine_reframe::Keyed;
10use ffcine_render::BlendMode;
11use serde_json::{Map, Value, json};
12
13fn f(m: &Map<String, Value>, k: &str) -> Option<f64> {
14    m.get(k).and_then(Value::as_f64)
15}
16
17/// `[r, g, b]` from a list or `"black"`/`"white"`.
18pub fn colour(v: Option<&Value>, default: [f64; 3]) -> [f64; 3] {
19    match v {
20        Some(Value::String(s)) if s == "white" => [1.0; 3],
21        Some(Value::String(s)) if s == "black" => [0.0; 3],
22        Some(Value::Array(a)) if a.len() >= 3 => {
23            let c = |i: usize| a[i].as_f64().unwrap_or(0.0).clamp(0.0, 1.0);
24            [c(0), c(1), c(2)]
25        }
26        _ => default,
27    }
28}
29
30/// Per-shot finishing after the grade (`render.py`'s `post` filters).
31#[derive(Debug, Clone, PartialEq)]
32pub struct Finish {
33    /// `ffcine.fx.vignette/1` `amount`; ffmpeg's `vignette` angle is `amount · π/2`.
34    pub vignette: f64,
35    /// `ffcine.fx.darken/1` `amount`: RGB multiplier.
36    pub darken: f64,
37    /// `ffcine.fx.fade/1`: seconds from / to `fade_colour` at the clip's start / end.
38    pub fade_in: f64,
39    pub fade_out: f64,
40    pub fade_colour: [f64; 3],
41    /// `ffcine.fx.flash/1`: `at` seconds from the clip's first frame, then one lift per frame.
42    pub flash: Option<(f64, Vec<f64>)>,
43}
44
45impl Default for Finish {
46    fn default() -> Self {
47        Self {
48            vignette: 0.0,
49            darken: 1.0,
50            fade_in: 0.0,
51            fade_out: 0.0,
52            fade_colour: [0.0; 3],
53            flash: None,
54        }
55    }
56}
57
58impl Finish {
59    /// Folds one effect's `metadata.ffcine` into the finish; `false` if `schema` isn't one.
60    pub fn add(&mut self, schema: (&str, u32), m: &Map<String, Value>) -> bool {
61        match schema {
62            ("ffcine.fx.vignette", 1) => self.vignette = f(m, "amount").unwrap_or(0.0),
63            ("ffcine.fx.darken", 1) => self.darken = f(m, "amount").unwrap_or(1.0),
64            ("ffcine.fx.fade", 1) => {
65                self.fade_in = f(m, "in").unwrap_or(0.0).max(0.0);
66                self.fade_out = f(m, "out").unwrap_or(0.0).max(0.0);
67                self.fade_colour = colour(m.get("colour"), [0.0; 3]);
68            }
69            ("ffcine.fx.flash", 1) => {
70                let levels = m.get("levels").and_then(Value::as_array).map_or_else(
71                    || vec![0.5, 0.5, 0.2],
72                    |a| a.iter().filter_map(Value::as_f64).collect(),
73                );
74                self.flash = Some((f(m, "at").unwrap_or(0.0), levels));
75            }
76            _ => return false,
77        }
78        true
79    }
80
81    pub fn is_identity(&self) -> bool {
82        *self == Self::default()
83    }
84
85    /// Whether the result depends on where a piece sits in its clip (fades, flash).
86    pub fn is_timed(&self) -> bool {
87        self.fade_in > 0.0 || self.fade_out > 0.0 || self.flash.is_some()
88    }
89
90    /// Per-frame `(fade, lift)` for frames `at..at + n` of a clip `len` frames long, ffmpeg's
91    /// `fade` maths (frame time `i / rate`; `fade` 0 is the colour, 1 the picture).
92    #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
93    pub fn tracks(&self, at: i64, n: i64, len: i64, rate: f64) -> (Vec<f32>, Vec<f32>) {
94        let flash_at = self
95            .flash
96            .as_ref()
97            .map(|(s, l)| ((s * rate).round() as i64, l));
98        (at..at + n)
99            .map(|i| {
100                let t = i as f64 / rate;
101                let mut k: f64 = 1.0;
102                if self.fade_in > 0.0 {
103                    k = k.min(t / self.fade_in);
104                }
105                if self.fade_out > 0.0 {
106                    let st = len as f64 / rate - self.fade_out;
107                    k = k.min(1.0 - (t - st) / self.fade_out);
108                }
109                let lift = flash_at
110                    .and_then(|(s, l)| usize::try_from(i - s).ok().and_then(|j| l.get(j)))
111                    .copied()
112                    .unwrap_or(0.0);
113                (k.clamp(0.0, 1.0) as f32, lift as f32)
114            })
115            .unzip()
116    }
117
118    /// `render.py`'s vignette angle in radians.
119    pub fn vignette_angle(&self) -> f64 {
120        self.vignette * FRAC_PI_2
121    }
122}
123
124/// How a transition mixes its two sides.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub enum TransitionKind {
127    Dissolve,
128    /// Out to the colour over the first half, in from it over the second.
129    Dip([f64; 3]),
130    /// Blacks lifted to white and back (a shutter flash at the cut).
131    Flash,
132    /// The picture moves the way of the unit vector, motion-blurred.
133    Whip([f64; 2]),
134}
135
136impl TransitionKind {
137    /// From a `Transition`'s `metadata.ffcine` (`ffcine.transition/1`); anything else is a
138    /// dissolve (docs/timeline.md).
139    pub fn from_meta(m: Option<&Map<String, Value>>) -> Self {
140        let Some(m) = m else { return Self::Dissolve };
141        match m.get("kind").and_then(Value::as_str) {
142            Some("dip") => Self::Dip(colour(m.get("colour"), [0.0; 3])),
143            Some("flash") => {
144                let c = colour(m.get("colour"), [1.0; 3]);
145                if c.iter().all(|x| (x - 1.0).abs() < 1e-9) {
146                    Self::Flash
147                } else {
148                    Self::Dip(c)
149                }
150            }
151            Some("whip") => Self::Whip(match m.get("direction").and_then(Value::as_str) {
152                Some("right") => [1.0, 0.0],
153                Some("up") => [0.0, -1.0],
154                Some("down") => [0.0, 1.0],
155                _ => [-1.0, 0.0],
156            }),
157            _ => Self::Dissolve,
158        }
159    }
160
161    pub fn recipe(&self) -> Value {
162        match self {
163            Self::Dissolve => json!("dissolve"),
164            Self::Dip(c) => json!({"dip": c}),
165            Self::Flash => json!("flash"),
166            Self::Whip(d) => json!({"whip": d}),
167        }
168    }
169}
170
171/// Progress through frame `i` of `n`, sampled at the frame's middle.
172#[allow(clippy::cast_precision_loss)]
173pub fn progress(i: i64, n: i64) -> f64 {
174    ((i as f64 + 0.5) / n.max(1) as f64).clamp(0.0, 1.0)
175}
176
177/// `3p² − 2p³`.
178pub fn smoothstep(p: f64) -> f64 {
179    let p = p.clamp(0.0, 1.0);
180    p * p * (3.0 - 2.0 * p)
181}
182
183pub use ffcine_comp::templates::layout::{Layout, Rect};
184
185/// The source window (x, y, w, h) of a `sw x sh` picture covering a `w x h` slot around `focus`
186/// (fractions, `[0.5, 0.5]` = centred), zoomed in by `zoom` (>= 1) around the same point.
187pub fn cover(sw: f64, sh: f64, w: f64, h: f64, focus: [f64; 2], zoom: f64) -> Rect {
188    let (mut cw, mut ch) = if sw / sh > w / h {
189        (sh * w / h, sh)
190    } else {
191        (sw, sw * h / w)
192    };
193    let z = zoom.max(1e-3);
194    cw /= z;
195    ch /= z;
196    Rect {
197        x: (sw - cw) * focus[0].clamp(0.0, 1.0),
198        y: (sh - ch) * focus[1].clamp(0.0, 1.0),
199        w: cw,
200        h: ch,
201    }
202}
203
204/// An overlay's placement and opacity over time: `ffcine.geo.transform/1` and
205/// `ffcine.comp.blend/1` (`opacity`, `mode`, `radius` as a fraction of the frame height). Each
206/// number may be keyframes in source time.
207///
208/// The picture `P` is the source fitted inside the frame. `translate_x`/`translate_y` and
209/// `anchor` are fractions of `P` (`units: "picture"`) or of the frame (`units: "frame"`, the
210/// default), y down; with `A` the anchor and `T` the translation in pixels, `S` the scale and `R`
211/// the rotation (`rotate_deg`, counter-clockwise on screen), picture point `q` (from its middle)
212/// lands at `A + R·S·(q − A) + T` from the frame's middle (docs/timeline.md).
213#[derive(Debug, Clone)]
214pub struct Placement {
215    picture_units: bool,
216    tx: Keyed<f64>,
217    ty: Keyed<f64>,
218    sx: Keyed<f64>,
219    sy: Keyed<f64>,
220    rotate: Keyed<f64>,
221    anchor: Keyed<[f64; 2]>,
222    opacity: Keyed<f64>,
223    pub mode: BlendMode,
224    pub radius: f64,
225}
226
227impl Placement {
228    /// From the two effects' parameters (either may be empty). Unparseable values fall back
229    /// to the defaults; `warn` gets what's ignored.
230    pub fn parse(
231        transform: &Map<String, Value>,
232        blend: &Map<String, Value>,
233        warn: &mut dyn FnMut(String),
234    ) -> Self {
235        let mut key = |m: &Map<String, Value>, k: &'static str, d: f64| match m.get(k) {
236            None => Keyed::Const(d),
237            Some(v) => Keyed::parse(v, k).unwrap_or_else(|e| {
238                warn(format!("{k}: {e}"));
239                Keyed::Const(d)
240            }),
241        };
242        let tx = key(transform, "translate_x", 0.0);
243        let ty = key(transform, "translate_y", 0.0);
244        let sx = key(transform, "scale_x", 1.0);
245        let sy = key(transform, "scale_y", 1.0);
246        let rotate = key(transform, "rotate_deg", 0.0);
247        let opacity = key(blend, "opacity", 1.0);
248        let anchor = match transform.get("anchor") {
249            None => Keyed::Const([0.0; 2]),
250            Some(v) => Keyed::parse(v, "anchor").unwrap_or_else(|e| {
251                warn(format!("anchor: {e}"));
252                Keyed::Const([0.0; 2])
253            }),
254        };
255        let mode = match blend.get("mode").and_then(Value::as_str) {
256            None => BlendMode::Normal,
257            Some(m) => m.parse().unwrap_or_else(|_| {
258                warn(format!("blend mode {m} rendered as normal"));
259                BlendMode::Normal
260            }),
261        };
262        Self {
263            picture_units: transform.get("units").and_then(Value::as_str) == Some("picture"),
264            tx,
265            ty,
266            sx,
267            sy,
268            rotate,
269            anchor,
270            opacity,
271            mode,
272            radius: f(blend, "radius").unwrap_or(0.0),
273        }
274    }
275
276    /// The unrotated rectangle of a `sw x sh` picture at source time `t` in a `w x h` frame;
277    /// [`Self::rotation`] turns it about its middle.
278    #[allow(clippy::many_single_char_names)]
279    pub fn rect(&self, t: f64, sw: f64, sh: f64, w: u32, h: u32) -> Rect {
280        let (fw, fh) = (f64::from(w), f64::from(h));
281        let fit = (fw / sw).min(fh / sh);
282        let ev = |k: &Keyed<f64>, d| k.eval(t).unwrap_or(d);
283        let (s0, s1) = (ev(&self.sx, 1.0), ev(&self.sy, 1.0));
284        let picture = [sw * fit, sh * fit];
285        let unit = if self.picture_units {
286            picture
287        } else {
288            [fw, fh]
289        };
290        let [ax, ay] = self.anchor.eval(t).unwrap_or([0.0; 2]);
291        let a = [ax * unit[0], ay * unit[1]];
292        // The picture's middle lands at A − R·S·A + T.
293        let (sin, cos) = self.rotation(t).to_radians().sin_cos();
294        let z = [-a[0] * s0, -a[1] * s1];
295        let turned = [cos * z[0] + sin * z[1], -sin * z[0] + cos * z[1]];
296        let (pw, ph) = (picture[0] * s0, picture[1] * s1);
297        let (cx, cy) = (
298            fw / 2.0 + a[0] + turned[0] + ev(&self.tx, 0.0) * unit[0],
299            fh / 2.0 + a[1] + turned[1] + ev(&self.ty, 0.0) * unit[1],
300        );
301        Rect {
302            x: cx - pw / 2.0,
303            y: cy - ph / 2.0,
304            w: pw,
305            h: ph,
306        }
307    }
308
309    /// Degrees counter-clockwise on screen at source time `t`.
310    pub fn rotation(&self, t: f64) -> f64 {
311        self.rotate.eval(t).unwrap_or(0.0)
312    }
313
314    pub fn opacity(&self, t: f64) -> f64 {
315        self.opacity.eval(t).unwrap_or(1.0).clamp(0.0, 1.0)
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[allow(clippy::needless_pass_by_value)]
324    fn obj(v: Value) -> Map<String, Value> {
325        v.as_object().cloned().unwrap()
326    }
327
328    #[test]
329    fn fades_follow_ffmpeg() {
330        let mut fin = Finish::default();
331        assert!(fin.add(
332            ("ffcine.fx.fade", 1),
333            &obj(json!({"in": 1.0, "out": 0.5, "colour": "white"}))
334        ));
335        assert!(fin.is_timed());
336        assert_eq!(fin.fade_colour, [1.0; 3]);
337        // 24 fps, a 48-frame clip: in over frames 0..24, out over the last 12.
338        let (k, lift) = fin.tracks(0, 48, 48, 24.0);
339        assert!(k[0].abs() < 1e-6);
340        assert!((k[12] - 0.5).abs() < 1e-6);
341        assert!((k[30] - 1.0).abs() < 1e-6);
342        assert!((k[47] - 1.0 / 12.0).abs() < 1e-5);
343        assert!(lift.iter().all(|l| *l == 0.0));
344        // A later piece of the same clip gets the same values.
345        let (k2, _) = fin.tracks(40, 8, 48, 24.0);
346        assert_eq!(k2, k[40..]);
347    }
348
349    #[test]
350    fn flash_lifts_frames() {
351        let mut fin = Finish::default();
352        fin.add(("ffcine.fx.flash", 1), &obj(json!({"at": 0.5})));
353        let (_, lift) = fin.tracks(10, 6, 100, 24.0);
354        assert_eq!(lift, [0.0, 0.0, 0.5, 0.5, 0.2, 0.0]);
355    }
356
357    #[test]
358    fn transition_kinds() {
359        let k = |v: Value| TransitionKind::from_meta(Some(&obj(v)));
360        assert_eq!(k(json!({"kind": "dip"})), TransitionKind::Dip([0.0; 3]));
361        assert_eq!(
362            k(json!({"kind": "dip", "colour": "white"})),
363            TransitionKind::Dip([1.0; 3])
364        );
365        assert_eq!(k(json!({"kind": "flash"})), TransitionKind::Flash);
366        assert_eq!(
367            k(json!({"kind": "whip", "direction": "up"})),
368            TransitionKind::Whip([0.0, -1.0])
369        );
370        assert_eq!(k(json!({"kind": "wipe"})), TransitionKind::Dissolve);
371        assert_eq!(TransitionKind::from_meta(None), TransitionKind::Dissolve);
372    }
373
374    #[test]
375    fn cover_and_placement() {
376        let c = cover(3840.0, 2160.0, 960.0, 1080.0, [0.5, 0.5], 1.0);
377        assert!((c.w - 1920.0).abs() < 1e-9 && (c.x - 960.0).abs() < 1e-9);
378        let mut warns = Vec::new();
379        let p = Placement::parse(
380            &obj(json!({"translate_x": 0.25, "scale_x": 0.5, "scale_y": 0.5})),
381            &obj(json!({"opacity": [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 1.0}], "radius": 0.02})),
382            &mut |w| warns.push(w),
383        );
384        assert_eq!(warns, Vec::<String>::new());
385        let r = p.rect(0.0, 1920.0, 1080.0, 1920, 1080);
386        assert_eq!((r.x, r.y, r.w, r.h), (960.0, 270.0, 960.0, 540.0));
387        assert!((p.opacity(0.5) - 0.5).abs() < 1e-9);
388        assert_eq!(p.mode, BlendMode::Normal);
389        assert!(p.rotation(0.0).abs() < 1e-12);
390    }
391
392    #[test]
393    fn rotation_pivots_on_the_anchor() {
394        let mut warns = Vec::new();
395        // A half-size picture turned 90° about its top left corner (anchor in picture units).
396        let p = Placement::parse(
397            &obj(
398                json!({"units": "picture", "anchor": [-0.5, -0.5], "scale_x": 0.5,
399                        "scale_y": 0.5, "rotate_deg": 90.0}),
400            ),
401            &obj(json!({"mode": "multiply"})),
402            &mut |w| warns.push(w),
403        );
404        assert_eq!(warns, Vec::<String>::new());
405        assert_eq!(p.mode, BlendMode::Multiply);
406        assert!((p.rotation(0.0) - 90.0).abs() < 1e-12);
407        let r = p.rect(0.0, 1920.0, 1080.0, 1920, 1080);
408        // The anchor (frame point (0, 0)) stays put; counter-clockwise with y down, the
409        // picture's middle (480, 270) from it goes to (270, −480).
410        let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
411        assert!((r.w - 960.0).abs() < 1e-9 && (r.h - 540.0).abs() < 1e-9);
412        assert!((cx - 270.0).abs() < 1e-9, "{cx}");
413        assert!((cy + 480.0).abs() < 1e-9, "{cy}");
414        // Matches the importer's maths (resolve::Placement::apply) at the picture's middle.
415        let q = ffcine_import::resolve::Placement {
416            anchor: [-0.5, -0.5],
417            scale: [0.5, 0.5],
418            rotate_deg: 90.0,
419            ..Default::default()
420        }
421        .apply([1920.0, 1080.0], [0.0, 0.0]);
422        assert!((cx - 960.0 - q[0]).abs() < 1e-9 && (cy - 540.0 - q[1]).abs() < 1e-9);
423        // Keyframed rotation, frame units; a foreign mode falls back to normal with a warning.
424        let k = Placement::parse(
425            &obj(
426                json!({"rotate_deg": [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 30.0}],
427                        "anchor": [0.1, 0.0]}),
428            ),
429            &obj(json!({"mode": "resolve:27"})),
430            &mut |w| warns.push(w),
431        );
432        assert!((k.rotation(0.5) - 15.0).abs() < 1e-9);
433        assert_eq!(k.mode, BlendMode::Normal);
434        assert_eq!(warns.len(), 1);
435        // Unturned, the anchor doesn't move the picture.
436        let r0 = k.rect(0.0, 1920.0, 1080.0, 1920, 1080);
437        assert!(r0.x.abs() < 1e-9 && r0.y.abs() < 1e-9);
438    }
439}