Skip to main content

rff_codec_webp/
lib.rs

1//! WebP still-image codec, backed by the pure-Rust [`rusty_webp`].
2//!
3//! Decode handles both VP8 (lossy) and VP8L (lossless), yielding packed
4//! [`Rgb24`](PixelFormat::Rgb24) or [`Rgba`](PixelFormat::Rgba). Encode is
5//! **lossless** (image-webp's encoder). Bridge to the YUV codecs with
6//! `-vf format=...`.
7
8use std::io::Cursor;
9
10use rff_codec::{Codec, CodecRegistry, Decoder, Encoder};
11use rff_core::{Error, Frame, MediaType, Packet, PixelFormat, Result, VideoFrame};
12use rusty_webp::{ColorType, WebPDecoder, WebPEncoder};
13
14/// Register the WebP codec into a [`CodecRegistry`].
15pub fn register(registry: &mut CodecRegistry) {
16    registry.register(Codec {
17        id: rff_core::CodecId::Webp,
18        name: "webp",
19        long_name: "WebP image (VP8 / VP8L)",
20        media_type: MediaType::Video,
21        decoder: Some(|| Box::new(WebpDecoder::default())),
22        encoder: Some(|| Box::new(WebpEncoder::default())),
23    });
24}
25
26#[derive(Default)]
27struct WebpDecoder {
28    frames: std::collections::VecDeque<Frame>,
29    eof: bool,
30}
31
32impl Decoder for WebpDecoder {
33    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
34        self.frames = decode_webp(&packet.data)?;
35        Ok(())
36    }
37
38    fn receive_frame(&mut self) -> Result<Frame> {
39        if let Some(frame) = self.frames.pop_front() {
40            return Ok(frame);
41        }
42        if self.eof {
43            Err(Error::Eof)
44        } else {
45            Err(Error::Again)
46        }
47    }
48
49    fn flush(&mut self) {
50        self.eof = true;
51    }
52}
53
54fn decode_webp(data: &[u8]) -> Result<std::collections::VecDeque<Frame>> {
55    // Chroma upsampling for lossy (VP8) files. Bilinear ("fancy") matches
56    // libwebp's dwebp default and is ours too; `RFF_WEBP_UPSAMPLE=simple`
57    // selects the cheaper nearest-value method (dwebp -nofancy) for A/Bs.
58    let mut options = rusty_webp::WebPDecodeOptions::default();
59    if std::env::var("RFF_WEBP_UPSAMPLE").as_deref() == Ok("simple") {
60        options.lossy_upsampling = rusty_webp::UpsamplingMethod::Simple;
61    }
62    let mut decoder = WebPDecoder::new_with_options(Cursor::new(data), options)
63        .map_err(|e| Error::invalid(format!("webp decode: {e}")))?;
64    let (w, h) = decoder.dimensions();
65    let has_alpha = decoder.has_alpha();
66
67    // Animated WebP: decode every composited canvas frame, with pts in
68    // milliseconds accumulated from the per-frame durations (the demuxer sets
69    // a 1/1000 time base for animated streams). FFmpeg's native webp decoder
70    // errors out on these files entirely.
71    if decoder.is_animated() {
72        let mut frames = std::collections::VecDeque::new();
73        let mut pts_ms = 0i64;
74        for _ in 0..decoder.num_frames() {
75            let mut buf = vec![0u8; w as usize * h as usize * 4];
76            let duration = decoder
77                .read_frame(&mut buf)
78                .map_err(|e| Error::invalid(format!("webp decode (animation): {e}")))?;
79            frames.push_back(Frame::Video(VideoFrame {
80                width: w,
81                height: h,
82                format: PixelFormat::Rgba,
83                planes: vec![buf],
84                strides: vec![w as usize * 4],
85                pts: Some(pts_ms),
86            }));
87            pts_ms += i64::from(duration);
88        }
89        return Ok(frames);
90    }
91
92    // Still lossy (VP8) images without alpha decode straight to their native
93    // YUV 4:2:0 planes (BT.601 limited-range — the demuxer labels the stream)
94    // with no chroma upsampling and no RGB round-trip, exactly like FFmpeg's
95    // webp decoder. Setting `RFF_WEBP_UPSAMPLE` opts back into RGB output with
96    // the named upsampling method (libwebp-dwebp look).
97    if std::env::var("RFF_WEBP_UPSAMPLE").is_err() {
98        if let Some(yuv) = decoder
99            .read_yuv420()
100            .map_err(|e| Error::invalid(format!("webp decode: {e}")))?
101        {
102            return Ok(std::collections::VecDeque::from([Frame::Video(
103                VideoFrame {
104                    width: w,
105                    height: h,
106                    format: PixelFormat::Yuv420p,
107                    strides: vec![yuv.y_stride, yuv.uv_stride, yuv.uv_stride],
108                    planes: vec![yuv.y, yuv.u, yuv.v],
109                    pts: None,
110                },
111            )]));
112        }
113    }
114
115    let channels = if has_alpha { 4 } else { 3 };
116
117    let mut buf = vec![0u8; w as usize * h as usize * channels];
118    decoder
119        .read_image(&mut buf)
120        .map_err(|e| Error::invalid(format!("webp decode: {e}")))?;
121
122    let (format, stride) = if has_alpha {
123        (PixelFormat::Rgba, w as usize * 4)
124    } else {
125        (PixelFormat::Rgb24, w as usize * 3)
126    };
127    Ok(std::collections::VecDeque::from([Frame::Video(
128        VideoFrame {
129            width: w,
130            height: h,
131            format,
132            planes: vec![buf],
133            strides: vec![stride],
134            pts: None,
135        },
136    )]))
137}
138
139#[derive(Default)]
140struct WebpEncoder {
141    packet: Option<Packet>,
142    eof: bool,
143}
144
145impl Encoder for WebpEncoder {
146    /// Packed RGB only — this encoder has no Y'CbCr path, so the pipeline
147    /// converts planar input (e.g. straight from the JPEG decoder) for us.
148    fn accepted_pixel_formats(&self) -> Option<Vec<PixelFormat>> {
149        Some(vec![PixelFormat::Rgb24, PixelFormat::Rgba])
150    }
151
152    fn send_frame(&mut self, frame: &Frame) -> Result<()> {
153        let vf = match frame {
154            Frame::Video(v) => v,
155            Frame::Audio(_) => {
156                return Err(Error::unsupported(
157                    "webp encode: audio frame on an image codec",
158                ))
159            }
160        };
161        self.packet = Some(Packet::from_data(0, encode_webp(vf)?));
162        Ok(())
163    }
164
165    fn receive_packet(&mut self) -> Result<Packet> {
166        if let Some(packet) = self.packet.take() {
167            return Ok(packet);
168        }
169        if self.eof {
170            Err(Error::Eof)
171        } else {
172            Err(Error::Again)
173        }
174    }
175
176    fn flush(&mut self) {
177        self.eof = true;
178    }
179}
180
181fn encode_webp(vf: &VideoFrame) -> Result<Vec<u8>> {
182    let (color, channels) = match vf.format {
183        PixelFormat::Rgb24 => (ColorType::Rgb8, 3usize),
184        PixelFormat::Rgba => (ColorType::Rgba8, 4usize),
185        other => {
186            return Err(Error::unsupported(format!(
187                "webp encode: needs rgb24/rgba, got `{}` (convert with -vf format=rgb24)",
188                other.name()
189            )))
190        }
191    };
192    let (w, h) = (vf.width as usize, vf.height as usize);
193    let row = w * channels;
194    let stride = vf.strides[0];
195    let packed: Vec<u8> = if stride == row {
196        vf.planes[0].clone()
197    } else {
198        let mut p = Vec::with_capacity(row * h);
199        for j in 0..h {
200            p.extend_from_slice(&vf.planes[0][j * stride..j * stride + row]);
201        }
202        p
203    };
204
205    let mut out = Vec::new();
206    let mut encoder = WebPEncoder::new(&mut out);
207    // `RFF_WEBP_BACKREFS=0` selects the runs-only fast path (no LZ77 search,
208    // no color cache): bigger files, fastest possible encode. Also the A/B
209    // arm for pricing the compression machinery.
210    if std::env::var("RFF_WEBP_BACKREFS").as_deref() == Ok("0") {
211        let mut params = rusty_webp::EncoderParams::default();
212        params.use_backrefs = false;
213        encoder.set_params(params);
214    }
215    encoder
216        .encode(&packed, vf.width, vf.height, color)
217        .map_err(|e| Error::invalid(format!("webp encode: {e}")))?;
218    Ok(out)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn webp_lossless_encode_decode_roundtrips() {
227        let (w, h) = (32u32, 24u32);
228        let (wi, hi) = (w as usize, h as usize);
229        let mut rgb = vec![0u8; wi * hi * 3];
230        for j in 0..hi {
231            for i in 0..wi {
232                let o = (j * wi + i) * 3;
233                rgb[o] = (i * 255 / (wi - 1)) as u8;
234                rgb[o + 1] = (j * 255 / (hi - 1)) as u8;
235                rgb[o + 2] = 64;
236            }
237        }
238        let src = VideoFrame {
239            width: w,
240            height: h,
241            format: PixelFormat::Rgb24,
242            planes: vec![rgb.clone()],
243            strides: vec![wi * 3],
244            pts: None,
245        };
246
247        let bytes = encode_webp(&src).unwrap();
248        assert_eq!(&bytes[0..4], b"RIFF");
249        assert_eq!(&bytes[8..12], b"WEBP");
250
251        let Frame::Video(decoded) = decode_webp(&bytes).unwrap().pop_front().unwrap() else {
252            unreachable!()
253        };
254        assert_eq!((decoded.width, decoded.height), (w, h));
255        // Lossless: an RGB source round-trips exactly (decoded may carry alpha).
256        let got_rgb: Vec<u8> = match decoded.format {
257            PixelFormat::Rgb24 => decoded.planes[0].clone(),
258            PixelFormat::Rgba => decoded.planes[0]
259                .chunks_exact(4)
260                .flat_map(|p| p[0..3].to_vec())
261                .collect(),
262            other => panic!("unexpected format {other:?}"),
263        };
264        assert_eq!(got_rgb, rgb);
265    }
266}