Skip to main content

otf_pixels_codec_webp/
encoder.rs

1//! The WebP encoder.
2//!
3//! Lossy by default, through the owned VP8 encoder at
4//! [`EncodeOptions::quality`]; [`EncodeOptions::lossless`] selects the owned
5//! VP8L encoder. The rest of sharp's WebP options follow libwebp:
6//! [`EncodeOptions::effort`] is its `method`, [`EncodeOptions::alpha_quality`]
7//! reduces a lossy image's alpha to fewer levels as its `alpha_quality`
8//! does, and [`EncodeOptions::near_lossless`] quantizes lossless residuals
9//! at `quality` as its `near_lossless` does. Either way the whole image is gathered first: both
10//! bitstreams make decisions over the whole picture before the first byte is
11//! final.
12
13use otf_pixels_core::{
14    EncodeOptions, Encoder, ImageDescriptor, PixelFormat, PixelsError, Result, Sink,
15};
16
17/// Encodes a WebP stream.
18#[derive(Debug)]
19pub struct WebPEncoder {
20    /// Set by `write_header`; its presence means the header was written.
21    state: Option<State>,
22    options: EncodeOptions,
23    /// The ICC profile to write as `ICCP`, if any.
24    icc: Option<Vec<u8>>,
25    /// The EXIF block to write as `EXIF`, if any.
26    exif: Option<Vec<u8>>,
27    /// The XMP packet to write as `XMP `, if any.
28    xmp: Option<Vec<u8>>,
29}
30
31impl Default for WebPEncoder {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37/// Everything fixed once the descriptor is known.
38#[derive(Debug)]
39struct State {
40    descriptor: ImageDescriptor,
41    /// The whole image, accumulated: the lossless encoder builds a dictionary
42    /// over all of it and cannot emit a row at a time.
43    pixels: Vec<u8>,
44    rows_written: u32,
45}
46
47impl WebPEncoder {
48    /// An encoder with default settings: lossy at the default quality.
49    #[must_use]
50    pub fn new() -> Self {
51        Self::from_options(&EncodeOptions::default())
52    }
53
54    /// An encoder configured from generic encode options: lossy at
55    /// `quality`, or lossless when `lossless` is set.
56    #[must_use]
57    pub const fn from_options(options: &EncodeOptions) -> Self {
58        Self {
59            state: None,
60            options: *options,
61            icc: None,
62            exif: None,
63            xmp: None,
64        }
65    }
66}
67
68/// Refuse pixel formats WebP cannot carry: it is 8 bits per channel.
69fn check_format(format: PixelFormat) -> Result<()> {
70    match format {
71        PixelFormat::Gray8 | PixelFormat::GrayA8 | PixelFormat::Rgb8 | PixelFormat::Rgba8 => Ok(()),
72        other => Err(PixelsError::unsupported(format!(
73            "WebP encoding needs an 8-bit format; got {other}. Convert first."
74        ))),
75    }
76}
77
78impl Encoder for WebPEncoder {
79    fn set_icc_profile(&mut self, profile: Option<&[u8]>) -> Result<()> {
80        if self.state.is_some() {
81            return Err(PixelsError::invalid_argument(
82                "profile",
83                "the ICC profile must be set before write_header",
84            ));
85        }
86        self.icc = profile.map(<[u8]>::to_vec);
87        Ok(())
88    }
89
90    fn set_exif(&mut self, exif: Option<&[u8]>) -> Result<()> {
91        if self.state.is_some() {
92            return Err(PixelsError::invalid_argument(
93                "exif",
94                "the EXIF block must be set before write_header",
95            ));
96        }
97        self.exif = exif.map(<[u8]>::to_vec);
98        Ok(())
99    }
100
101    fn set_xmp(&mut self, xmp: Option<&[u8]>) -> Result<()> {
102        if self.state.is_some() {
103            return Err(PixelsError::invalid_argument(
104                "xmp",
105                "the XMP packet must be set before write_header",
106            ));
107        }
108        self.xmp = xmp.map(<[u8]>::to_vec);
109        Ok(())
110    }
111
112    fn write_header(&mut self, desc: &ImageDescriptor, _sink: &mut dyn Sink) -> Result<()> {
113        if self.state.is_some() {
114            return Err(PixelsError::invalid_argument(
115                "descriptor",
116                "write_header called more than once",
117            ));
118        }
119        check_format(desc.pixel)?;
120        // WebP dimensions are 14-bit in the lossless bitstream; a larger image
121        // cannot be represented at all, so this is a format limit.
122        const MAX: u32 = 16_383;
123        if desc.width > MAX || desc.height > MAX {
124            return Err(PixelsError::unsupported(format!(
125                "WebP dimensions are at most {MAX}; {}x{} does not fit",
126                desc.width, desc.height
127            )));
128        }
129        let capacity = desc
130            .byte_len()
131            .ok_or_else(|| PixelsError::malformed("webp", "image size overflows"))?;
132
133        // Nothing is written yet: the container length is not known until the
134        // compressed body exists.
135        self.state = Some(State {
136            descriptor: *desc,
137            pixels: Vec::with_capacity(capacity),
138            rows_written: 0,
139        });
140        Ok(())
141    }
142
143    fn write_row(&mut self, row: &[u8], _sink: &mut dyn Sink) -> Result<()> {
144        let Some(state) = self.state.as_mut() else {
145            return Err(PixelsError::invalid_argument(
146                "row",
147                "write_row called before write_header",
148            ));
149        };
150        let expected = state.descriptor.row_bytes();
151        if row.len() != expected {
152            return Err(PixelsError::invalid_argument(
153                "row",
154                format!("row is {} bytes, expected {expected}", row.len()),
155            ));
156        }
157        if state.rows_written >= state.descriptor.height {
158            return Err(PixelsError::invalid_argument(
159                "row",
160                format!("more than {} rows written", state.descriptor.height),
161            ));
162        }
163        state.pixels.extend_from_slice(row);
164        state.rows_written += 1;
165        Ok(())
166    }
167
168    fn finish(&mut self, sink: &mut dyn Sink) -> Result<()> {
169        let Some(state) = self.state.as_mut() else {
170            return Err(PixelsError::invalid_argument(
171                "sink",
172                "finish called before write_header",
173            ));
174        };
175        if state.rows_written < state.descriptor.height {
176            return Err(PixelsError::malformed(
177                "webp",
178                format!(
179                    "{} of {} rows were written",
180                    state.rows_written, state.descriptor.height
181                ),
182            ));
183        }
184
185        let options = self.options;
186        let effort = options.effort.unwrap_or(DEFAULT_EFFORT).min(6);
187        let lossless = options.lossless || options.near_lossless;
188        let (body, alpha) = if lossless {
189            let near = options.near_lossless.then_some(options.quality);
190            encode_lossless(state, effort, near)
191        } else {
192            encode_lossy(state, options.quality, effort, options.alpha_quality)?
193        };
194        let (width, height) = (state.descriptor.width, state.descriptor.height);
195        // VP8L carries its own alpha; only a lossy ALPH chunk needs VP8X.
196        let needs_vp8x = alpha && !lossless;
197        let file = container(
198            width,
199            height,
200            &body,
201            alpha,
202            needs_vp8x,
203            self.icc.as_deref(),
204            self.exif.as_deref(),
205            self.xmp.as_deref(),
206        );
207        sink.write_all(&file)?;
208        sink.flush()
209    }
210}
211
212/// libwebp's default `method`, and the search this encoder made before it
213/// took an effort.
214const DEFAULT_EFFORT: u8 = 4;
215
216/// A RIFF chunk: FourCC, little-endian size, payload, pad to even.
217fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], payload: &[u8]) {
218    out.extend_from_slice(kind);
219    out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
220    out.extend_from_slice(payload);
221    if payload.len() % 2 == 1 {
222        out.push(0);
223    }
224}
225
226/// Wrap chunks in the `RIFF`/`WEBP` header.
227fn riff(body: &[u8]) -> Vec<u8> {
228    let mut out = Vec::with_capacity(body.len() + 12);
229    out.extend_from_slice(b"RIFF");
230    out.extend_from_slice(&(body.len() as u32 + 4).to_le_bytes());
231    out.extend_from_slice(b"WEBP");
232    out.extend_from_slice(body);
233    out
234}
235
236/// The image chunks of a lossy WebP: a `VP8 ` frame, preceded by an `ALPH`
237/// chunk when the image has any transparency; and whether it does.
238fn encode_lossy(
239    state: &State,
240    quality: u8,
241    effort: u8,
242    alpha_quality: u8,
243) -> Result<(Vec<u8>, bool)> {
244    let (width, height) = (
245        state.descriptor.width as usize,
246        state.descriptor.height as usize,
247    );
248    let channels = state.descriptor.pixel.channels();
249    let (planes, alpha) = crate::yuv::from_rgb(&state.pixels, channels, width, height);
250    let mut params = crate::vp8::encode::Params::with_quality(quality.clamp(1, 100));
251    params.intra4 = effort >= 2;
252    let vp8 = crate::vp8::encode::encode(&planes, params)?;
253    // An opaque alpha channel is dropped: a simple file says the same thing
254    // in fewer bytes and every reader handles it.
255    let mut alpha = alpha.filter(|a| a.iter().any(|&v| v != 255));
256    let mut body = Vec::new();
257    if let Some(alpha) = &mut alpha {
258        if alpha_quality < 100 {
259            crate::preprocess::quantize_levels(
260                alpha,
261                crate::preprocess::alpha_levels(alpha_quality),
262            );
263        }
264        chunk(
265            &mut body,
266            b"ALPH",
267            &encode_alpha(alpha, width, height, effort),
268        );
269    }
270    chunk(&mut body, b"VP8 ", &vp8);
271    Ok((body, alpha.is_some()))
272}
273
274/// The whole file: the image chunks alone in a simple file, or behind a
275/// `VP8X` header (with an `ICCP` chunk before them and `EXIF` and `XMP `
276/// chunks after) when the alpha needs one or there is metadata.
277#[allow(
278    clippy::too_many_arguments,
279    reason = "the image and each kind of metadata"
280)]
281fn container(
282    width: u32,
283    height: u32,
284    image: &[u8],
285    alpha: bool,
286    needs_vp8x: bool,
287    icc: Option<&[u8]>,
288    exif: Option<&[u8]>,
289    xmp: Option<&[u8]>,
290) -> Vec<u8> {
291    if !needs_vp8x && icc.is_none() && exif.is_none() && xmp.is_none() {
292        return riff(image);
293    }
294    // VP8X flags: ICC 0x20, alpha 0x10, EXIF 0x08, XMP 0x04; then the
295    // canvas size less one.
296    let flags = if icc.is_some() { 0x20 } else { 0 }
297        | if alpha { 0x10 } else { 0 }
298        | if exif.is_some() { 0x08 } else { 0 }
299        | if xmp.is_some() { 0x04 } else { 0 };
300    let mut vp8x = vec![flags, 0, 0, 0];
301    vp8x.extend_from_slice(&(width - 1).to_le_bytes()[..3]);
302    vp8x.extend_from_slice(&(height - 1).to_le_bytes()[..3]);
303    let mut body = Vec::new();
304    chunk(&mut body, b"VP8X", &vp8x);
305    if let Some(profile) = icc {
306        chunk(&mut body, b"ICCP", profile);
307    }
308    body.extend_from_slice(image);
309    if let Some(exif) = exif {
310        chunk(&mut body, b"EXIF", exif);
311    }
312    if let Some(xmp) = xmp {
313        chunk(&mut body, b"XMP ", xmp);
314    }
315    riff(&body)
316}
317
318/// Interleaved samples as VP8L's ARGB words, grey spread to all three
319/// colour channels.
320fn to_argb(pixels: &[u8], channels: usize) -> Vec<u32> {
321    pixels
322        .chunks_exact(channels)
323        .map(|p| {
324            let (rgb, alpha) = match *p {
325                [g] => ([g, g, g], 255),
326                [g, a] => ([g, g, g], a),
327                [r, g, b] => ([r, g, b], 255),
328                [r, g, b, a, ..] => ([r, g, b], a),
329                _ => ([0; 3], 255),
330            };
331            u32::from_be_bytes([alpha, rgb[0], rgb[1], rgb[2]])
332        })
333        .collect()
334}
335
336/// The image chunk of a lossless WebP, one `VP8L`, and whether it has
337/// transparency.
338fn encode_lossless(state: &State, effort: u8, near_lossless: Option<u8>) -> (Vec<u8>, bool) {
339    let (width, height) = (
340        state.descriptor.width as usize,
341        state.descriptor.height as usize,
342    );
343    let argb = to_argb(&state.pixels, state.descriptor.pixel.channels());
344    let mut tuning = crate::vp8l_encode::Tuning::for_effort(effort);
345    // libwebp skips near-lossless for icons under 64 pixels both ways.
346    if let Some(quality) = near_lossless.filter(|_| width >= 64 || height >= 64) {
347        tuning = tuning.near_lossless(quality);
348    }
349    let has_alpha = argb.iter().any(|&p| p >> 24 != 0xff);
350    let mut body = Vec::new();
351    chunk(
352        &mut body,
353        b"VP8L",
354        &crate::vp8l_encode::encode(&argb, width, height, has_alpha, tuning),
355    );
356    (body, has_alpha)
357}
358
359/// An `ALPH` chunk payload: no filter, lossless VP8L compression, the alpha
360/// carried in the green channel of an image stream of implicit size.
361fn encode_alpha(alpha: &[u8], width: usize, height: usize, effort: u8) -> Vec<u8> {
362    let argb: Vec<u32> = alpha
363        .iter()
364        .map(|&a| 0xff00_0000 | (u32::from(a) << 8))
365        .collect();
366    let mut w = crate::vp8l_encode::BitWriter::default();
367    crate::vp8l_encode::write_image_stream(
368        &mut w,
369        &argb,
370        width,
371        height,
372        crate::vp8l_encode::Tuning::for_effort(effort),
373    );
374    let mut out = vec![1]; // compression 1, filter 0, no preprocessing
375    out.extend_from_slice(&w.finish());
376    out
377}
378
379#[cfg(test)]
380#[allow(
381    clippy::unwrap_used,
382    clippy::indexing_slicing,
383    reason = "tests operate on known-good values and assert shapes directly"
384)]
385mod tests {
386    use super::*;
387    use crate::preprocess::{alpha_levels, quantize_levels};
388    use otf_pixels_core::ErrorCode;
389
390    #[test]
391    fn unsupported_pixel_formats_are_refused_at_the_header() {
392        for format in [
393            PixelFormat::Gray16,
394            PixelFormat::Rgb16,
395            PixelFormat::Rgba16,
396            PixelFormat::RgbF32,
397        ] {
398            let descriptor = ImageDescriptor::new(4, 4, format).unwrap();
399            let mut sink = Vec::new();
400            let error = WebPEncoder::new()
401                .write_header(&descriptor, &mut sink)
402                .unwrap_err();
403            assert_eq!(error.code(), ErrorCode::Unsupported, "{format}");
404            assert!(sink.is_empty(), "{format}: bytes were written anyway");
405        }
406    }
407
408    #[test]
409    fn the_encoder_contract_is_enforced() {
410        let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
411        let row = vec![0_u8; descriptor.row_bytes()];
412
413        let mut encoder = WebPEncoder::new();
414        let mut sink = Vec::new();
415        assert_eq!(
416            encoder.write_row(&row, &mut sink).unwrap_err().code(),
417            ErrorCode::InvalidArgument
418        );
419        assert_eq!(
420            encoder.finish(&mut sink).unwrap_err().code(),
421            ErrorCode::InvalidArgument
422        );
423
424        encoder.write_header(&descriptor, &mut sink).unwrap();
425        assert_eq!(
426            encoder
427                .write_header(&descriptor, &mut sink)
428                .unwrap_err()
429                .code(),
430            ErrorCode::InvalidArgument
431        );
432
433        // Finishing early must not emit a truncated image that looks whole.
434        encoder.write_row(&row, &mut sink).unwrap();
435        assert_eq!(
436            encoder.finish(&mut sink).unwrap_err().code(),
437            ErrorCode::Malformed
438        );
439        assert!(sink.is_empty());
440    }
441
442    #[test]
443    fn oversized_images_are_refused() {
444        let descriptor = ImageDescriptor::new(20_000, 4, PixelFormat::Rgb8).unwrap();
445        let error = WebPEncoder::new()
446            .write_header(&descriptor, &mut Vec::new())
447            .unwrap_err();
448        assert_eq!(error.code(), ErrorCode::Unsupported, "{error}");
449    }
450
451    /// Encode `pixels` with `options`, returning the file.
452    fn encode(descriptor: &ImageDescriptor, pixels: &[u8], options: EncodeOptions) -> Vec<u8> {
453        let mut encoder = WebPEncoder::from_options(&options);
454        let mut out = Vec::new();
455        encoder.write_header(descriptor, &mut out).unwrap();
456        for row in pixels.chunks_exact(descriptor.row_bytes()) {
457            encoder.write_row(row, &mut out).unwrap();
458        }
459        encoder.finish(&mut out).unwrap();
460        out
461    }
462
463    /// Decode a file to RGBA.
464    fn decode(bytes: &[u8]) -> Vec<u8> {
465        use otf_pixels_core::{Decoder, Limits};
466        let mut decoder = crate::WebPDecoder::new(bytes, Limits::default()).unwrap();
467        let descriptor = decoder.descriptor();
468        let mut row = vec![0_u8; descriptor.row_bytes()];
469        let mut out = Vec::new();
470        for _ in 0..descriptor.height {
471            decoder.read_row(&mut row).unwrap();
472            out.extend_from_slice(&row);
473        }
474        out
475    }
476
477    /// A `width` x `height` RGBA image: noise in colour, a ramp in alpha.
478    fn noisy(width: u32, height: u32) -> (ImageDescriptor, Vec<u8>) {
479        let descriptor = ImageDescriptor::new(width, height, PixelFormat::Rgba8).unwrap();
480        let mut seed = 99_u32;
481        let mut pixels = Vec::new();
482        for _y in 0..height {
483            for x in 0..width {
484                for _ in 0..3 {
485                    seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
486                    pixels.push((seed >> 16) as u8);
487                }
488                pixels.push((x * 255 / (width - 1)) as u8);
489            }
490        }
491        (descriptor, pixels)
492    }
493
494    fn distinct(values: impl Iterator<Item = u8>) -> usize {
495        let mut seen = [false; 256];
496        values.for_each(|v| seen[usize::from(v)] = true);
497        seen.iter().filter(|&&s| s).count()
498    }
499
500    #[test]
501    fn alpha_levels_follow_libwebp() {
502        assert_eq!(alpha_levels(0), 2);
503        assert_eq!(alpha_levels(50), 12);
504        assert_eq!(alpha_levels(70), 16);
505        assert_eq!(alpha_levels(71), 24);
506        assert_eq!(alpha_levels(99), 248);
507    }
508
509    #[test]
510    fn alpha_is_reduced_to_its_levels_keeping_the_ends() {
511        let mut ramp: Vec<u8> = (0..=255).collect();
512        quantize_levels(&mut ramp, 4);
513        assert!(distinct(ramp.iter().copied()) <= 4);
514        assert_eq!((ramp[0], ramp[255]), (0, 255), "the ends are fixed");
515        // Values already within the level count are left alone.
516        let mut few = vec![3, 3, 200, 9];
517        quantize_levels(&mut few, 4);
518        assert_eq!(few, [3, 3, 200, 9]);
519    }
520
521    #[test]
522    fn a_lower_alpha_quality_keeps_fewer_alpha_levels() {
523        let (descriptor, pixels) = noisy(64, 32);
524        let alpha = |quality| {
525            let options = EncodeOptions::default()
526                .with_alpha_quality(quality)
527                .unwrap();
528            let decoded = decode(&encode(&descriptor, &pixels, options));
529            distinct(decoded.chunks_exact(4).map(|p| p[3]))
530        };
531        assert_eq!(alpha(100), 64, "lossless alpha keeps every level");
532        assert!(alpha(0) <= 2);
533        assert!(alpha(50) <= 12);
534    }
535
536    #[test]
537    fn near_lossless_stays_close_and_is_exact_at_full_quality() {
538        let (descriptor, pixels) = noisy(80, 70);
539        let options = EncodeOptions::default().with_near_lossless(true);
540        let bytes = encode(&descriptor, &pixels, options);
541        assert!(
542            bytes.windows(4).any(|w| w == b"VP8L"),
543            "near-lossless is lossless"
544        );
545        let decoded = decode(&bytes);
546        assert_ne!(decoded, pixels, "quality 80 changes something");
547        // Errors are bounded by the largest quantization, 2 at quality 80,
548        // with green's compensation carried into red and blue.
549        for (got, want) in decoded.chunks_exact(4).zip(pixels.chunks_exact(4)) {
550            assert!(
551                got.iter().zip(want).all(|(&g, &w)| g.abs_diff(w) <= 2),
552                "{got:?} {want:?}"
553            );
554        }
555        let exact = EncodeOptions::with_quality(100)
556            .unwrap()
557            .with_near_lossless(true);
558        assert_eq!(decode(&encode(&descriptor, &pixels, exact)), pixels);
559        // An icon is left exact, as libwebp leaves it.
560        let (small, small_pixels) = noisy(32, 32);
561        assert_eq!(
562            decode(&encode(&small, &small_pixels, options)),
563            small_pixels
564        );
565    }
566
567    #[test]
568    fn every_effort_decodes_and_the_default_is_four() {
569        let (descriptor, pixels) = noisy(48, 40);
570        for lossless in [false, true] {
571            let base = EncodeOptions::default().with_lossless(lossless);
572            let default = encode(&descriptor, &pixels, base);
573            assert_eq!(
574                default,
575                encode(&descriptor, &pixels, base.with_effort(4).unwrap())
576            );
577            for effort in 0..=10 {
578                let bytes = encode(&descriptor, &pixels, base.with_effort(effort).unwrap());
579                let decoded = decode(&bytes);
580                if lossless {
581                    assert_eq!(decoded, pixels, "effort {effort}");
582                } else {
583                    assert_eq!(decoded.len(), pixels.len());
584                }
585            }
586        }
587        // The fast lossy search skips 4x4 prediction, which costs bytes on
588        // detail.
589        let lossy = EncodeOptions::default();
590        assert_ne!(
591            encode(&descriptor, &pixels, lossy.with_effort(0).unwrap()),
592            encode(&descriptor, &pixels, lossy)
593        );
594    }
595}