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