Skip to main content

gamut_avif/
encoder.rs

1//! The AVIF still-image encoder: RGB → identity planes → AV1 temporal unit → ISOBMFF container.
2
3use gamut_av1::{EncodedStill, encode_still_intra, encode_still_lossless_identity};
4use gamut_color::Planar8;
5use gamut_core::{Dimensions, EncodeImage, ImageRef, Result, Rgb8};
6use gamut_isobmff::{Av1cConfig, AvifStillImage, ImageTransform, NclxColr, write_avif_still};
7
8/// Encodes images to AVIF still images.
9///
10/// 8-bit RGB in, mapped to AV1 identity-matrix 4:4:4. By default the encode is **lossless**;
11/// [`AvifEncoder::with_qindex`] selects a lossy quantizer (`base_q_idx`, `1..=255`). Use
12/// Encode via the [`EncodeImage<Rgb8>`](gamut_core::EncodeImage) trait, taking a typed
13/// [`ImageRef`]. [`AvifEncoder::with_rotation_ccw`] /
14/// [`AvifEncoder::with_mirror`] add `irot`/`imir` display-orientation transforms.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct AvifEncoder {
17    /// AV1 `base_q_idx`: `0` is lossless, `1..=255` is lossy intra (higher = more quantization).
18    qindex: u8,
19    /// Optional `irot`/`imir` display-orientation transforms.
20    transform: ImageTransform,
21}
22
23impl AvifEncoder {
24    /// Creates an encoder (lossless by default).
25    #[must_use]
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Sets the AV1 quantizer (`base_q_idx`): `0` is lossless, `1..=255` is lossy intra (higher
31    /// quantizes more aggressively for smaller files). Returns the updated encoder for chaining.
32    #[must_use]
33    pub fn with_qindex(mut self, qindex: u8) -> Self {
34        self.qindex = qindex;
35        self
36    }
37
38    /// Adds an `irot` display rotation of `quarter_turns × 90°` applied anti-clockwise (the value is
39    /// taken modulo 4, so `0` clears it). The stored pixels are unchanged — a reader rotates at
40    /// display time — so this records e.g. a camera's EXIF orientation without re-encoding. Returns
41    /// the updated encoder for chaining.
42    #[must_use]
43    pub fn with_rotation_ccw(mut self, quarter_turns: u8) -> Self {
44        self.transform.rotation_ccw = quarter_turns % 4;
45        self
46    }
47
48    /// Adds an `imir` display mirror: `axis = 0` mirrors about a vertical axis (left↔right), `1`
49    /// about a horizontal axis (top↔bottom). The stored pixels are unchanged. Returns the updated
50    /// encoder for chaining.
51    #[must_use]
52    pub fn with_mirror(mut self, axis: u8) -> Self {
53        self.transform.mirror_axis = Some(axis & 1);
54        self
55    }
56}
57
58/// Wraps the encoded AV1 temporal unit in the AVIF container, stamping `av1C`/`colr`/`ispe`/`pixi`
59/// from the AV1 configuration so the cross-box consistency requirements hold by construction
60/// (AVIF v1.2.0 §2.2, AV1-ISOBMFF v1.3.0 §2.3.4).
61fn build_avif(still: &EncodedStill, dims: Dimensions, transform: ImageTransform) -> Vec<u8> {
62    let c = &still.config;
63    let av1c = Av1cConfig {
64        seq_profile: c.seq_profile,
65        seq_level_idx_0: c.seq_level_idx_0,
66        seq_tier_0: c.seq_tier_0,
67        high_bitdepth: c.high_bitdepth,
68        twelve_bit: c.twelve_bit,
69        monochrome: c.monochrome,
70        chroma_subsampling_x: c.chroma_subsampling_x,
71        chroma_subsampling_y: c.chroma_subsampling_y,
72        chroma_sample_position: c.chroma_sample_position,
73    };
74    let nclx = NclxColr {
75        colour_primaries: c.color_primaries,
76        transfer_characteristics: c.transfer_characteristics,
77        matrix_coefficients: c.matrix_coefficients,
78        full_range: c.full_range,
79    };
80    let image = AvifStillImage {
81        width: dims.width,
82        height: dims.height,
83        bit_depth: 8,
84        num_channels: 3,
85        av1c,
86        nclx,
87        transform,
88        item_data: &still.obus,
89    };
90    write_avif_still(&image)
91}
92
93impl EncodeImage<Rgb8> for AvifEncoder {
94    /// Maps the RGB image to AV1 identity 4:4:4 planes and wraps the temporal unit in an AVIF file.
95    fn encode_image(&self, image: ImageRef<'_, Rgb8>, out: &mut Vec<u8>) -> Result<usize> {
96        let dims = image.dimensions();
97        let planes = Planar8::from_rgb8_identity_view(image);
98        let still = if self.qindex == 0 {
99            encode_still_lossless_identity(&planes)?
100        } else {
101            encode_still_intra(&planes, self.qindex)?.0
102        };
103        let file = build_avif(&still, dims, self.transform);
104        out.extend_from_slice(&file);
105        Ok(file.len())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn encode(w: u32, h: u32) -> Vec<u8> {
114        let mut rgb = vec![0u8; (w * h * 3) as usize];
115        for (i, b) in rgb.iter_mut().enumerate() {
116            *b = (i * 37) as u8;
117        }
118        let mut out = Vec::new();
119        let dims = Dimensions {
120            width: w,
121            height: h,
122        };
123        AvifEncoder::new()
124            .encode_image(ImageRef::<Rgb8>::new(&rgb, dims).unwrap(), &mut out)
125            .unwrap();
126        out
127    }
128
129    #[test]
130    fn produces_valid_avif_container() {
131        let f = encode(40, 24);
132        assert_eq!(&f[4..8], b"ftyp");
133        for fourcc in [
134            b"meta", b"av1C", b"ispe", b"pixi", b"colr", b"mdat", b"av01",
135        ] {
136            assert!(f.windows(4).any(|w| w == fourcc), "missing box {fourcc:?}");
137        }
138    }
139
140    #[test]
141    fn lossy_produces_valid_avif_container() {
142        // A lossy quantizer still produces the same well-formed container; only the mdat payload
143        // (the AV1 OBUs) differs. Exercises the with_qindex path across quantizer contexts.
144        let mut rgb = vec![0u8; 48 * 32 * 3];
145        for (i, b) in rgb.iter_mut().enumerate() {
146            *b = (i * 29) as u8;
147        }
148        for q in [4u8, 40, 200] {
149            let mut out = Vec::new();
150            let n = AvifEncoder::new()
151                .with_qindex(q)
152                .encode_image(
153                    ImageRef::<Rgb8>::new(
154                        &rgb,
155                        Dimensions {
156                            width: 48,
157                            height: 32,
158                        },
159                    )
160                    .unwrap(),
161                    &mut out,
162                )
163                .unwrap();
164            assert_eq!(n, out.len());
165            assert_eq!(&out[4..8], b"ftyp");
166            for fourcc in [b"meta", b"av1C", b"ispe", b"mdat", b"av01"] {
167                assert!(
168                    out.windows(4).any(|w| w == fourcc),
169                    "missing box {fourcc:?}"
170                );
171            }
172        }
173    }
174
175    #[test]
176    fn ispe_matches_dimensions() {
177        let (w, h) = (37u32, 19u32);
178        let f = encode(w, h);
179        let pos = f.windows(4).position(|x| x == b"ispe").unwrap();
180        let body = pos + 4 + 4; // skip 'ispe' fourcc + FullBox version/flags
181        let rw = u32::from_be_bytes([f[body], f[body + 1], f[body + 2], f[body + 3]]);
182        let rh = u32::from_be_bytes([f[body + 4], f[body + 5], f[body + 6], f[body + 7]]);
183        assert_eq!((rw, rh), (w, h));
184    }
185
186    #[test]
187    fn rejects_wrong_length() {
188        // The wrong-length buffer can't even be wrapped in an ImageRef for the encoder.
189        let r = ImageRef::<Rgb8>::new(
190            &[0; 10],
191            Dimensions {
192                width: 4,
193                height: 4,
194            },
195        );
196        assert!(r.is_err());
197    }
198
199    #[test]
200    fn appends_without_clobbering() {
201        let mut out = vec![0xAA, 0xBB];
202        let rgb = vec![128u8; 4 * 4 * 3];
203        let n = AvifEncoder::new()
204            .encode_image(
205                ImageRef::<Rgb8>::new(
206                    &rgb,
207                    Dimensions {
208                        width: 4,
209                        height: 4,
210                    },
211                )
212                .unwrap(),
213                &mut out,
214            )
215            .unwrap();
216        assert_eq!(out.len(), 2 + n);
217        assert_eq!(&out[0..2], &[0xAA, 0xBB]);
218    }
219
220    fn encode_with(enc: AvifEncoder, w: u32, h: u32) -> Vec<u8> {
221        let mut rgb = vec![0u8; (w * h * 3) as usize];
222        for (i, b) in rgb.iter_mut().enumerate() {
223            *b = (i * 37) as u8;
224        }
225        let mut out = Vec::new();
226        let dims = Dimensions {
227            width: w,
228            height: h,
229        };
230        enc.encode_image(ImageRef::<Rgb8>::new(&rgb, dims).unwrap(), &mut out)
231            .unwrap();
232        out
233    }
234
235    #[test]
236    fn with_rotation_ccw_emits_irot_and_normalizes_mod_four() {
237        // A non-zero rotation emits an `irot` whose body byte is the angle. `irot` lives in `meta`,
238        // which precedes `mdat`, so the first occurrence is the property box (not stray OBU bytes).
239        let f = encode_with(AvifEncoder::new().with_rotation_ccw(1), 4, 4);
240        let p = f
241            .windows(4)
242            .position(|w| w == b"irot")
243            .expect("irot present");
244        assert_eq!(f[p + 4] & 0x03, 1, "irot angle = 1");
245        // 4 ≡ 0 (mod 4) clears the rotation, so no `irot` is written.
246        let f0 = encode_with(AvifEncoder::new().with_rotation_ccw(4), 4, 4);
247        assert!(
248            !f0.windows(4).any(|w| w == b"irot"),
249            "rotation 4 ≡ 0 ⇒ no irot"
250        );
251    }
252
253    #[test]
254    fn with_mirror_emits_imir_axis() {
255        for axis in [0u8, 1] {
256            let f = encode_with(AvifEncoder::new().with_mirror(axis), 4, 4);
257            let p = f
258                .windows(4)
259                .position(|w| w == b"imir")
260                .expect("imir present");
261            assert_eq!(f[p + 4] & 0x01, axis, "imir axis = {axis}");
262            assert!(!f.windows(4).any(|w| w == b"irot"), "mirror only ⇒ no irot");
263        }
264    }
265}