Skip to main content

gamut_webp/
encoder.rs

1//! The public WebP encoder: orchestrates color handling, the VP8/VP8L bitstream, and the RIFF
2//! container, mirroring the shape of [`gamut_avif::AvifEncoder`](https://docs.rs/gamut-avif).
3//!
4//! Both the lossless **VP8L** path (see [`crate::vp8l::encoder`]) and the lossy **VP8** path are
5//! implemented, via the [`EncodeImage<Rgb8>`](gamut_core::EncodeImage) and `EncodeImage<Rgba8>`
6//! impls; transparent lossy images use the extended (`VP8X`) format with a raw `ALPH` alpha chunk.
7
8use gamut_color::{Bt601Range, Yuv420};
9use gamut_core::{Dimensions, EncodeImage, ImageRef, Result, Rgb8, Rgba8};
10use gamut_riff::{FourCc, Vp8xHeader, write_extended, write_simple_lossless, write_simple_lossy};
11
12use crate::alpha;
13use crate::config::{WebpConfig, WebpMode};
14use crate::vp8::frame::encode_frame;
15use crate::vp8l::encoder::encode as encode_vp8l;
16use crate::vp8l::transform::make_argb;
17
18/// Maps a `0..=100` quality to a VP8 base quantizer index (`0..=127`); higher quality → lower index
19/// (less quantization). This is the keystone's simple mapping; finer rate control is issue #32.
20fn quality_to_quant(quality: u8) -> u8 {
21    let q = u32::from(quality.min(100));
22    ((100 - q) * 127 / 100) as u8
23}
24
25/// Encodes 8-bit RGB images to WebP.
26///
27/// Construct with [`WebpEncoder::new`] (lossless), [`WebpEncoder::lossless`], or
28/// [`WebpEncoder::lossy`], then encode via the [`EncodeImage`](gamut_core::EncodeImage) trait.
29#[derive(Debug, Clone, Default)]
30pub struct WebpEncoder {
31    /// Encoder configuration (mode + quality).
32    config: WebpConfig,
33}
34
35impl WebpEncoder {
36    /// Creates an encoder with the default configuration (lossless VP8L).
37    #[must_use]
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Creates an encoder that produces a lossless VP8L bitstream.
43    #[must_use]
44    pub fn lossless() -> Self {
45        Self {
46            config: WebpConfig {
47                mode: WebpMode::Lossless,
48                ..WebpConfig::default()
49            },
50        }
51    }
52
53    /// Creates an encoder that produces a lossy VP8 bitstream at the given `quality` (`0..=100`).
54    #[must_use]
55    pub fn lossy(quality: u8) -> Self {
56        Self {
57            config: WebpConfig {
58                mode: WebpMode::Lossy,
59                quality,
60            },
61        }
62    }
63
64    /// Returns the encoder's configuration.
65    #[must_use]
66    pub fn config(&self) -> WebpConfig {
67        self.config
68    }
69
70    /// Encodes interleaved 8-bit RGB `pixels` (row-major) of `dims`, appending the WebP file to
71    /// `out`. Backs the [`EncodeImage<Rgb8>`] impl; the buffer is already validated by [`ImageRef`].
72    fn encode_rgb8_inner(
73        &self,
74        pixels: &[u8],
75        dims: Dimensions,
76        out: &mut Vec<u8>,
77    ) -> Result<usize> {
78        match self.config.mode {
79            WebpMode::Lossless => {
80                let argb: Vec<u32> = pixels
81                    .chunks_exact(3)
82                    .map(|p| make_argb(0xff, p[0], p[1], p[2]))
83                    .collect();
84                let bitstream = encode_vp8l(&argb, dims)?;
85                let file = write_simple_lossless(&bitstream);
86                let written = file.len();
87                out.extend_from_slice(&file);
88                Ok(written)
89            }
90            WebpMode::Lossy => {
91                // WebP/VP8 is limited-range BT.601 (what libwebp + browsers decode); see Bt601Range.
92                let yuv = Yuv420::from_rgb8(pixels, dims.width, dims.height, Bt601Range::Limited)?;
93                let (payload, _recon) = encode_frame(&yuv, quality_to_quant(self.config.quality));
94                let file = write_simple_lossy(&payload);
95                let written = file.len();
96                out.extend_from_slice(&file);
97                Ok(written)
98            }
99        }
100    }
101
102    /// Encodes interleaved 8-bit RGBA `pixels` (row-major) of `dims`, appending the WebP file to
103    /// `out`. A fully opaque image produces a simple file; a transparent one uses the extended
104    /// (`VP8X`) format with a raw `ALPH` alpha chunk (lossy color) or in-bitstream alpha (lossless).
105    /// Backs the [`EncodeImage<Rgba8>`] impl; the buffer is already validated by [`ImageRef`].
106    fn encode_rgba8_inner(
107        &self,
108        pixels: &[u8],
109        dims: Dimensions,
110        out: &mut Vec<u8>,
111    ) -> Result<usize> {
112        let file = match self.config.mode {
113            WebpMode::Lossless => {
114                let argb: Vec<u32> = pixels
115                    .chunks_exact(4)
116                    .map(|p| make_argb(p[3], p[0], p[1], p[2]))
117                    .collect();
118                write_simple_lossless(&encode_vp8l(&argb, dims)?)
119            }
120            WebpMode::Lossy => {
121                let rgb: Vec<u8> = pixels
122                    .chunks_exact(4)
123                    .flat_map(|p| [p[0], p[1], p[2]])
124                    .collect();
125                let yuv = Yuv420::from_rgb8(&rgb, dims.width, dims.height, Bt601Range::Limited)?;
126                let (vp8, _) = encode_frame(&yuv, quality_to_quant(self.config.quality));
127                if pixels.chunks_exact(4).all(|p| p[3] == 0xff) {
128                    write_simple_lossy(&vp8)
129                } else {
130                    let alpha: Vec<u8> = pixels.chunks_exact(4).map(|p| p[3]).collect();
131                    let alph =
132                        alpha::write_alph(&alpha, dims.width as usize, dims.height as usize)?;
133                    let header = Vp8xHeader {
134                        alpha: true,
135                        canvas_width: dims.width,
136                        canvas_height: dims.height,
137                        ..Default::default()
138                    };
139                    write_extended(&header, &[(FourCc::ALPH, &alph), (FourCc::VP8, &vp8)])
140                }
141            }
142        };
143        let written = file.len();
144        out.extend_from_slice(&file);
145        Ok(written)
146    }
147}
148
149impl EncodeImage<Rgb8> for WebpEncoder {
150    fn encode_image(&self, image: ImageRef<'_, Rgb8>, out: &mut Vec<u8>) -> Result<usize> {
151        self.encode_rgb8_inner(image.as_samples(), image.dimensions(), out)
152    }
153}
154
155impl EncodeImage<Rgba8> for WebpEncoder {
156    fn encode_image(&self, image: ImageRef<'_, Rgba8>, out: &mut Vec<u8>) -> Result<usize> {
157        self.encode_rgba8_inner(image.as_samples(), image.dimensions(), out)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use gamut_core::{DecodeImage, ImageBuf};
165
166    fn dims(w: u32, h: u32) -> Dimensions {
167        Dimensions {
168            width: w,
169            height: h,
170        }
171    }
172
173    #[test]
174    fn constructors_select_mode() {
175        assert_eq!(WebpEncoder::new().config().mode, WebpMode::Lossless);
176        assert_eq!(WebpEncoder::lossless().config().mode, WebpMode::Lossless);
177        let lossy = WebpEncoder::lossy(40);
178        assert_eq!(lossy.config().mode, WebpMode::Lossy);
179        assert_eq!(lossy.config().quality, 40);
180    }
181
182    #[test]
183    fn rejects_mismatched_buffer_length() {
184        // Validation now lives at the ImageRef boundary, before the encoder is even called.
185        assert!(ImageRef::<Rgb8>::new(&[0u8; 10], dims(2, 2)).is_err());
186    }
187
188    #[test]
189    fn lossless_encodes_a_valid_webp_file() {
190        // A solid 2x2 RGB image encodes to a RIFF/WebP file that the gamut decoder reads back
191        // bit-exactly (the round-trip is the lossless guarantee).
192        let mut out = Vec::new();
193        let rgb = [0x10, 0x20, 0x30].repeat(4);
194        let written = WebpEncoder::lossless()
195            .encode_image(ImageRef::<Rgb8>::new(&rgb, dims(2, 2)).unwrap(), &mut out)
196            .expect("encode");
197        assert_eq!(written, out.len());
198        assert_eq!(&out[0..4], b"RIFF");
199
200        let decoded: ImageBuf<Rgb8> = crate::WebpDecoder::new()
201            .decode_image(&out)
202            .expect("decode");
203        assert_eq!(decoded.dimensions(), dims(2, 2));
204        assert_eq!(decoded.as_samples(), rgb.as_slice());
205    }
206
207    #[test]
208    fn lossy_encodes_a_decodable_webp_file() {
209        // Lossy now produces a RIFF/WebP the native decoder reads back to RGB of the right shape (the
210        // pixels are lossy, so only structure is checked here; bit-exactness is the libwebp oracle).
211        let mut out = Vec::new();
212        let rgb = [40u8, 80, 120].repeat(16 * 16);
213        let written = WebpEncoder::lossy(60)
214            .encode_image(ImageRef::<Rgb8>::new(&rgb, dims(16, 16)).unwrap(), &mut out)
215            .expect("lossy encode");
216        assert_eq!(written, out.len());
217        assert_eq!(&out[0..4], b"RIFF");
218        let decoded: ImageBuf<Rgb8> = crate::WebpDecoder::new()
219            .decode_image(&out)
220            .expect("decode");
221        assert_eq!(decoded.dimensions(), dims(16, 16));
222        assert_eq!(decoded.as_samples().len(), 16 * 16 * 3);
223    }
224
225    #[test]
226    fn lossy_rgba_round_trips_alpha_exactly() {
227        // Transparent content: the alpha is stored losslessly (raw `ALPH`), so it round-trips
228        // bit-exactly through the extended container; only the color is lossy.
229        let (w, h) = (32u32, 24u32);
230        let rgba: Vec<u8> = (0..(w * h) as usize)
231            .flat_map(|i| {
232                let (x, y) = (i as u32 % w, i as u32 / w);
233                [
234                    (x * 7) as u8,
235                    (y * 9) as u8,
236                    (x ^ y) as u8,
237                    ((x * 5 + y * 3) & 0xff) as u8,
238                ]
239            })
240            .collect();
241        let mut file = Vec::new();
242        WebpEncoder::lossy(75)
243            .encode_image(
244                ImageRef::<Rgba8>::new(&rgba, dims(w, h)).unwrap(),
245                &mut file,
246            )
247            .expect("rgba encode");
248        assert_eq!(&file[0..4], b"RIFF");
249
250        let decoded: ImageBuf<Rgba8> = crate::WebpDecoder::new()
251            .decode_image(&file)
252            .expect("rgba decode");
253        assert_eq!(decoded.dimensions(), dims(w, h));
254        let dec_alpha: Vec<u8> = decoded.as_samples().chunks_exact(4).map(|p| p[3]).collect();
255        let src_alpha: Vec<u8> = rgba.chunks_exact(4).map(|p| p[3]).collect();
256        assert_eq!(dec_alpha, src_alpha, "alpha must round-trip losslessly");
257    }
258
259    #[test]
260    fn opaque_rgba_uses_the_simple_lossy_format() {
261        use gamut_riff::{RiffReader, WebpChunkId};
262        let rgba = [120u8, 60, 200, 0xff].repeat(16 * 16);
263        let mut file = Vec::new();
264        WebpEncoder::lossy(60)
265            .encode_image(
266                ImageRef::<Rgba8>::new(&rgba, dims(16, 16)).unwrap(),
267                &mut file,
268            )
269            .expect("rgba encode");
270        // A fully-opaque image carries no alpha overhead — just a single `VP8 ` chunk.
271        let ids: Vec<_> = RiffReader::new(&file)
272            .unwrap()
273            .map(|c| WebpChunkId::from(c.unwrap().fourcc))
274            .collect();
275        assert_eq!(ids, vec![WebpChunkId::Vp8]);
276    }
277
278    #[test]
279    fn encode_image_is_object_safe() {
280        let mut out = Vec::new();
281        let rgb = [7u8, 8, 9];
282        let enc: &dyn EncodeImage<Rgb8> = &WebpEncoder::new();
283        let written = enc
284            .encode_image(ImageRef::<Rgb8>::new(&rgb, dims(1, 1)).unwrap(), &mut out)
285            .expect("encode via trait");
286        assert_eq!(written, out.len());
287        assert_eq!(&out[0..4], b"RIFF");
288    }
289}