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. Either way the whole image is gathered first: both
6//! bitstreams make decisions over the whole picture before the first byte is
7//! final.
8
9use otf_pixels_core::{
10    EncodeOptions, Encoder, ImageDescriptor, PixelFormat, PixelsError, Result, Sink,
11};
12
13/// Encodes a WebP stream.
14#[derive(Debug)]
15pub struct WebPEncoder {
16    /// Set by `write_header`; its presence means the header was written.
17    state: Option<State>,
18    options: EncodeOptions,
19    /// The ICC profile to write as `ICCP`, if any.
20    icc: Option<Vec<u8>>,
21}
22
23impl Default for WebPEncoder {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29/// Everything fixed once the descriptor is known.
30#[derive(Debug)]
31struct State {
32    descriptor: ImageDescriptor,
33    /// The whole image, accumulated: the lossless encoder builds a dictionary
34    /// over all of it and cannot emit a row at a time.
35    pixels: Vec<u8>,
36    rows_written: u32,
37}
38
39impl WebPEncoder {
40    /// An encoder with default settings: lossy at the default quality.
41    #[must_use]
42    pub fn new() -> Self {
43        Self::from_options(&EncodeOptions::default())
44    }
45
46    /// An encoder configured from generic encode options: lossy at
47    /// `quality`, or lossless when `lossless` is set.
48    #[must_use]
49    pub const fn from_options(options: &EncodeOptions) -> Self {
50        Self {
51            state: None,
52            options: *options,
53            icc: None,
54        }
55    }
56}
57
58/// Refuse pixel formats WebP cannot carry: it is 8 bits per channel.
59fn check_format(format: PixelFormat) -> Result<()> {
60    match format {
61        PixelFormat::Gray8 | PixelFormat::GrayA8 | PixelFormat::Rgb8 | PixelFormat::Rgba8 => Ok(()),
62        other => Err(PixelsError::unsupported(format!(
63            "WebP encoding needs an 8-bit format; got {other}. Convert first."
64        ))),
65    }
66}
67
68impl Encoder for WebPEncoder {
69    fn set_icc_profile(&mut self, profile: Option<&[u8]>) -> Result<()> {
70        if self.state.is_some() {
71            return Err(PixelsError::invalid_argument(
72                "profile",
73                "the ICC profile must be set before write_header",
74            ));
75        }
76        self.icc = profile.map(<[u8]>::to_vec);
77        Ok(())
78    }
79
80    fn write_header(&mut self, desc: &ImageDescriptor, _sink: &mut dyn Sink) -> Result<()> {
81        if self.state.is_some() {
82            return Err(PixelsError::invalid_argument(
83                "descriptor",
84                "write_header called more than once",
85            ));
86        }
87        check_format(desc.pixel)?;
88        // WebP dimensions are 14-bit in the lossless bitstream; a larger image
89        // cannot be represented at all, so this is a format limit.
90        const MAX: u32 = 16_383;
91        if desc.width > MAX || desc.height > MAX {
92            return Err(PixelsError::unsupported(format!(
93                "WebP dimensions are at most {MAX}; {}x{} does not fit",
94                desc.width, desc.height
95            )));
96        }
97        let capacity = desc
98            .byte_len()
99            .ok_or_else(|| PixelsError::malformed("webp", "image size overflows"))?;
100
101        // Nothing is written yet: the container length is not known until the
102        // compressed body exists.
103        self.state = Some(State {
104            descriptor: *desc,
105            pixels: Vec::with_capacity(capacity),
106            rows_written: 0,
107        });
108        Ok(())
109    }
110
111    fn write_row(&mut self, row: &[u8], _sink: &mut dyn Sink) -> Result<()> {
112        let Some(state) = self.state.as_mut() else {
113            return Err(PixelsError::invalid_argument(
114                "row",
115                "write_row called before write_header",
116            ));
117        };
118        let expected = state.descriptor.row_bytes();
119        if row.len() != expected {
120            return Err(PixelsError::invalid_argument(
121                "row",
122                format!("row is {} bytes, expected {expected}", row.len()),
123            ));
124        }
125        if state.rows_written >= state.descriptor.height {
126            return Err(PixelsError::invalid_argument(
127                "row",
128                format!("more than {} rows written", state.descriptor.height),
129            ));
130        }
131        state.pixels.extend_from_slice(row);
132        state.rows_written += 1;
133        Ok(())
134    }
135
136    fn finish(&mut self, sink: &mut dyn Sink) -> Result<()> {
137        let Some(state) = self.state.as_mut() else {
138            return Err(PixelsError::invalid_argument(
139                "sink",
140                "finish called before write_header",
141            ));
142        };
143        if state.rows_written < state.descriptor.height {
144            return Err(PixelsError::malformed(
145                "webp",
146                format!(
147                    "{} of {} rows were written",
148                    state.rows_written, state.descriptor.height
149                ),
150            ));
151        }
152
153        let (body, alpha) = if self.options.lossless {
154            encode_lossless(state)
155        } else {
156            encode_lossy(state, self.options.quality)?
157        };
158        let (width, height) = (state.descriptor.width, state.descriptor.height);
159        // VP8L carries its own alpha; only a lossy ALPH chunk needs VP8X.
160        let needs_vp8x = alpha && !self.options.lossless;
161        let file = container(width, height, &body, alpha, needs_vp8x, self.icc.as_deref());
162        sink.write_all(&file)?;
163        sink.flush()
164    }
165}
166
167/// A RIFF chunk: FourCC, little-endian size, payload, pad to even.
168fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], payload: &[u8]) {
169    out.extend_from_slice(kind);
170    out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
171    out.extend_from_slice(payload);
172    if payload.len() % 2 == 1 {
173        out.push(0);
174    }
175}
176
177/// Wrap chunks in the `RIFF`/`WEBP` header.
178fn riff(body: &[u8]) -> Vec<u8> {
179    let mut out = Vec::with_capacity(body.len() + 12);
180    out.extend_from_slice(b"RIFF");
181    out.extend_from_slice(&(body.len() as u32 + 4).to_le_bytes());
182    out.extend_from_slice(b"WEBP");
183    out.extend_from_slice(body);
184    out
185}
186
187/// The image chunks of a lossy WebP: a `VP8 ` frame, preceded by an `ALPH`
188/// chunk when the image has any transparency; and whether it does.
189fn encode_lossy(state: &State, quality: u8) -> Result<(Vec<u8>, bool)> {
190    let (width, height) = (
191        state.descriptor.width as usize,
192        state.descriptor.height as usize,
193    );
194    let channels = state.descriptor.pixel.channels();
195    let (planes, alpha) = crate::yuv::from_rgb(&state.pixels, channels, width, height);
196    let vp8 = crate::vp8::encode::encode(
197        &planes,
198        crate::vp8::encode::Params::with_quality(quality.clamp(1, 100)),
199    )?;
200    // An opaque alpha channel is dropped: a simple file says the same thing
201    // in fewer bytes and every reader handles it.
202    let alpha = alpha.filter(|a| a.iter().any(|&v| v != 255));
203    let mut body = Vec::new();
204    if let Some(alpha) = &alpha {
205        chunk(&mut body, b"ALPH", &encode_alpha(alpha, width, height));
206    }
207    chunk(&mut body, b"VP8 ", &vp8);
208    Ok((body, alpha.is_some()))
209}
210
211/// The whole file: the image chunks alone in a simple file, or behind a
212/// `VP8X` header (and an `ICCP` chunk) when the alpha needs one or there is a
213/// profile.
214fn container(
215    width: u32,
216    height: u32,
217    image: &[u8],
218    alpha: bool,
219    needs_vp8x: bool,
220    icc: Option<&[u8]>,
221) -> Vec<u8> {
222    if !needs_vp8x && icc.is_none() {
223        return riff(image);
224    }
225    // VP8X flags: ICC 0x20, alpha 0x10; then the canvas size less one.
226    let flags = if icc.is_some() { 0x20 } else { 0 } | if alpha { 0x10 } else { 0 };
227    let mut vp8x = vec![flags, 0, 0, 0];
228    vp8x.extend_from_slice(&(width - 1).to_le_bytes()[..3]);
229    vp8x.extend_from_slice(&(height - 1).to_le_bytes()[..3]);
230    let mut body = Vec::new();
231    chunk(&mut body, b"VP8X", &vp8x);
232    if let Some(profile) = icc {
233        chunk(&mut body, b"ICCP", profile);
234    }
235    body.extend_from_slice(image);
236    riff(&body)
237}
238
239/// Interleaved samples as VP8L's ARGB words, grey spread to all three
240/// colour channels.
241fn to_argb(pixels: &[u8], channels: usize) -> Vec<u32> {
242    pixels
243        .chunks_exact(channels)
244        .map(|p| {
245            let (rgb, alpha) = match *p {
246                [g] => ([g, g, g], 255),
247                [g, a] => ([g, g, g], a),
248                [r, g, b] => ([r, g, b], 255),
249                [r, g, b, a, ..] => ([r, g, b], a),
250                _ => ([0; 3], 255),
251            };
252            u32::from_be_bytes([alpha, rgb[0], rgb[1], rgb[2]])
253        })
254        .collect()
255}
256
257/// The image chunk of a lossless WebP, one `VP8L`, and whether it has
258/// transparency.
259fn encode_lossless(state: &State) -> (Vec<u8>, bool) {
260    let (width, height) = (
261        state.descriptor.width as usize,
262        state.descriptor.height as usize,
263    );
264    let argb = to_argb(&state.pixels, state.descriptor.pixel.channels());
265    let has_alpha = argb.iter().any(|&p| p >> 24 != 0xff);
266    let mut body = Vec::new();
267    chunk(
268        &mut body,
269        b"VP8L",
270        &crate::vp8l_encode::encode(&argb, width, height, has_alpha),
271    );
272    (body, has_alpha)
273}
274
275/// An `ALPH` chunk payload: no filter, lossless VP8L compression, the alpha
276/// carried in the green channel of an image stream of implicit size.
277fn encode_alpha(alpha: &[u8], width: usize, height: usize) -> Vec<u8> {
278    let argb: Vec<u32> = alpha
279        .iter()
280        .map(|&a| 0xff00_0000 | (u32::from(a) << 8))
281        .collect();
282    let mut w = crate::vp8l_encode::BitWriter::default();
283    crate::vp8l_encode::write_image_stream(&mut w, &argb, width, height);
284    let mut out = vec![1]; // compression 1, filter 0, no preprocessing
285    out.extend_from_slice(&w.finish());
286    out
287}
288
289#[cfg(test)]
290#[allow(
291    clippy::unwrap_used,
292    clippy::indexing_slicing,
293    reason = "tests operate on known-good values and assert shapes directly"
294)]
295mod tests {
296    use super::*;
297    use otf_pixels_core::ErrorCode;
298
299    #[test]
300    fn unsupported_pixel_formats_are_refused_at_the_header() {
301        for format in [
302            PixelFormat::Gray16,
303            PixelFormat::Rgb16,
304            PixelFormat::Rgba16,
305            PixelFormat::RgbF32,
306        ] {
307            let descriptor = ImageDescriptor::new(4, 4, format).unwrap();
308            let mut sink = Vec::new();
309            let error = WebPEncoder::new()
310                .write_header(&descriptor, &mut sink)
311                .unwrap_err();
312            assert_eq!(error.code(), ErrorCode::Unsupported, "{format}");
313            assert!(sink.is_empty(), "{format}: bytes were written anyway");
314        }
315    }
316
317    #[test]
318    fn the_encoder_contract_is_enforced() {
319        let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
320        let row = vec![0_u8; descriptor.row_bytes()];
321
322        let mut encoder = WebPEncoder::new();
323        let mut sink = Vec::new();
324        assert_eq!(
325            encoder.write_row(&row, &mut sink).unwrap_err().code(),
326            ErrorCode::InvalidArgument
327        );
328        assert_eq!(
329            encoder.finish(&mut sink).unwrap_err().code(),
330            ErrorCode::InvalidArgument
331        );
332
333        encoder.write_header(&descriptor, &mut sink).unwrap();
334        assert_eq!(
335            encoder
336                .write_header(&descriptor, &mut sink)
337                .unwrap_err()
338                .code(),
339            ErrorCode::InvalidArgument
340        );
341
342        // Finishing early must not emit a truncated image that looks whole.
343        encoder.write_row(&row, &mut sink).unwrap();
344        assert_eq!(
345            encoder.finish(&mut sink).unwrap_err().code(),
346            ErrorCode::Malformed
347        );
348        assert!(sink.is_empty());
349    }
350
351    #[test]
352    fn oversized_images_are_refused() {
353        let descriptor = ImageDescriptor::new(20_000, 4, PixelFormat::Rgb8).unwrap();
354        let error = WebPEncoder::new()
355            .write_header(&descriptor, &mut Vec::new())
356            .unwrap_err();
357        assert_eq!(error.code(), ErrorCode::Unsupported, "{error}");
358    }
359}