Skip to main content

ffcine_reframe/
geo.rs

1//! The NLE importers' `ffcine.geo.crop/1` and `ffcine.geo.transform/1` (`docs/timeline.md`) on a
2//! clip's picture, evaluated per frame into one map from output pixels to source pixels and a
3//! source-side crop mask. [`crate::pass::apply_mapped`] samples it in the grade pass, composed
4//! with the reframe window and the stabilizer's warp, so the picture is resampled once more at
5//! most (as on the warp path).
6//!
7//! The **picture** is the reframed picture: the source as the window shows it in the output,
8//! extended past the window at the same scale (a zoomed-out or moved picture shows what the
9//! window cut off, black past the source). `"units": "picture"` sizes are that picture's (for a
10//! centred window, Resolve's picture: the source sized into the frame); `"units": "frame"` sizes
11//! are the output frame's.
12
13// Frame maths reads best with the usual one-letter names.
14#![allow(clippy::many_single_char_names)]
15
16use ffcine_core::geom::{self, Mat3};
17use ffcine_core::{Error, Result};
18use serde_json::{Map, Value};
19
20use crate::keyframe::Keyed;
21use crate::profile::Profile;
22use crate::reframe::{Frame, Reframe, Shot};
23use crate::window::Rect;
24
25pub const CROP_SCHEMA: &str = "ffcine.geo.crop";
26pub const TRANSFORM_SCHEMA: &str = "ffcine.geo.transform";
27
28/// `ffcine.geo.crop/1`: sides as fractions of the source picture, cut before the transform.
29#[derive(Debug, Clone, PartialEq)]
30pub struct Crop {
31    pub left: Keyed<f64>,
32    pub right: Keyed<f64>,
33    pub top: Keyed<f64>,
34    pub bottom: Keyed<f64>,
35    /// Soft edge inside the cut, in picture widths.
36    pub feather: Keyed<f64>,
37    /// Scale the cropped picture back up to the frame (FCPXML / FCP7 crop mode).
38    pub fill: bool,
39}
40
41/// `ffcine.geo.transform/1`: picture point `q` (from the picture's middle) lands at
42/// `A + R·S·(flip(q) − A) + T` from the frame's middle.
43#[derive(Debug, Clone, PartialEq)]
44pub struct Transform {
45    /// `"units": "frame"`: anchor and translation in output frame sizes, else picture sizes.
46    pub frame_units: bool,
47    /// `"y": "up"`: positions with y up (negated); the importers write `down`.
48    pub y_up: bool,
49    pub anchor: Keyed<[f64; 2]>,
50    pub translate_x: Keyed<f64>,
51    pub translate_y: Keyed<f64>,
52    pub scale_x: Keyed<f64>,
53    pub scale_y: Keyed<f64>,
54    /// Counter-clockwise on screen.
55    pub rotate_deg: Keyed<f64>,
56    pub flip: [bool; 2],
57}
58
59/// A clip's NLE crop and transforms.
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct Geo {
62    pub crop: Option<Crop>,
63    /// Applied in effect order (Resolve's Transform, then its Dynamic Zoom).
64    pub transforms: Vec<Transform>,
65}
66
67/// One output frame's mapping.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct Mapped {
70    /// Output pixel -> source pixel (before the stabilizer's warp).
71    pub m: Mat3,
72    /// The picture's part that shows, in source pixels: black outside.
73    pub mask: Rect,
74    /// Soft edge inside `mask`, source pixels.
75    pub feather: f64,
76}
77
78fn num(m: &Map<String, Value>, k: &'static str, d: f64) -> Result<Keyed<f64>> {
79    m.get(k)
80        .filter(|v| !v.is_null())
81        .map_or(Ok(Keyed::Const(d)), |v| Keyed::parse(v, k))
82}
83
84fn flag(m: &Map<String, Value>, k: &str) -> bool {
85    m.get(k).and_then(Value::as_bool).unwrap_or(false)
86}
87
88impl Crop {
89    pub fn parse(m: &Map<String, Value>) -> Result<Self> {
90        Ok(Self {
91            left: num(m, "left", 0.0)?,
92            right: num(m, "right", 0.0)?,
93            top: num(m, "top", 0.0)?,
94            bottom: num(m, "bottom", 0.0)?,
95            feather: num(m, "feather", 0.0)?,
96            fill: flag(m, "fill"),
97        })
98    }
99
100    /// The kept part of a `sw x sh` source at source time `t`, at least a pixel each way.
101    pub fn rect(&self, t: f64, sw: f64, sh: f64) -> Rect {
102        let ev = |k: &Keyed<f64>| k.eval(t).unwrap_or(0.0).clamp(0.0, 1.0);
103        let (x0, x1) = (ev(&self.left) * sw, sw - ev(&self.right) * sw);
104        let (y0, y1) = (ev(&self.top) * sh, sh - ev(&self.bottom) * sh);
105        Rect {
106            x: x0.min(sw - 1.0),
107            y: y0.min(sh - 1.0),
108            w: (x1 - x0).max(1.0),
109            h: (y1 - y0).max(1.0),
110        }
111    }
112
113    fn is_animated(&self) -> bool {
114        [&self.left, &self.right, &self.top, &self.bottom]
115            .iter()
116            .any(|k| k.is_animated())
117    }
118}
119
120impl Transform {
121    pub fn parse(m: &Map<String, Value>) -> Result<Self> {
122        let anchor = match m.get("anchor").filter(|v| !v.is_null()) {
123            Some(v) => Keyed::parse(v, "transform anchor")?,
124            None => Keyed::Const([0.0, 0.0]),
125        };
126        let units = m.get("units").and_then(Value::as_str);
127        if !matches!(units, None | Some("picture" | "frame")) {
128            return Err(Error::Invalid {
129                what: "transform units",
130                value: format!("{units:?}"),
131            });
132        }
133        Ok(Self {
134            frame_units: units == Some("frame"),
135            y_up: m.get("y").and_then(Value::as_str) == Some("up"),
136            anchor,
137            translate_x: num(m, "translate_x", 0.0)?,
138            translate_y: num(m, "translate_y", 0.0)?,
139            scale_x: num(m, "scale_x", 1.0)?,
140            scale_y: num(m, "scale_y", 1.0)?,
141            rotate_deg: num(m, "rotate_deg", 0.0)?,
142            flip: [flag(m, "flip_x"), flag(m, "flip_y")],
143        })
144    }
145
146    /// At source time `t`, for a picture `unit` px (anchor and translation units): picture point
147    /// `q` -> frame point (both px from the middles, y down).
148    pub fn forward(&self, t: f64, unit: [f64; 2]) -> Mat3 {
149        let ev = |k: &Keyed<f64>, d: f64| k.eval(t).unwrap_or(d);
150        let y = if self.y_up { -1.0 } else { 1.0 };
151        let [ax, ay] = self.anchor.eval(t).unwrap_or([0.0, 0.0]);
152        let a = [ax * unit[0], y * ay * unit[1]];
153        let tr = [
154            ev(&self.translate_x, 0.0) * unit[0],
155            y * ev(&self.translate_y, 0.0) * unit[1],
156        ];
157        // Zero scales would have no inverse: a hair above, so the picture vanishes instead.
158        let s = |v: f64| if v.abs() < 1e-6 { 1e-6 } else { v };
159        let (sx, sy) = (s(ev(&self.scale_x, 1.0)), s(ev(&self.scale_y, 1.0)));
160        let (sin, cos) = ev(&self.rotate_deg, 0.0).to_radians().sin_cos();
161        let flip = [
162            [if self.flip[0] { -1.0 } else { 1.0 }, 0.0, 0.0],
163            [0.0, if self.flip[1] { -1.0 } else { 1.0 }, 0.0],
164            [0.0, 0.0, 1.0],
165        ];
166        // Counter-clockwise on screen with y down (`import::resolve::Placement::apply`).
167        let rs = [
168            [cos * sx, sin * sy, 0.0],
169            [-sin * sx, cos * sy, 0.0],
170            [0.0, 0.0, 1.0],
171        ];
172        let about = geom::mul(
173            &geom::translation(a[0] + tr[0], a[1] + tr[1]),
174            &geom::mul(&rs, &geom::translation(-a[0], -a[1])),
175        );
176        geom::mul(&about, &flip)
177    }
178
179    fn is_animated(&self) -> bool {
180        self.anchor.is_animated()
181            || [
182                &self.translate_x,
183                &self.translate_y,
184                &self.scale_x,
185                &self.scale_y,
186                &self.rotate_deg,
187            ]
188            .iter()
189            .any(|k| k.is_animated())
190    }
191}
192
193impl Geo {
194    /// The clip's first `ffcine.geo.crop/1` and every `ffcine.geo.transform/1`.
195    pub fn from_effects(effects: &[ffcine_timeline::schema::Effect]) -> Result<Self> {
196        let mut geo = Self::default();
197        for e in effects {
198            let ffcine_timeline::schema::Effect::Effect(g) = e else {
199                continue;
200            };
201            if geo.crop.is_none()
202                && let Some(p) = g.ffcine_params::<Map<String, Value>>(CROP_SCHEMA, 1)
203            {
204                geo.crop = Some(Crop::parse(&p?)?);
205            } else if let Some(p) = g.ffcine_params::<Map<String, Value>>(TRANSFORM_SCHEMA, 1) {
206                geo.transforms.push(Transform::parse(&p?)?);
207            }
208        }
209        Ok(geo)
210    }
211
212    pub fn is_empty(&self) -> bool {
213        self.crop.is_none() && self.transforms.is_empty()
214    }
215
216    pub fn is_animated(&self) -> bool {
217        self.crop.as_ref().is_some_and(Crop::is_animated)
218            || self.transforms.iter().any(Transform::is_animated)
219    }
220
221    /// Output frame `i`'s window (in the cropped picture with `fill`) and its full mapping.
222    #[allow(clippy::cast_precision_loss)]
223    pub fn frame(&self, reframe: &Reframe, i: u64, shot: &Shot, out: &Profile) -> (Frame, Mapped) {
224        let rate = if shot.rate > 0.0 { shot.rate } else { 24.0 };
225        let t = shot.start + i as f64 / rate;
226        let (sw, sh) = (f64::from(shot.width), f64::from(shot.height));
227        let full = Rect::frame(sw, sh);
228        let cut = self.crop.as_ref().map(|c| c.rect(t, sw, sh));
229        let picture = match (&self.crop, cut) {
230            (Some(c), Some(r)) if c.fill => r,
231            _ => full,
232        };
233        let frame = reframe.frame_in(i, shot, out, picture);
234        let (ow, oh) = (f64::from(out.width), f64::from(out.height));
235        // The window: output pixel -> source pixel, onto the placed rectangle with `contain`.
236        let at = frame.place.unwrap_or(Rect::frame(ow, oh));
237        let (kx, ky) = (frame.crop.w / at.w, frame.crop.h / at.h);
238        let window = [
239            [kx, 0.0, frame.crop.x - at.x * kx],
240            [0.0, ky, frame.crop.y - at.y * ky],
241            [0.0, 0.0, 1.0],
242        ];
243        let m = if self.transforms.is_empty() {
244            window
245        } else {
246            {
247                // The picture's middle and size in output pixels.
248                let inv = geom::inverse(&window).unwrap_or(geom::IDENTITY);
249                let mid = geom::apply(
250                    &inv,
251                    [picture.x + picture.w / 2.0, picture.y + picture.h / 2.0],
252                );
253                let fwd = self.transforms.iter().fold(geom::IDENTITY, |acc, tf| {
254                    let unit = if tf.frame_units {
255                        [ow, oh]
256                    } else {
257                        [picture.w / kx, picture.h / ky]
258                    };
259                    geom::mul(&tf.forward(t, unit), &acc)
260                });
261                let back = geom::inverse(&fwd).unwrap_or(geom::IDENTITY);
262                // Output pixel -> frame point -> picture point -> output pixel of the picture.
263                let g = geom::mul(
264                    &geom::translation(mid[0], mid[1]),
265                    &geom::mul(&back, &geom::translation(-ow / 2.0, -oh / 2.0)),
266                );
267                geom::mul(&window, &g)
268            }
269        };
270        let feather = self
271            .crop
272            .as_ref()
273            .map_or(0.0, |c| c.feather.eval(t).unwrap_or(0.0).max(0.0) * sw);
274        (
275            frame,
276            Mapped {
277                m,
278                mask: cut.unwrap_or(full),
279                feather,
280            },
281        )
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use serde_json::json;
289
290    const HD: Shot = Shot {
291        width: 1920,
292        height: 1080,
293        frames: 48,
294        rate: 24.0,
295        start: 0.0,
296    };
297
298    fn geo(crop: Option<Value>, transform: Option<Value>) -> Geo {
299        Geo {
300            crop: crop.map(|v| Crop::parse(v.as_object().unwrap()).unwrap()),
301            transforms: transform
302                .map(|v| Transform::parse(v.as_object().unwrap()).unwrap())
303                .into_iter()
304                .collect(),
305        }
306    }
307
308    fn near(a: [f64; 2], b: [f64; 2]) -> bool {
309        (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6
310    }
311
312    #[test]
313    fn identity_is_the_window() {
314        let out = Profile::new("hd", 1920, 1080);
315        let (f, m) = geo(None, None).frame(&Reframe::default(), 0, &HD, &out);
316        assert_eq!(f.crop, Rect::frame(1920.0, 1080.0));
317        assert!(near(geom::apply(&m.m, [10.0, 20.0]), [10.0, 20.0]));
318        assert_eq!(m.mask, Rect::frame(1920.0, 1080.0));
319    }
320
321    #[test]
322    fn matches_the_importers_placement() {
323        // The importer's worked example: picture 1000x500 at 1:1, anchor (0.25, -0.1),
324        // scale 2, 90° ccw: picture point A lands at A + T; the middle at A − R·S·A + T.
325        let out = Profile::new("o", 1000, 500);
326        let shot = Shot {
327            width: 1000,
328            height: 500,
329            ..HD
330        };
331        let tf = json!({"units": "picture", "y": "down", "anchor": [0.25, -0.1],
332                        "translate_x": 0.1, "translate_y": 0.0, "scale_x": 2.0, "scale_y": 2.0,
333                        "rotate_deg": 90.0});
334        let (_, m) = geo(None, Some(tf)).frame(&Reframe::default(), 0, &shot, &out);
335        // Output pixel of the anchor: frame middle + A + T = (500 + 250 + 100, 250 - 50).
336        let src = geom::apply(&m.m, [850.0, 200.0]);
337        assert!(near(src, [750.0, 200.0]), "{src:?}");
338        // The picture's middle: A − R·S·A + T = (250, -50) − (−100, −500) + (100, 0).
339        let src = geom::apply(&m.m, [500.0 + 450.0, 250.0 + 450.0]);
340        assert!(near(src, [500.0, 250.0]), "{src:?}");
341    }
342
343    #[test]
344    fn zoom_out_shows_past_the_window_and_frame_units() {
345        let out = Profile::new("hd", 1920, 1080);
346        let tf = json!({"units": "frame", "translate_x": 0.25, "scale_x": 0.5, "scale_y": 0.5});
347        let (_, m) = geo(None, Some(tf)).frame(&Reframe::default(), 0, &HD, &out);
348        // Half size, its middle a quarter frame right of centre.
349        assert!(near(geom::apply(&m.m, [1440.0, 540.0]), [960.0, 540.0]));
350        assert!(near(geom::apply(&m.m, [960.0, 270.0]), [0.0, 0.0]));
351        // Left of the picture maps outside the source (black).
352        assert!(geom::apply(&m.m, [100.0, 540.0])[0] < 0.0);
353    }
354
355    #[test]
356    fn crop_masks_or_fills() {
357        let out = Profile::new("hd", 1920, 1080);
358        let cut = json!({"left": 0.25, "right": 0.25, "top": 0.0, "bottom": 0.5, "feather": 0.01});
359        let (f, m) = geo(Some(cut.clone()), None).frame(&Reframe::default(), 0, &HD, &out);
360        assert_eq!(f.crop, Rect::frame(1920.0, 1080.0));
361        assert_eq!(
362            m.mask,
363            Rect {
364                x: 480.0,
365                y: 0.0,
366                w: 960.0,
367                h: 540.0
368            }
369        );
370        assert!((m.feather - 19.2).abs() < 1e-9);
371        // `fill`: the kept part is the picture the window fits the frame to.
372        let mut fill = cut;
373        fill["fill"] = json!(true);
374        let (f, m) = geo(Some(fill), None).frame(&Reframe::default(), 0, &HD, &out);
375        assert_eq!(f.crop, m.mask);
376        assert!(near(geom::apply(&m.m, [0.0, 0.0]), [480.0, 0.0]));
377        assert!(near(geom::apply(&m.m, [1920.0, 1080.0]), [1440.0, 540.0]));
378    }
379
380    #[test]
381    fn keyframes_and_effects() {
382        let tf = json!({"translate_x": [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 0.5}]});
383        let g = geo(None, Some(tf));
384        assert!(g.is_animated() && !g.is_empty());
385        let out = Profile::new("hd", 1920, 1080);
386        let (_, m) = g.frame(&Reframe::default(), 12, &HD, &out);
387        // Half a second in: a quarter picture right, so output 960 + 480 shows the middle.
388        assert!(near(geom::apply(&m.m, [1440.0, 540.0]), [960.0, 540.0]));
389        assert!(Transform::parse(json!({"units": "inches"}).as_object().unwrap()).is_err());
390        // Several transforms chain in effect order: x2 then x0.5 is the identity.
391        let mut two = geo(
392            None,
393            Some(json!({"scale_x": 2.0, "scale_y": 2.0, "translate_x": 0.1})),
394        );
395        two.transforms.push(
396            Transform::parse(json!({"scale_x": 0.5, "scale_y": 0.5}).as_object().unwrap()).unwrap(),
397        );
398        let (_, m) = two.frame(&Reframe::default(), 0, &HD, &out);
399        // The first's translation is halved by the second: 0.1 picture widths x 0.5 = 96 px.
400        assert!(near(
401            geom::apply(&m.m, [960.0 + 96.0, 540.0]),
402            [960.0, 540.0]
403        ));
404        assert!(near(geom::apply(&m.m, [96.0, 0.0]), [0.0, 0.0]));
405    }
406}