1#![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#[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 pub feather: Keyed<f64>,
37 pub fill: bool,
39}
40
41#[derive(Debug, Clone, PartialEq)]
44pub struct Transform {
45 pub frame_units: bool,
47 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 pub rotate_deg: Keyed<f64>,
56 pub flip: [bool; 2],
57}
58
59#[derive(Debug, Clone, PartialEq, Default)]
61pub struct Geo {
62 pub crop: Option<Crop>,
63 pub transforms: Vec<Transform>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct Mapped {
70 pub m: Mat3,
72 pub mask: Rect,
74 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 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 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 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 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 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 #[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 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 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 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 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 let src = geom::apply(&m.m, [850.0, 200.0]);
337 assert!(near(src, [750.0, 200.0]), "{src:?}");
338 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 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 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 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 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 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 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}