Skip to main content

victauri_plugin/
filmstrip.rs

1//! Compose captured frames into a single contact-sheet PNG ("filmstrip").
2//!
3//! The animation `scrub` tool seeks an animation to N evenly-spaced points and
4//! captures one frame at each. Returning N separate images is expensive for an
5//! agent to read; a single grid image shows the whole motion arc in one look.
6//! The caller pairs the image with a `manifest` that maps each cell index to its
7//! animation progress/time, so the (unlabelled) grid stays cheap to produce
8//! while remaining fully interpretable.
9
10/// A single captured frame: straight (non-premultiplied) RGBA bytes + size.
11#[derive(Debug, Clone)]
12pub struct Frame {
13    /// RGBA pixel data, 4 bytes per pixel, `w * h * 4` long.
14    pub rgba: Vec<u8>,
15    /// Frame width in pixels.
16    pub w: u32,
17    /// Frame height in pixels.
18    pub h: u32,
19}
20
21impl Frame {
22    /// Construct a frame, validating that the buffer matches the dimensions.
23    #[must_use]
24    pub fn new(rgba: Vec<u8>, w: u32, h: u32) -> Option<Self> {
25        let expected = (w as usize).checked_mul(h as usize)?.checked_mul(4)?;
26        if rgba.len() == expected && w > 0 && h > 0 {
27            Some(Self { rgba, w, h })
28        } else {
29            None
30        }
31    }
32}
33
34/// Compose `frames` into a grid `cols` wide (rows derived), separated and
35/// bordered by `gap` pixels of `bg`. Cells are sized to the largest frame;
36/// smaller frames are placed top-left within their cell. Returns
37/// `(rgba, width, height)` for the composed sheet, or `None` if `frames` is
38/// empty or the resulting buffer would overflow `usize`.
39#[must_use]
40pub fn compose(
41    frames: &[Frame],
42    cols: usize,
43    gap: u32,
44    bg: [u8; 4],
45) -> Option<(Vec<u8>, u32, u32)> {
46    if frames.is_empty() {
47        return None;
48    }
49    let n = frames.len();
50    let cols = cols.max(1).min(n);
51    let rows = n.div_ceil(cols);
52    let gap = gap as usize;
53
54    let cell_w = frames.iter().map(|f| f.w as usize).max()?;
55    let cell_h = frames.iter().map(|f| f.h as usize).max()?;
56
57    // out_w = cols*cell_w + (cols+1)*gap ; out_h analogous. All checked.
58    let out_w = cols
59        .checked_mul(cell_w)?
60        .checked_add(cols.checked_add(1)?.checked_mul(gap)?)?;
61    let out_h = rows
62        .checked_mul(cell_h)?
63        .checked_add(rows.checked_add(1)?.checked_mul(gap)?)?;
64    let total = out_w.checked_mul(out_h)?.checked_mul(4)?;
65    // Guard against absurd allocations (e.g. > ~512 MB sheet).
66    if total > 512 * 1024 * 1024 {
67        return None;
68    }
69
70    // Fill background.
71    let mut out = vec![0u8; total];
72    for px in out.chunks_exact_mut(4) {
73        px.copy_from_slice(&bg);
74    }
75
76    let out_row_bytes = out_w * 4;
77    for (i, frame) in frames.iter().enumerate() {
78        let col = i % cols;
79        let row = i / cols;
80        let x0 = gap + col * (cell_w + gap);
81        let y0 = gap + row * (cell_h + gap);
82        let fw = frame.w as usize;
83        let fh = frame.h as usize;
84        let frame_row_bytes = fw * 4;
85        for y in 0..fh {
86            let dst_start = (y0 + y) * out_row_bytes + x0 * 4;
87            let src_start = y * frame_row_bytes;
88            // Bounds are guaranteed by construction (fw<=cell_w, fh<=cell_h),
89            // but slice with care to avoid any panic on malformed input.
90            let dst_end = dst_start + frame_row_bytes;
91            let src_end = src_start + frame_row_bytes;
92            if dst_end <= out.len() && src_end <= frame.rgba.len() {
93                out[dst_start..dst_end].copy_from_slice(&frame.rgba[src_start..src_end]);
94            }
95        }
96    }
97
98    Some((out, out_w as u32, out_h as u32))
99}
100
101/// Default column count for `n` frames: roughly square, capped so wide strips
102/// stay readable.
103#[must_use]
104pub fn default_cols(n: usize) -> usize {
105    if n == 0 {
106        return 1;
107    }
108    #[allow(
109        clippy::cast_precision_loss,
110        clippy::cast_sign_loss,
111        clippy::cast_possible_truncation
112    )]
113    let c = (n as f64).sqrt().ceil() as usize;
114    c.clamp(1, 8)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn solid(w: u32, h: u32, color: [u8; 4]) -> Frame {
122        let mut rgba = Vec::with_capacity((w * h * 4) as usize);
123        for _ in 0..(w * h) {
124            rgba.extend_from_slice(&color);
125        }
126        Frame::new(rgba, w, h).unwrap()
127    }
128
129    #[test]
130    fn frame_new_validates_size() {
131        assert!(Frame::new(vec![0; 16], 2, 2).is_some());
132        assert!(Frame::new(vec![0; 15], 2, 2).is_none());
133        assert!(Frame::new(vec![], 0, 0).is_none());
134    }
135
136    #[test]
137    fn empty_returns_none() {
138        assert!(compose(&[], 4, 2, [0, 0, 0, 0]).is_none());
139    }
140
141    #[test]
142    fn single_frame_no_gap() {
143        let f = solid(3, 2, [10, 20, 30, 255]);
144        let (rgba, w, h) = compose(std::slice::from_ref(&f), 4, 0, [0, 0, 0, 0]).unwrap();
145        assert_eq!((w, h), (3, 2));
146        assert_eq!(rgba.len(), 3 * 2 * 4);
147        // First pixel should be the frame color (no gap).
148        assert_eq!(&rgba[0..4], &[10, 20, 30, 255]);
149    }
150
151    #[test]
152    fn grid_dims_with_gap() {
153        // 3 frames of 4x2, cols=2 -> rows=2, gap=1.
154        let frames: Vec<Frame> = (0..3).map(|_| solid(4, 2, [1, 2, 3, 255])).collect();
155        let (_, w, h) = compose(&frames, 2, 1, [0, 0, 0, 255]).unwrap();
156        // out_w = 2*4 + 3*1 = 11 ; out_h = 2*2 + 3*1 = 7
157        assert_eq!((w, h), (11, 7));
158    }
159
160    #[test]
161    fn ragged_sizes_clamp_to_max_cell() {
162        let a = solid(4, 2, [255, 0, 0, 255]);
163        let b = solid(2, 4, [0, 255, 0, 255]);
164        let (_, w, h) = compose(&[a, b], 2, 0, [0, 0, 0, 0]).unwrap();
165        // cell = max(4,2) x max(2,4) = 4x4 ; cols=2 -> out_w=8, out_h=4
166        assert_eq!((w, h), (8, 4));
167    }
168
169    #[test]
170    fn background_fills_gaps() {
171        let f = solid(2, 2, [255, 255, 255, 255]);
172        let bg = [9, 8, 7, 255];
173        let (rgba, w, _h) = compose(std::slice::from_ref(&f), 1, 1, bg).unwrap();
174        // Top-left corner is gap → background color.
175        assert_eq!(&rgba[0..4], &bg);
176        // Frame sits at (gap,gap) = (1,1): offset = (w + 1)*4.
177        let off = (w as usize + 1) * 4;
178        assert_eq!(&rgba[off..off + 4], &[255, 255, 255, 255]);
179    }
180
181    #[test]
182    fn default_cols_is_roughly_square() {
183        assert_eq!(default_cols(0), 1);
184        assert_eq!(default_cols(1), 1);
185        assert_eq!(default_cols(4), 2);
186        assert_eq!(default_cols(9), 3);
187        assert_eq!(default_cols(20), 5);
188        assert_eq!(default_cols(1000), 8); // capped
189    }
190}