Skip to main content

ffcine_reframe/
window.rs

1//! Crop windows in source pixels, kept as `f64` end to end: nothing is rounded, so a moving
2//! window moves by fractions of a pixel and its size never steps out of sync with its position.
3
4use ffcine_core::geom::Mat3;
5use ffcine_render::Crop;
6
7/// An axis-aligned rectangle in continuous pixel coordinates (`ffcine_core::geom`).
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Rect {
10    pub x: f64,
11    pub y: f64,
12    pub w: f64,
13    pub h: f64,
14}
15
16impl Rect {
17    /// The whole `w x h` frame.
18    pub fn frame(w: f64, h: f64) -> Self {
19        Self {
20            x: 0.0,
21            y: 0.0,
22            w,
23            h,
24        }
25    }
26
27    pub fn aspect(&self) -> f64 {
28        self.w / self.h
29    }
30
31    /// The largest window of aspect `ar` (width / height) inside this one, placed by `pos`: 0 at
32    /// the left/top edge, 0.5 centred, 1 at the right/bottom edge (fractions of the slack, as the
33    /// prototype's `crop_x` / `crop_y`).
34    #[must_use]
35    pub fn fit_aspect(&self, ar: f64, pos: [f64; 2]) -> Self {
36        let (w, h) = if self.aspect() > ar {
37            (self.h * ar, self.h)
38        } else {
39            (self.w, self.w / ar)
40        };
41        self.place(w, h, pos)
42    }
43
44    /// A window `1 / zoom` of this one's size, placed by `pos` in the slack: the prototype's
45    /// `crop=iw/z:ih/z:(iw-iw/z)*cx:(ih-ih/z)*cy`. Zooms below 1 count as 1, so the window never
46    /// leaves the picture.
47    #[must_use]
48    pub fn zoom(&self, zoom: f64, pos: [f64; 2]) -> Self {
49        let z = if zoom.is_finite() { zoom.max(1.0) } else { 1.0 };
50        self.place(self.w / z, self.h / z, pos)
51    }
52
53    #[must_use]
54    fn place(&self, w: f64, h: f64, [px, py]: [f64; 2]) -> Self {
55        Self {
56            x: self.x + (self.w - w) * px.clamp(0.0, 1.0),
57            y: self.y + (self.h - h) * py.clamp(0.0, 1.0),
58            w,
59            h,
60        }
61    }
62
63    /// The map from a pixel of a `out_w x out_h` image showing this window to the source pixel
64    /// it samples (a [`Mat3`] in the stabilizer's convention: output -> source).
65    pub fn to_mat3(&self, out_w: f64, out_h: f64) -> Mat3 {
66        let (sx, sy) = (self.w / out_w, self.h / out_h);
67        [[sx, 0.0, self.x], [0.0, sy, self.y], [0.0, 0.0, 1.0]]
68    }
69
70    #[allow(clippy::cast_possible_truncation)]
71    pub fn to_crop(&self) -> Crop {
72        Crop {
73            x: self.x as f32,
74            y: self.y as f32,
75            width: self.w as f32,
76            height: self.h as f32,
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use ffcine_core::geom;
85
86    #[test]
87    fn vertical_windows_from_uhd() {
88        let src = Rect::frame(3840.0, 2160.0);
89        let reel = src.fit_aspect(9.0 / 16.0, [0.5, 0.5]);
90        assert!((reel.w - 1215.0).abs() < 1e-9 && (reel.h - 2160.0).abs() < 1e-9);
91        assert!((reel.x - (3840.0 - 1215.0) / 2.0).abs() < 1e-9 && reel.y == 0.0);
92        let feed = src.fit_aspect(4.0 / 5.0, [0.0, 0.5]);
93        assert!((feed.w - 1728.0).abs() < 1e-9 && feed.x == 0.0);
94        let right = src.fit_aspect(4.0 / 5.0, [1.0, 0.5]);
95        assert!((right.x + right.w - 3840.0).abs() < 1e-9);
96        // Taller sources slide on y.
97        let portrait = Rect::frame(2160.0, 3840.0).fit_aspect(16.0 / 9.0, [0.5, 0.25]);
98        assert!((portrait.h - 1215.0).abs() < 1e-9 && portrait.x == 0.0);
99        assert!((portrait.y - (3840.0 - 1215.0) * 0.25).abs() < 1e-9);
100    }
101
102    #[test]
103    fn zoom_matches_prototype_crop() {
104        let src = Rect::frame(3840.0, 2160.0);
105        let z = src.zoom(1.25, [0.3, 0.7]);
106        assert!((z.w - 3072.0).abs() < 1e-9 && (z.h - 1728.0).abs() < 1e-9);
107        assert!((z.x - 768.0 * 0.3).abs() < 1e-9 && (z.y - 432.0 * 0.7).abs() < 1e-9);
108        // Below 1 clamps; the aspect is kept exactly.
109        assert_eq!(src.zoom(0.8, [0.5, 0.5]), src);
110        assert!((z.aspect() - src.aspect()).abs() < 1e-12);
111    }
112
113    #[test]
114    fn matrix_maps_corners() {
115        let r = Rect {
116            x: 100.25,
117            y: 50.5,
118            w: 1215.0,
119            h: 2160.0,
120        };
121        let m = r.to_mat3(1080.0, 1920.0);
122        let [x, y] = geom::apply(&m, [0.0, 0.0]);
123        assert!((x - 100.25).abs() < 1e-9 && (y - 50.5).abs() < 1e-9);
124        let [x, y] = geom::apply(&m, [1080.0, 1920.0]);
125        assert!((x - 1315.25).abs() < 1e-9 && (y - 2210.5).abs() < 1e-9);
126    }
127}