Skip to main content

otf_pixels_codec_avif/
encoder.rs

1//! The AVIF encoder: pixels to YUV, YUV to an AV1 key frame, and the frame
2//! (with an alpha frame, when the image has transparency) into a HEIF
3//! container.
4//!
5//! What is written:
6//! - Colour as 8-bit BT.601 full-range YUV with sRGB primaries and transfer
7//!   (`colr` nclx 1/13/6, libavif's default for RGB input): 4:2:0 in AV1
8//!   profile 0 by default, or 4:4:4 in profile 1 when
9//!   [`EncodeOptions::chroma_subsampling`] asks; a grey image as monochrome
10//!   AV1.
11//! - [`EncodeOptions::effort`] (0..=9, default 4) narrows the intra mode
12//!   search below 3; above 4 there is no wider search to make yet.
13//! - Transparency as an auxiliary monochrome AV1 item (`auxC` alpha URN,
14//!   `auxl` reference to the colour item), coded at a finer quantizer than
15//!   the colour. An opaque alpha channel is dropped.
16//!
17//! Lossless AVIF needs 4:4:4 RGB coding at quantizer 0, which this encoder
18//! does not produce; asking for it is refused rather than silently lossy.
19
20use crate::av1::encode::frame::{
21    CodedStill, DEFAULT_EFFORT, Picture, encode_still_with, qindex_for_quality,
22};
23use otf_pixels_core::{
24    ChromaSubsampling, EncodeOptions, Encoder, ImageDescriptor, PixelFormat, PixelsError, Result,
25    Sink,
26};
27
28/// `colr` nclx: BT.709 primaries, sRGB transfer, BT.601 matrix.
29const CICP: (u8, u8, u8) = (1, 13, 6);
30/// The largest dimension an AV1 sequence header can declare.
31const MAX_DIMENSION: u32 = 65_536;
32
33/// Encodes an AVIF still.
34#[derive(Debug)]
35pub struct AvifEncoder {
36    state: Option<State>,
37    options: EncodeOptions,
38    /// The ICC profile for a `colr` of type `prof`, if any.
39    icc: Option<Vec<u8>>,
40    /// The EXIF block for an `Exif` item, if any.
41    exif: Option<Vec<u8>>,
42}
43
44#[derive(Debug)]
45struct State {
46    descriptor: ImageDescriptor,
47    pixels: Vec<u8>,
48    rows_written: u32,
49}
50
51impl Default for AvifEncoder {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl AvifEncoder {
58    /// An encoder at the default quality.
59    #[must_use]
60    pub fn new() -> Self {
61        Self::from_options(&EncodeOptions::default())
62    }
63
64    /// An encoder at `options.quality`.
65    #[must_use]
66    pub const fn from_options(options: &EncodeOptions) -> Self {
67        Self {
68            state: None,
69            options: *options,
70            icc: None,
71            exif: None,
72        }
73    }
74}
75
76impl Encoder for AvifEncoder {
77    fn set_icc_profile(&mut self, profile: Option<&[u8]>) -> Result<()> {
78        if self.state.is_some() {
79            return Err(PixelsError::invalid_argument(
80                "profile",
81                "the ICC profile must be set before write_header",
82            ));
83        }
84        self.icc = profile.map(<[u8]>::to_vec);
85        Ok(())
86    }
87
88    fn set_exif(&mut self, exif: Option<&[u8]>) -> Result<()> {
89        if self.state.is_some() {
90            return Err(PixelsError::invalid_argument(
91                "exif",
92                "the EXIF block must be set before write_header",
93            ));
94        }
95        self.exif = exif.map(<[u8]>::to_vec);
96        Ok(())
97    }
98
99    fn write_header(&mut self, desc: &ImageDescriptor, _sink: &mut dyn Sink) -> Result<()> {
100        if self.state.is_some() {
101            return Err(PixelsError::invalid_argument(
102                "descriptor",
103                "write_header called more than once",
104            ));
105        }
106        if self.options.lossless {
107            return Err(PixelsError::unsupported(
108                "lossless AVIF encoding is not implemented; encode lossy or choose PNG or WebP",
109            ));
110        }
111        match desc.pixel {
112            PixelFormat::Gray8 | PixelFormat::GrayA8 | PixelFormat::Rgb8 | PixelFormat::Rgba8 => {}
113            other => {
114                return Err(PixelsError::unsupported(format!(
115                    "AVIF encoding needs an 8-bit format; got {other}. Convert first."
116                )));
117            }
118        }
119        if desc.width == 0
120            || desc.height == 0
121            || desc.width > MAX_DIMENSION
122            || desc.height > MAX_DIMENSION
123        {
124            return Err(PixelsError::unsupported(format!(
125                "AVIF dimensions must be 1 to {MAX_DIMENSION}; {}x{} is not",
126                desc.width, desc.height
127            )));
128        }
129        let capacity = desc
130            .byte_len()
131            .ok_or_else(|| PixelsError::malformed("avif", "image size overflows"))?;
132        self.state = Some(State {
133            descriptor: *desc,
134            pixels: Vec::with_capacity(capacity),
135            rows_written: 0,
136        });
137        Ok(())
138    }
139
140    fn write_row(&mut self, row: &[u8], _sink: &mut dyn Sink) -> Result<()> {
141        let Some(state) = self.state.as_mut() else {
142            return Err(PixelsError::invalid_argument(
143                "row",
144                "write_row called before write_header",
145            ));
146        };
147        let expected = state.descriptor.row_bytes();
148        if row.len() != expected {
149            return Err(PixelsError::invalid_argument(
150                "row",
151                format!("row is {} bytes, expected {expected}", row.len()),
152            ));
153        }
154        if state.rows_written >= state.descriptor.height {
155            return Err(PixelsError::invalid_argument(
156                "row",
157                format!("more than {} rows written", state.descriptor.height),
158            ));
159        }
160        state.pixels.extend_from_slice(row);
161        state.rows_written += 1;
162        Ok(())
163    }
164
165    fn finish(&mut self, sink: &mut dyn Sink) -> Result<()> {
166        let Some(state) = self.state.as_ref() else {
167            return Err(PixelsError::invalid_argument(
168                "sink",
169                "finish called before write_header",
170            ));
171        };
172        if state.rows_written < state.descriptor.height {
173            return Err(PixelsError::malformed(
174                "avif",
175                format!(
176                    "{} of {} rows were written",
177                    state.rows_written, state.descriptor.height
178                ),
179            ));
180        }
181        let bytes = encode(
182            state,
183            &self.options,
184            self.icc.as_deref(),
185            self.exif.as_deref(),
186        )?;
187        sink.write_all(&bytes)?;
188        sink.flush()
189    }
190}
191
192/// The planes of an 8-bit image: YUV 4:2:0 (or Y alone for grey) and the
193/// alpha channel, if any.
194fn to_planes(
195    pixels: &[u8],
196    format: PixelFormat,
197    width: usize,
198    height: usize,
199    subsampled: bool,
200) -> (Vec<Vec<u16>>, Option<Vec<u16>>) {
201    let channels = format.channels();
202    let grey = matches!(format, PixelFormat::Gray8 | PixelFormat::GrayA8);
203    let has_alpha = matches!(format, PixelFormat::GrayA8 | PixelFormat::Rgba8);
204    let pixel = |i: usize| pixels.get(i * channels..(i + 1) * channels).unwrap_or(&[]);
205    let alpha = has_alpha.then(|| {
206        (0..width * height)
207            .map(|i| u16::from(pixel(i).get(channels - 1).copied().unwrap_or(255)))
208            .collect::<Vec<u16>>()
209    });
210    if grey {
211        let y = (0..width * height)
212            .map(|i| u16::from(pixel(i).first().copied().unwrap_or(0)))
213            .collect();
214        return (vec![y], alpha);
215    }
216    // BT.601 full range: Kr 0.299, Kb 0.114.
217    let mut y = Vec::with_capacity(width * height);
218    let mut u_full = Vec::with_capacity(width * height);
219    let mut v_full = Vec::with_capacity(width * height);
220    for i in 0..width * height {
221        let p = pixel(i);
222        let [r, g, b] = [0, 1, 2].map(|k| f64::from(p.get(k).copied().unwrap_or(0)));
223        let luma = 0.299 * r + 0.587 * g + 0.114 * b;
224        y.push(luma.round().clamp(0.0, 255.0) as u16);
225        u_full.push((b - luma) / 1.772);
226        v_full.push((r - luma) / 1.402);
227    }
228    if !subsampled {
229        let full = |plane: &[f64]| -> Vec<u16> {
230            plane
231                .iter()
232                .map(|&c| (c + 128.0).round().clamp(0.0, 255.0) as u16)
233                .collect()
234        };
235        return (vec![y, full(&u_full), full(&v_full)], alpha);
236    }
237    let (cw, ch) = (width.div_ceil(2), height.div_ceil(2));
238    let average = |full: &[f64]| -> Vec<u16> {
239        let mut out = Vec::with_capacity(cw * ch);
240        for cy in 0..ch {
241            for cx in 0..cw {
242                let (mut sum, mut n) = (0.0, 0.0);
243                for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
244                    let (x, yy) = (cx * 2 + dx, cy * 2 + dy);
245                    if x < width && yy < height {
246                        sum += full.get(yy * width + x).copied().unwrap_or(0.0);
247                        n += 1.0;
248                    }
249                }
250                out.push((sum / n + 128.0).round().clamp(0.0, 255.0) as u16);
251            }
252        }
253        out
254    };
255    (vec![y, average(&u_full), average(&v_full)], alpha)
256}
257
258fn encode(
259    state: &State,
260    options: &EncodeOptions,
261    icc: Option<&[u8]>,
262    exif: Option<&[u8]>,
263) -> Result<Vec<u8>> {
264    let (width, height) = (state.descriptor.width, state.descriptor.height);
265    let subsampled = options.chroma_subsampling != Some(ChromaSubsampling::Yuv444);
266    let effort = options.effort.unwrap_or(DEFAULT_EFFORT).min(9);
267    let (planes, alpha) = to_planes(
268        &state.pixels,
269        state.descriptor.pixel,
270        width as usize,
271        height as usize,
272        subsampled,
273    );
274    let qindex = qindex_for_quality(options.quality);
275    let refs: Vec<&[u16]> = planes.iter().map(Vec::as_slice).collect();
276    let colour = encode_still_with(
277        &Picture {
278            width,
279            height,
280            subsampled,
281            planes: &refs,
282            cicp: CICP,
283            full_range: true,
284        },
285        qindex,
286        effort,
287    )?;
288    // Alpha edges show more than chroma errors: code it finer.
289    let alpha = match alpha.filter(|a| a.iter().any(|&v| v != 255)) {
290        Some(alpha) => Some(encode_still_with(
291            &Picture {
292                width,
293                height,
294                subsampled: true,
295                planes: &[&alpha],
296                cicp: (2, 2, 2),
297                full_range: true,
298            },
299            (qindex / 2).max(1),
300            effort,
301        )?),
302        None => None,
303    };
304    Ok(container(
305        width,
306        height,
307        planes.len() == 1,
308        &colour,
309        alpha.as_ref(),
310        icc,
311        exif,
312    ))
313}
314
315/// An ISOBMFF box.
316fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
317    let mut out = Vec::with_capacity(payload.len() + 8);
318    out.extend_from_slice(&(payload.len() as u32 + 8).to_be_bytes());
319    out.extend_from_slice(kind);
320    out.extend_from_slice(payload);
321    out
322}
323
324/// A full box: version and flags, then the payload.
325fn full(kind: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec<u8> {
326    let mut body = vec![version];
327    body.extend_from_slice(&flags.to_be_bytes()[1..]);
328    body.extend_from_slice(payload);
329    bx(kind, &body)
330}
331
332/// `av1C` for a coded still (AV1-ISOBMFF ยง2.3.3).
333fn av1c(coded: &CodedStill, mono: bool) -> Vec<u8> {
334    let mut payload = vec![
335        0x81, // marker, version 1
336        (coded.profile << 5) | (coded.level & 0x1F),
337        // tier 0, 8-bit, monochrome, chroma subsampling x and y, sample
338        // position 0.
339        (u8::from(mono) << 4) | if coded.subsampled { 0b1100 } else { 0 },
340        0, // no initial_presentation_delay
341    ];
342    payload.extend_from_slice(&coded.sequence_header_obu);
343    bx(b"av1C", &payload)
344}
345
346/// The items' properties, in `ipco` order (1-based indices for `ipma`).
347fn container(
348    width: u32,
349    height: u32,
350    mono: bool,
351    colour: &CodedStill,
352    alpha: Option<&CodedStill>,
353    icc: Option<&[u8]>,
354    exif: Option<&[u8]>,
355) -> Vec<u8> {
356    let ftyp = bx(b"ftyp", b"avif\0\0\0\0avifmif1miaf");
357    let ispe = {
358        let mut p = width.to_be_bytes().to_vec();
359        p.extend_from_slice(&height.to_be_bytes());
360        full(b"ispe", 0, 0, &p)
361    };
362    let pixi = |channels: u8| {
363        let mut p = vec![channels];
364        p.extend(std::iter::repeat_n(8, usize::from(channels)));
365        full(b"pixi", 0, 0, &p)
366    };
367    let colr = {
368        let mut p = b"nclx".to_vec();
369        for v in [CICP.0, CICP.1, CICP.2] {
370            p.extend_from_slice(&u16::from(v).to_be_bytes());
371        }
372        p.push(0x80); // full_range_flag
373        bx(b"colr", &p)
374    };
375    // ipco: 1 ispe, 2 pixi, 3 av1C, 4 colr; alpha adds 5 pixi, 6 av1C, 7 auxC;
376    // a profile adds its colr last.
377    let mut ipco = [
378        ispe,
379        pixi(if mono { 1 } else { 3 }),
380        av1c(colour, mono),
381        colr,
382    ]
383    .concat();
384    let mut associations: Vec<(u16, Vec<u8>)> = vec![(1, vec![1, 2, 0x80 | 3, 4])];
385    if let Some(alpha) = alpha {
386        let mut urn = crate::meta::URN_ALPHA.as_bytes().to_vec();
387        urn.push(0);
388        ipco.extend_from_slice(&[pixi(1), av1c(alpha, true), full(b"auxC", 0, 0, &urn)].concat());
389        associations.push((2, vec![1, 5, 0x80 | 6, 7]));
390    }
391    // An ICC profile is a second `colr` on the colour item, next to the
392    // nclx, which still says how to turn the YUV into RGB.
393    if let Some(profile) = icc {
394        ipco.extend_from_slice(&bx(b"colr", &[b"prof".as_slice(), profile].concat()));
395        let index = if alpha.is_some() { 8 } else { 5 };
396        if let Some((_, props)) = associations.first_mut() {
397            props.push(index);
398        }
399    }
400    let ipma = {
401        let mut p = (associations.len() as u32).to_be_bytes().to_vec();
402        for (item, props) in &associations {
403            p.extend_from_slice(&item.to_be_bytes());
404            p.push(props.len() as u8);
405            p.extend_from_slice(props);
406        }
407        full(b"ipma", 0, 0, &p)
408    };
409    let iprp = bx(b"iprp", &[bx(b"ipco", &ipco), ipma].concat());
410
411    let hdlr = full(b"hdlr", 0, 0, b"\0\0\0\0pict\0\0\0\0\0\0\0\0\0\0\0\0\0");
412    let pitm = full(b"pitm", 0, 0, &1_u16.to_be_bytes());
413    let infe = |item: u16, kind: &[u8; 4]| {
414        let mut p = item.to_be_bytes().to_vec();
415        p.extend_from_slice(&[0, 0]);
416        p.extend_from_slice(kind);
417        p.push(0); // item_name
418        full(b"infe", 2, 0, &p)
419    };
420    // Items 1 (colour) and 2 (alpha) are coded images; an EXIF block is the
421    // next, its data a zero offset to the TIFF header and then the block
422    // (ISO/IEC 23008-12 A.2.1).
423    let exif_data = exif.map(|block| [&[0_u8; 4][..], block].concat());
424    let mut items: Vec<(&[u8; 4], &[u8])> = std::iter::once(colour)
425        .chain(alpha)
426        .map(|coded| (b"av01", coded.data.as_slice()))
427        .collect();
428    if let Some(data) = &exif_data {
429        items.push((b"Exif", data));
430    }
431    let iinf = {
432        let mut p = (items.len() as u16).to_be_bytes().to_vec();
433        for (i, (kind, _)) in items.iter().enumerate() {
434            p.extend_from_slice(&infe(i as u16 + 1, kind));
435        }
436        full(b"iinf", 0, 0, &p)
437    };
438    // auxl: the alpha item (2) is auxiliary to the colour item (1); cdsc:
439    // the EXIF item describes it.
440    let mut references = Vec::new();
441    if alpha.is_some() {
442        references.extend(bx(b"auxl", &[0, 2, 0, 1, 0, 1]));
443    }
444    if exif.is_some() {
445        let id = items.len() as u16;
446        references.extend(bx(
447            b"cdsc",
448            &[&id.to_be_bytes()[..], &[0, 1, 0, 1]].concat(),
449        ));
450    }
451    let iref = (!references.is_empty()).then(|| full(b"iref", 0, 0, &references));
452    // iloc v0 with 4-byte offsets and lengths, no base offset.
453    let iloc = |mdat_start: u32| {
454        let mut p = vec![0x44, 0x00];
455        p.extend_from_slice(&(items.len() as u16).to_be_bytes());
456        let mut offset = mdat_start;
457        for (i, (_, data)) in items.iter().enumerate() {
458            p.extend_from_slice(&(i as u16 + 1).to_be_bytes());
459            p.extend_from_slice(&[0, 0, 0, 1]); // data_reference_index, extent_count
460            p.extend_from_slice(&offset.to_be_bytes());
461            p.extend_from_slice(&(data.len() as u32).to_be_bytes());
462            offset += data.len() as u32;
463        }
464        full(b"iloc", 0, 0, &p)
465    };
466    let meta = |mdat_start: u32| {
467        let mut p = [hdlr.clone(), pitm.clone(), iloc(mdat_start), iinf.clone()].concat();
468        if let Some(iref) = &iref {
469            p.extend_from_slice(iref);
470        }
471        p.extend_from_slice(&iprp);
472        full(b"meta", 0, 0, &p)
473    };
474    // The meta box's size does not depend on the offsets it records.
475    let mdat_start = (ftyp.len() + meta(0).len() + 8) as u32;
476    let mdat_payload: Vec<u8> = items
477        .iter()
478        .flat_map(|(_, data)| data.iter().copied())
479        .collect();
480    [ftyp, meta(mdat_start), bx(b"mdat", &mdat_payload)].concat()
481}
482
483#[cfg(test)]
484#[allow(
485    clippy::unwrap_used,
486    clippy::indexing_slicing,
487    reason = "tests operate on known-good values"
488)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn rgb_to_yuv_follows_bt601_full_range() {
494        let pixels = [255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 0, 255];
495        let (planes, alpha) = to_planes(&pixels, PixelFormat::Rgb8, 2, 2, true);
496        assert!(alpha.is_none());
497        assert_eq!(planes[0], vec![255, 0, 76, 29]);
498        // Chroma of the four averaged: red's V and blue's U pull up.
499        let u = (((-76.245_f64) + 255.0 * (1.0 - 0.114)) / 1.772 / 4.0 + 128.0).round();
500        assert!(
501            (i32::from(planes[1][0]) - u as i32).abs() <= 1,
502            "{} vs {u}",
503            planes[1][0]
504        );
505        assert_eq!(planes[1].len(), 1);
506    }
507
508    #[test]
509    fn grey_keeps_one_plane_and_alpha_is_split_out() {
510        let pixels = [10, 200, 20, 255, 30, 0];
511        let (planes, alpha) = to_planes(&pixels, PixelFormat::GrayA8, 3, 1, true);
512        assert_eq!(planes, vec![vec![10, 20, 30]]);
513        assert_eq!(alpha.unwrap(), vec![200, 255, 0]);
514    }
515
516    #[test]
517    fn odd_sizes_average_only_the_samples_present() {
518        let pixels = [0, 0, 255, 0, 0, 255, 0, 0, 255];
519        let (planes, _) = to_planes(&pixels, PixelFormat::Rgb8, 3, 1, true);
520        assert_eq!((planes[1].len(), planes[2].len()), (2, 2));
521        assert_eq!(planes[1][0], planes[1][1]);
522    }
523
524    /// Encode `pixels` with `options`.
525    fn encode_with(descriptor: &ImageDescriptor, pixels: &[u8], options: EncodeOptions) -> Vec<u8> {
526        let mut encoder = AvifEncoder::from_options(&options);
527        let mut out = Vec::new();
528        encoder.write_header(descriptor, &mut out).unwrap();
529        for row in pixels.chunks_exact(descriptor.row_bytes()) {
530            encoder.write_row(row, &mut out).unwrap();
531        }
532        encoder.finish(&mut out).unwrap();
533        out
534    }
535
536    fn decode(bytes: &[u8]) -> Vec<u8> {
537        use otf_pixels_core::{Decoder, Limits};
538        let mut decoder = crate::AvifDecoder::new(bytes, Limits::default()).unwrap();
539        let mut row = vec![0_u8; decoder.descriptor().row_bytes()];
540        let mut out = Vec::new();
541        for _ in 0..decoder.descriptor().height {
542            decoder.read_row(&mut row).unwrap();
543            out.extend_from_slice(&row);
544        }
545        out
546    }
547
548    /// The `av1C` record's profile byte and subsampling byte.
549    fn av1c_bytes(file: &[u8]) -> (u8, u8) {
550        let at = file.windows(4).position(|w| w == b"av1C").unwrap() + 4;
551        (file[at + 1] >> 5, file[at + 2] & 0b1100)
552    }
553
554    #[test]
555    fn full_resolution_chroma_keeps_every_chroma_sample() {
556        let pixels = [255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 0, 255];
557        let (planes, _) = to_planes(&pixels, PixelFormat::Rgb8, 2, 2, false);
558        assert_eq!((planes[1].len(), planes[2].len()), (4, 4));
559        assert_eq!(planes[1][0], 128, "white has no chroma");
560        assert_eq!(planes[2][2], 255, "red's V is full scale");
561    }
562
563    #[test]
564    fn four_four_four_is_profile_one_and_sharper_in_colour() {
565        // Thin colour stripes, which 4:2:0 averages away.
566        let descriptor = ImageDescriptor::new(32, 16, PixelFormat::Rgb8).unwrap();
567        let pixels: Vec<u8> = (0..32 * 16)
568            .flat_map(|i| if i % 2 == 0 { [255, 0, 0] } else { [0, 0, 255] })
569            .collect();
570        let options = EncodeOptions::with_quality(90).unwrap();
571        let sub = encode_with(&descriptor, &pixels, options);
572        let full = encode_with(
573            &descriptor,
574            &pixels,
575            options.with_chroma_subsampling(ChromaSubsampling::Yuv444),
576        );
577        assert_eq!(av1c_bytes(&sub), (0, 0b1100));
578        assert_eq!(av1c_bytes(&full), (1, 0));
579        assert_eq!(
580            encode_with(
581                &descriptor,
582                &pixels,
583                options.with_chroma_subsampling(ChromaSubsampling::Yuv420)
584            ),
585            sub
586        );
587        let error = |bytes: &[u8]| -> u64 {
588            decode(bytes)
589                .iter()
590                .zip(&pixels)
591                .map(|(a, b)| u64::from(a.abs_diff(*b)))
592                .sum()
593        };
594        assert!(
595            error(&full) * 2 < error(&sub),
596            "{} against {}",
597            error(&full),
598            error(&sub)
599        );
600    }
601
602    #[test]
603    fn grey_and_alpha_stay_profile_zero_under_four_four_four() {
604        let descriptor = ImageDescriptor::new(9, 7, PixelFormat::GrayA8).unwrap();
605        let pixels: Vec<u8> = (0..9 * 7)
606            .flat_map(|i| [(i * 4) as u8, (i * 3) as u8])
607            .collect();
608        let file = encode_with(
609            &descriptor,
610            &pixels,
611            EncodeOptions::default().with_chroma_subsampling(ChromaSubsampling::Yuv444),
612        );
613        assert_eq!(av1c_bytes(&file).0, 0);
614        assert_eq!(decode(&file).len(), 9 * 7 * 2);
615    }
616
617    #[test]
618    fn effort_narrows_the_search_and_four_is_the_default() {
619        let descriptor = ImageDescriptor::new(40, 24, PixelFormat::Rgb8).unwrap();
620        let pixels: Vec<u8> = (0..40 * 24 * 3)
621            .map(|i| ((i * 13 + i / 97) % 256) as u8)
622            .collect();
623        let base = EncodeOptions::default();
624        let default = encode_with(&descriptor, &pixels, base);
625        for effort in 3..=10 {
626            assert_eq!(
627                encode_with(&descriptor, &pixels, base.with_effort(effort).unwrap()),
628                default,
629                "effort {effort}"
630            );
631        }
632        let fast = encode_with(&descriptor, &pixels, base.with_effort(0).unwrap());
633        assert_ne!(fast, default);
634        assert_eq!(decode(&fast).len(), pixels.len());
635    }
636}