Skip to main content

ffcine_reframe/
reframe.rs

1//! `ffcine.geo.reframe/1`: the prototype's zoom / punch / push / pan / fit fields (`edit.yaml`,
2//! `render.py` `gpu_geo`), any of `zoom`, `zoom_center`, `crop_x`, `crop_y` also in the keyframe
3//! form, plus per-profile overrides. Evaluated per output frame into a crop window.
4
5// Frame maths reads best with the usual one-letter names.
6#![allow(clippy::many_single_char_names)]
7
8use ffcine_core::{Error, Result};
9use serde_json::{Map, Value};
10
11use crate::keyframe::{Key, Keyed, ease, is_keys};
12use crate::profile::Profile;
13use crate::window::Rect;
14
15pub const SCHEMA: &str = "ffcine.geo.reframe";
16pub const MAJOR: u32 = 1;
17
18/// A timed punch-in: `zoom` (capped at the profile's `max_punch`, times the base zoom) from `at`
19/// to `to` seconds, centred on `center` (else `zoom_center`).
20#[derive(Debug, Clone, PartialEq)]
21pub struct Punch {
22    pub at: f64,
23    pub to: Option<f64>,
24    pub zoom: f64,
25    pub center: Option<[f64; 2]>,
26}
27
28/// How the window fills the output.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum Fit {
31    /// Crop to the output aspect (the window slides by `crop_x` / `crop_y`).
32    #[default]
33    Cover,
34    /// The whole picture at full output width, centred at `fit_y`, a blurred copy around it
35    /// (`fill_blur`; 0 = black).
36    Contain,
37}
38
39/// Where the output-aspect window sits on its sliding axis.
40#[derive(Debug, Clone, PartialEq)]
41pub enum Slide {
42    /// Constant, or the keyframe form (`t` in source time).
43    Keyed(Keyed<f64>),
44    /// The prototype's `[[t, x], ...]`: eased keys, `t` in seconds from the clip's first frame.
45    ClipKeys(Keyed<f64>),
46    /// `[from, to]`: an eased pan over the whole shot.
47    Pan(f64, f64),
48}
49
50impl Default for Slide {
51    fn default() -> Self {
52        Self::Keyed(Keyed::Const(0.5))
53    }
54}
55
56impl Slide {
57    fn parse(v: &Value, what: &'static str) -> Result<Self> {
58        let invalid = || Error::Invalid {
59            what,
60            value: v.to_string(),
61        };
62        if v.is_null() {
63            return Ok(Self::default());
64        }
65        if is_keys(v) || v.is_number() {
66            return Keyed::parse(v, what).map(Self::Keyed);
67        }
68        let a = v.as_array().ok_or_else(invalid)?;
69        if a.first().is_some_and(Value::is_array) {
70            let keys = a
71                .iter()
72                .map(|k| match k.as_array().map(Vec::as_slice) {
73                    Some([t, x]) => Some(Key {
74                        t: t.as_f64()?,
75                        v: x.as_f64()?,
76                        interp: crate::keyframe::Interp::Ease,
77                    }),
78                    _ => None,
79                })
80                .collect::<Option<Vec<_>>>()
81                .ok_or_else(invalid)?;
82            return Ok(Self::ClipKeys(Keyed::from_keys(keys)));
83        }
84        match a.as_slice() {
85            [x, y] => Ok(Self::Pan(
86                x.as_f64().ok_or_else(invalid)?,
87                y.as_f64().ok_or_else(invalid)?,
88            )),
89            _ => Err(invalid()),
90        }
91    }
92
93    /// At `t` seconds into the clip, which starts at `start` in source time.
94    fn eval(&self, t: f64, start: f64, duration: f64) -> f64 {
95        match self {
96            Self::Keyed(k) => k.eval(start + t).unwrap_or(0.5),
97            Self::ClipKeys(k) => k.eval(t).unwrap_or(0.5),
98            Self::Pan(a, b) => a + (b - a) * ease(t / duration.max(1e-3)),
99        }
100    }
101}
102
103/// The shot being reframed.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct Shot {
106    /// Source size in pixels.
107    pub width: u32,
108    pub height: u32,
109    /// Output frames in the shot.
110    pub frames: u64,
111    /// Output frame rate.
112    pub rate: f64,
113    /// Source time of the clip's first frame in seconds (`source_range.start_time`): keyframe
114    /// `t`s are source time, so frame `i` evaluates them at `start + i / rate`.
115    pub start: f64,
116}
117
118/// One output frame's geometry.
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub struct Frame {
121    /// Source window, in source pixels.
122    pub crop: Rect,
123    /// Where it lands in the output, in output pixels; `None` fills the frame.
124    pub place: Option<Rect>,
125    /// Around a placed picture: a blurred copy of it, this radius in pixels of the placed
126    /// picture; `None` is black.
127    pub blur: Option<f64>,
128}
129
130#[derive(Debug, Clone, PartialEq, Default)]
131pub struct Reframe {
132    /// Base zoom (1 = the whole output-aspect window).
133    pub zoom: Option<Keyed<f64>>,
134    pub zoom_center: Option<Keyed<[f64; 2]>>,
135    pub punch: Vec<Punch>,
136    /// `[from, to]` zoom, eased over the whole shot (half linear, half cosine, as `render.py`).
137    pub push: Option<(f64, f64)>,
138    /// Centre of the push (default `zoom_center`).
139    pub push_center: Option<[f64; 2]>,
140    /// The push centre moving `[from, to]` with the push's easing.
141    pub pan: Option<([f64; 2], [f64; 2])>,
142    /// Window position on wider sources (0 left .. 1 right).
143    pub crop_x: Slide,
144    /// Window position on taller sources (0 top .. 1 bottom).
145    pub crop_y: Slide,
146    pub fit: Fit,
147    /// `fit: contain`: the picture's centre as a fraction of the output height (0.42).
148    pub fit_y: f64,
149    /// `fit: contain`: blur radius of the fill around the picture, as a fraction of the placed
150    /// picture's height (0.04); 0 for black.
151    pub fill_blur: f64,
152    /// A split-screen panel's `[x, y]` focus: the default `crop_x`, `crop_y` and `zoom_center`
153    /// when it renders as a panel ([`Self::focused`]).
154    pub focus: Option<[f64; 2]>,
155}
156
157/// `fill_blur`'s default.
158pub const FILL_BLUR: f64 = 0.04;
159
160fn pair(v: &Value) -> Option<[f64; 2]> {
161    match v.as_array()?.as_slice() {
162        [a, b] => Some([a.as_f64()?, b.as_f64()?]),
163        _ => None,
164    }
165}
166
167impl Reframe {
168    /// Reads the effect's parameters (`metadata.ffcine` without `schema`) for `profile`:
169    /// `profiles.<profile>` fields replace the base ones. Unknown fields are ignored (the
170    /// Resolve importer's `timeline_size`, the panels' `focus`).
171    pub fn parse(params: &Map<String, Value>, profile: Option<&str>) -> Result<Self> {
172        let mut p = params.clone();
173        if let Some(over) = profile
174            .and_then(|n| params.get("profiles")?.get(n))
175            .and_then(Value::as_object)
176        {
177            for (k, v) in over {
178                p.insert(k.clone(), v.clone());
179            }
180        }
181        let invalid = |what: &'static str, v: &Value| Error::Invalid {
182            what,
183            value: v.to_string(),
184        };
185        let mut r = Self {
186            fit_y: 0.42,
187            fill_blur: FILL_BLUR,
188            ..Self::default()
189        };
190        for (k, v) in &p {
191            if v.is_null() {
192                continue;
193            }
194            match k.as_str() {
195                "zoom" => r.zoom = Some(Keyed::parse(v, "reframe zoom")?),
196                "zoom_center" => r.zoom_center = Some(Keyed::parse(v, "reframe zoom_center")?),
197                "punch" => {
198                    for pu in v.as_array().ok_or_else(|| invalid("reframe punch", v))? {
199                        let num = |k: &str| pu.get(k).and_then(Value::as_f64);
200                        r.punch.push(Punch {
201                            at: num("at").ok_or_else(|| invalid("punch at", pu))?,
202                            to: num("to"),
203                            zoom: num("zoom").unwrap_or(1.25),
204                            center: pu.get("center").and_then(pair),
205                        });
206                    }
207                }
208                "push" => {
209                    let [a, b] = pair(v).ok_or_else(|| invalid("reframe push", v))?;
210                    r.push = Some((a, b));
211                }
212                "push_center" => {
213                    r.push_center = Some(pair(v).ok_or_else(|| invalid("push_center", v))?);
214                }
215                "pan" => {
216                    let a = v.as_array().ok_or_else(|| invalid("reframe pan", v))?;
217                    match a.as_slice() {
218                        [x, y] => {
219                            r.pan = Some((
220                                pair(x).ok_or_else(|| invalid("reframe pan", v))?,
221                                pair(y).ok_or_else(|| invalid("reframe pan", v))?,
222                            ));
223                        }
224                        _ => return Err(invalid("reframe pan", v)),
225                    }
226                }
227                "crop_x" => r.crop_x = Slide::parse(v, "reframe crop_x")?,
228                "crop_y" => r.crop_y = Slide::parse(v, "reframe crop_y")?,
229                "fit" => {
230                    r.fit = match v.as_str() {
231                        Some("contain") => Fit::Contain,
232                        Some("cover" | "fill") => Fit::Cover,
233                        _ => return Err(invalid("reframe fit", v)),
234                    }
235                }
236                // Resolve's sizing mode (`fit` letterboxes, `fill` crops).
237                "mode" => {
238                    r.fit = match v.as_str() {
239                        Some("fit") => Fit::Contain,
240                        _ => Fit::Cover,
241                    }
242                }
243                "fit_y" => r.fit_y = v.as_f64().ok_or_else(|| invalid("reframe fit_y", v))?,
244                "fill_blur" => {
245                    r.fill_blur = v
246                        .as_f64()
247                        .filter(|b| *b >= 0.0)
248                        .ok_or_else(|| invalid("reframe fill_blur", v))?;
249                }
250                "focus" => r.focus = Some(pair(v).ok_or_else(|| invalid("reframe focus", v))?),
251                _ => {}
252            }
253        }
254        Ok(r)
255    }
256
257    /// The first `ffcine.geo.reframe/1` among a clip's effects, for `profile`.
258    pub fn from_effects(
259        effects: &[ffcine_timeline::schema::Effect],
260        profile: Option<&str>,
261    ) -> Result<Option<Self>> {
262        for e in effects {
263            let ffcine_timeline::schema::Effect::Effect(g) = e else {
264                continue;
265            };
266            if let Some(params) = g.ffcine_params::<Map<String, Value>>(SCHEMA, MAJOR) {
267                return Self::parse(&params?, profile).map(Some);
268            }
269        }
270        Ok(None)
271    }
272
273    /// For a split-screen panel: `focus` stands in for `crop_x`, `crop_y` and `zoom_center`
274    /// where those aren't set.
275    #[must_use]
276    pub fn focused(mut self) -> Self {
277        if let Some([x, y]) = self.focus {
278            if self.crop_x == Slide::default() {
279                self.crop_x = Slide::Keyed(Keyed::Const(x));
280            }
281            if self.crop_y == Slide::default() {
282                self.crop_y = Slide::Keyed(Keyed::Const(y));
283            }
284            self.zoom_center.get_or_insert(Keyed::Const([x, y]));
285        }
286        self
287    }
288
289    /// Output frame `i`'s geometry.
290    pub fn frame(&self, i: u64, shot: &Shot, out: &Profile) -> Frame {
291        let full = Rect::frame(f64::from(shot.width), f64::from(shot.height));
292        self.frame_in(i, shot, out, full)
293    }
294
295    /// Output frame `i`'s geometry with the picture being `full`, a part of the source (an NLE
296    /// crop that fills the frame).
297    #[allow(clippy::cast_precision_loss)]
298    pub fn frame_in(&self, i: u64, shot: &Shot, out: &Profile, full: Rect) -> Frame {
299        let rate = if shot.rate > 0.0 { shot.rate } else { 24.0 };
300        let t = i as f64 / rate;
301        let duration = shot.frames as f64 / rate;
302        let (ow, oh) = (f64::from(out.width), f64::from(out.height));
303
304        let src_t = shot.start + t;
305        let base_zoom = self
306            .zoom
307            .as_ref()
308            .and_then(|z| z.eval(src_t))
309            .unwrap_or(1.0);
310        let base_center = self
311            .zoom_center
312            .as_ref()
313            .and_then(|c| c.eval(src_t))
314            .unwrap_or([0.5, 0.5]);
315        // The punch active at frame i (frame-exact like the prototype's s2f boundaries).
316        let frame_of = |s: f64| (s * rate).round();
317        let punch = self.punch.iter().rev().find(|p| {
318            let (a, b) = (frame_of(p.at), p.to.map_or(f64::INFINITY, frame_of));
319            (a..b).contains(&(i as f64))
320        });
321        let (zoom, center) = punch.map_or((base_zoom, base_center), |p| {
322            (
323                p.zoom.min(out.max_punch) * base_zoom,
324                p.center.unwrap_or(base_center),
325            )
326        });
327
328        let (window, place) = if self.fit == Fit::Contain {
329            let w = full.zoom(base_zoom, base_center);
330            let ph = ow / w.aspect();
331            let y = (oh * self.fit_y - ph / 2.0).round();
332            (
333                w,
334                Some(Rect {
335                    x: 0.0,
336                    y,
337                    w: ow,
338                    h: ph.round(),
339                }),
340            )
341        } else {
342            let ar = ow / oh;
343            let aspect = if (full.aspect() / ar - 1.0).abs() > 0.01 {
344                full.fit_aspect(
345                    ar,
346                    [
347                        self.crop_x.eval(t, shot.start, duration),
348                        self.crop_y.eval(t, shot.start, duration),
349                    ],
350                )
351            } else {
352                full
353            };
354            (aspect.zoom(zoom, center), None)
355        };
356
357        let crop = match self.push {
358            Some((z0, z1)) => {
359                let n = shot.frames.saturating_sub(1).max(1) as f64;
360                let u = (i as f64 / n).min(1.0);
361                let e = 0.5 * u + 0.5 * ease(u);
362                let c = match self.pan {
363                    Some((a, b)) => [a[0] + (b[0] - a[0]) * e, a[1] + (b[1] - a[1]) * e],
364                    None => self.push_center.unwrap_or(base_center),
365                };
366                window.zoom(z0 + (z1 - z0) * e, c)
367            }
368            None => window,
369        };
370        let blur = place
371            .filter(|_| self.fill_blur > 0.0)
372            .map(|p| self.fill_blur * p.h);
373        Frame { crop, place, blur }
374    }
375
376    /// Every output frame's geometry.
377    pub fn frames(&self, shot: &Shot, out: &Profile) -> Vec<Frame> {
378        (0..shot.frames.max(1))
379            .map(|i| self.frame(i, shot, out))
380            .collect()
381    }
382
383    /// Whether the window changes over the shot.
384    pub fn is_animated(&self) -> bool {
385        self.zoom.as_ref().is_some_and(Keyed::is_animated)
386            || self.zoom_center.as_ref().is_some_and(Keyed::is_animated)
387            || !self.punch.is_empty()
388            || self.push.is_some()
389            || [&self.crop_x, &self.crop_y].iter().any(|s| match s {
390                Slide::Pan(..) => true,
391                Slide::Keyed(k) | Slide::ClipKeys(k) => k.is_animated(),
392            })
393    }
394}