Skip to main content

rff_codec_jpeg/
lib.rs

1//! JPEG (a.k.a. MJPEG) still-image codec.
2//!
3//! Decode is backed by the pure-Rust [`rusty_jpeg::decode`]; encode by the pure-Rust
4//! [`rusty_jpeg::encode`]. Like PNG, JPEG is self-describing, so a packet is the whole
5//! file.
6//!
7//! **Encode takes planar Y'CbCr directly** (`yuv420p`/`yuv422p`/`yuv444p`) —
8//! JPEG is a Y'CbCr codec, so a planar frame is already in the right colour
9//! space at the right chroma resolution. Routing it through RGB instead cost two
10//! colour conversions and a chroma resample round-trip: measured 2x slower and
11//! several dB worse. RGB frames (`rgb24`/`rgba`) still work and are converted.
12//!
13//! **Decode emits planar too**, for ordinary 3-component Y'CbCr: it taps the
14//! component planes before the decoder's chroma upsampler and colour conversion
15//! run, which measured 1.41x faster than decoding to RGB. Grayscale, CMYK, YCCK
16//! and lossless still take the interleaved [`Rgb24`](PixelFormat::Rgb24) path.
17//! Encoders that need RGB declare so via `accepted_pixel_formats`, and the
18//! pipeline converts for them.
19//!
20//! JPEG is defined on **full-range** samples. The transcode pipeline converts
21//! limited-range sources up front (see `rff::transcode`), so nothing here has to
22//! think about range.
23
24use std::io::Cursor;
25
26use rff_codec::{Codec, CodecRegistry, Decoder, Encoder as RffEncoder};
27use rff_core::{Dictionary, Error, Frame, MediaType, Packet, PixelFormat, Result, VideoFrame};
28use rusty_jpeg::encode::{ColorType, Encoder, SamplingFactor};
29
30/// Default encode quality (1–100) when no `-q` option is given.
31const DEFAULT_QUALITY: u8 = 90;
32
33/// Register the JPEG codec into a [`CodecRegistry`].
34pub fn register(registry: &mut CodecRegistry) {
35    registry.register(Codec {
36        id: rff_core::CodecId::Jpeg,
37        name: "mjpeg",
38        long_name: "JPEG / MJPEG (Motion JPEG) image",
39        media_type: MediaType::Video,
40        decoder: Some(|| Box::new(JpegDecoder::default())),
41        encoder: Some(|| Box::new(JpegEncoder::default())),
42    });
43}
44
45#[derive(Default)]
46struct JpegDecoder {
47    frame: Option<Frame>,
48    eof: bool,
49}
50
51impl Decoder for JpegDecoder {
52    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
53        self.frame = Some(decode_jpeg(&packet.data)?);
54        Ok(())
55    }
56
57    fn receive_frame(&mut self) -> Result<Frame> {
58        if let Some(frame) = self.frame.take() {
59            return Ok(frame);
60        }
61        if self.eof {
62            Err(Error::Eof)
63        } else {
64            Err(Error::Again)
65        }
66    }
67
68    fn flush(&mut self) {
69        self.eof = true;
70    }
71}
72
73fn decode_jpeg(data: &[u8]) -> Result<Frame> {
74    // Planar first: for ordinary 3-component Y'CbCr this skips the decoder's
75    // chroma upsample and its YCbCr->RGB conversion entirely, and hands the
76    // pipeline the layout video actually wants. Anything else (grayscale, CMYK,
77    // YCCK, lossless) falls through to the interleaved path below.
78    if let Some(frame) = decode_jpeg_planar(data)? {
79        return Ok(frame);
80    }
81    decode_jpeg_rgb(data)
82}
83
84/// Decode straight to planar Y'CbCr, or `Ok(None)` if this image isn't a layout
85/// we can express as one of our planar pixel formats.
86fn decode_jpeg_planar(data: &[u8]) -> Result<Option<Frame>> {
87    let mut decoder = rusty_jpeg::decode::Decoder::new(Cursor::new(data));
88    // A decode failure here is a real error, not a reason to retry interleaved:
89    // the second attempt would fail the same way, just more slowly.
90    let planar = match decoder.decode_planar() {
91        Ok(p) => p,
92        Err(rusty_jpeg::decode::Error::Unsupported(_)) => return Ok(None),
93        Err(e) => return Err(Error::invalid(format!("jpeg decode: {e}"))),
94    };
95    let Some(sub) = planar.chroma_subsampling() else {
96        return Ok(None);
97    };
98    let format = match sub {
99        (1, 1) => PixelFormat::Yuv444p,
100        (2, 1) => PixelFormat::Yuv422p,
101        (2, 2) => PixelFormat::Yuv420p,
102        _ => return Ok(None),
103    };
104    // The planes are block-aligned, so `stride` can exceed `width`; consumers
105    // honour strides, and this avoids a repack copy of every plane.
106    let (mut planes, mut strides) = (Vec::with_capacity(3), Vec::with_capacity(3));
107    for c in planar.components {
108        strides.push(c.stride);
109        planes.push(c.data);
110    }
111    Ok(Some(Frame::Video(VideoFrame {
112        width: planar.width as u32,
113        height: planar.height as u32,
114        format,
115        planes,
116        strides,
117        pts: None,
118    })))
119}
120
121fn decode_jpeg_rgb(data: &[u8]) -> Result<Frame> {
122    let mut decoder = rusty_jpeg::decode::Decoder::new(Cursor::new(data));
123    let pixels = decoder
124        .decode()
125        .map_err(|e| Error::invalid(format!("jpeg decode: {e}")))?;
126    let info = decoder
127        .info()
128        .ok_or_else(|| Error::invalid("jpeg decode: missing image info"))?;
129    let (w, h) = (info.width as usize, info.height as usize);
130
131    let (planes, stride) = match info.pixel_format {
132        rusty_jpeg::decode::PixelFormat::RGB24 => (pixels, w * 3),
133        rusty_jpeg::decode::PixelFormat::L8 => {
134            // Grayscale → replicate into packed RGB.
135            let mut rgb = vec![0u8; w * h * 3];
136            for (i, &g) in pixels.iter().enumerate() {
137                rgb[i * 3..i * 3 + 3].copy_from_slice(&[g, g, g]);
138            }
139            (rgb, w * 3)
140        }
141        other => {
142            return Err(Error::unsupported(format!(
143                "jpeg decode: pixel format {other:?} (only RGB24/L8)"
144            )))
145        }
146    };
147
148    Ok(Frame::Video(VideoFrame {
149        width: info.width as u32,
150        height: info.height as u32,
151        format: PixelFormat::Rgb24,
152        planes: vec![planes],
153        strides: vec![stride],
154        pts: None,
155    }))
156}
157
158/// Encoder settings resolved from the output options.
159struct JpegSettings {
160    /// JPEG quality, 1–100 (higher is better).
161    quality: u8,
162    /// `None` = derive from quality, mirroring the encoder's own default.
163    sampling: Option<SamplingFactor>,
164    progressive: bool,
165    optimize_huffman: bool,
166    /// RD-optimal coefficient decisions. Off by default: ~2.4x encode time.
167    trellis: bool,
168    restart_interval: Option<u16>,
169}
170
171impl Default for JpegSettings {
172    fn default() -> Self {
173        Self {
174            quality: DEFAULT_QUALITY,
175            sampling: None,
176            progressive: false,
177            // OFF by default, to match FFmpeg's MJPEG encoder, which has no
178            // optimized-table mode at all — it always writes the fixed standard
179            // tables. Measured on a 40-frame 1080p clip, turning it on costs
180            // **2.09x encode time** for **7.4% smaller files** (27.17 -> 25.17
181            // MB). That is a real and LOSSLESS win, but it is a choice, not a
182            // free one, so it is opt-in: `-optimize_huffman 1`.
183            optimize_huffman: false,
184            // Off by default — it costs ~+144% encode time on real footage.
185            // `-trellis 1` opts in for smaller files.
186            trellis: false,
187            restart_interval: None,
188        }
189    }
190}
191
192/// Map FFmpeg's `-q:v` qscale (2 = best … 31 = worst) onto JPEG quality (1–100).
193///
194/// Calibrated, not derived. Deriving it from the two encoders' nominal table
195/// multipliers put us 4–6 dB low at every qscale, so this is fitted to measured
196/// equal-PSNR points on 1080p detail at 4:4:4 — the quality at which our output
197/// matches what FFmpeg's mjpeg produces at that qscale:
198///
199/// | qscale | FFmpeg PSNR | our matching quality | curve |
200/// |---|---|---|---|
201/// | 2 | 40.07 dB | 94.7 | 95.1 |
202/// | 3 | 37.15 dB | 91.8 | 91.9 |
203/// | 4 | 35.22 dB | 88.7 | 88.4 |
204/// | 6 | 33.08 dB | 82.3 | 80.8 |
205/// | 10 | 31.05 dB | 63.9 | 63.5 |
206///
207/// The intent is that `-q:v N` yields the same *visual quality* as FFmpeg's
208/// `-q:v N`. File sizes still differ — our rate at matched PSNR is behind, which
209/// is a separate open problem, not something to hide by bending this curve.
210fn quality_from_qscale(qscale: f32) -> u8 {
211    let q = 100.0 - 2.05 * qscale.max(1.0).powf(1.25);
212    q.round().clamp(1.0, 100.0) as u8
213}
214
215fn parse_sampling(value: &str) -> Option<SamplingFactor> {
216    match value.trim() {
217        "444" | "4:4:4" => Some(SamplingFactor::R_4_4_4),
218        "440" | "4:4:0" => Some(SamplingFactor::R_4_4_0),
219        "422" | "4:2:2" => Some(SamplingFactor::R_4_2_2),
220        "420" | "4:2:0" => Some(SamplingFactor::R_4_2_0),
221        "411" | "4:1:1" => Some(SamplingFactor::R_4_1_1),
222        _ => None,
223    }
224}
225
226fn parse_bool(value: &str) -> Option<bool> {
227    match value.trim() {
228        "1" | "true" | "yes" | "on" => Some(true),
229        "0" | "false" | "no" | "off" => Some(false),
230        _ => None,
231    }
232}
233
234#[derive(Default)]
235struct JpegEncoder {
236    packet: Option<Packet>,
237    eof: bool,
238    settings: JpegSettings,
239}
240
241impl RffEncoder for JpegEncoder {
242    /// Planar Y'CbCr first — JPEG's own colour space, encoded with no
243    /// conversion and no chroma resampling. Packed RGB is accepted and
244    /// converted internally; anything else the pipeline converts to `yuv420p`.
245    fn accepted_pixel_formats(&self) -> Option<Vec<PixelFormat>> {
246        Some(vec![
247            PixelFormat::Yuv420p,
248            PixelFormat::Yuv422p,
249            PixelFormat::Yuv444p,
250            PixelFormat::Rgb24,
251            PixelFormat::Rgba,
252        ])
253    }
254
255    fn configure(&mut self, options: &Dictionary) -> Result<()> {
256        // `-q:v` / `-qscale:v` follow FFmpeg's mjpeg convention so the CLI stays
257        // drop-in; `-jpeg_quality` is the direct 1–100 knob.
258        for key in ["q", "qscale", "qp", "crf"] {
259            if let Some(v) = options.get(key).and_then(|v| v.trim().parse::<f32>().ok()) {
260                self.settings.quality = quality_from_qscale(v);
261                break;
262            }
263        }
264        if let Some(v) = options
265            .get("jpeg_quality")
266            .and_then(|v| v.trim().parse::<u8>().ok())
267        {
268            self.settings.quality = v.clamp(1, 100);
269        }
270        if let Some(v) = options
271            .get("sampling")
272            .or_else(|| options.get("jpeg_sampling"))
273        {
274            match parse_sampling(v) {
275                Some(s) => self.settings.sampling = Some(s),
276                None => {
277                    return Err(Error::unsupported(format!(
278                        "jpeg encode: unknown sampling `{v}` (want 444/440/422/420/411)"
279                    )))
280                }
281            }
282        }
283        if let Some(v) = options.get("progressive").and_then(|v| parse_bool(v)) {
284            self.settings.progressive = v;
285        }
286        if let Some(v) = options.get("trellis").and_then(|v| parse_bool(v)) {
287            self.settings.trellis = v;
288        }
289        if let Some(v) = options.get("optimize_huffman").and_then(|v| parse_bool(v)) {
290            self.settings.optimize_huffman = v;
291        }
292        if let Some(v) = options
293            .get("restart_interval")
294            .and_then(|v| v.trim().parse::<u16>().ok())
295        {
296            self.settings.restart_interval = Some(v);
297        }
298        Ok(())
299    }
300
301    fn send_frame(&mut self, frame: &Frame) -> Result<()> {
302        let vf = match frame {
303            Frame::Video(v) => v,
304            Frame::Audio(_) => {
305                return Err(Error::unsupported(
306                    "jpeg encode: audio frame on an image codec",
307                ))
308            }
309        };
310        self.packet = Some(Packet::from_data(0, encode_jpeg(vf, &self.settings)?));
311        Ok(())
312    }
313
314    fn receive_packet(&mut self) -> Result<Packet> {
315        if let Some(packet) = self.packet.take() {
316            return Ok(packet);
317        }
318        if self.eof {
319            Err(Error::Eof)
320        } else {
321            Err(Error::Again)
322        }
323    }
324
325    fn flush(&mut self) {
326        self.eof = true;
327    }
328}
329
330fn encode_jpeg(vf: &VideoFrame, settings: &JpegSettings) -> Result<Vec<u8>> {
331    if vf.width > u16::MAX as u32 || vf.height > u16::MAX as u32 {
332        return Err(Error::unsupported("jpeg encode: dimensions exceed 65535"));
333    }
334    // Planar Y'CbCr goes straight in. JPEG is a Y'CbCr codec, so a planar frame
335    // is already in the codec's own colour space at the codec's own chroma
336    // resolution — routing it through RGB would convert twice and resample
337    // chroma up then back down, which is both slower and lossy.
338    if let Some(sub) = planar_subsampling(vf.format) {
339        return encode_planar(vf, settings, sub);
340    }
341    let (color, channels) = match vf.format {
342        PixelFormat::Rgb24 => (ColorType::Rgb, 3usize),
343        PixelFormat::Rgba => (ColorType::Rgba, 4usize),
344        other => {
345            return Err(Error::unsupported(format!(
346            "jpeg encode: needs rgb24/rgba or planar yuv, got `{}` (convert with -vf format=rgb24)",
347            other.name()
348        )))
349        }
350    };
351    let (w, h) = (vf.width as usize, vf.height as usize);
352    let row = w * channels;
353    let stride = vf.strides[0];
354
355    // rusty_jpeg::encode wants tightly packed rows.
356    let packed: Vec<u8> = if stride == row {
357        vf.planes[0].clone()
358    } else {
359        let mut p = Vec::with_capacity(row * h);
360        for j in 0..h {
361            p.extend_from_slice(&vf.planes[0][j * stride..j * stride + row]);
362        }
363        p
364    };
365
366    let mut out = Vec::new();
367    let mut encoder = Encoder::new(&mut out, settings.quality);
368    if let Some(sampling) = settings.sampling {
369        encoder.set_sampling_factor(sampling);
370    }
371    encoder.set_progressive(settings.progressive);
372    encoder.set_optimized_huffman_tables(settings.optimize_huffman);
373    encoder.set_trellis(settings.trellis);
374    if let Some(interval) = settings.restart_interval {
375        encoder.set_restart_interval(interval);
376    }
377    encoder
378        .encode(&packed, vf.width as u16, vf.height as u16, color)
379        .map_err(|e| Error::invalid(format!("jpeg encode: {e}")))?;
380    Ok(out)
381}
382
383/// Chroma subsampling `(horizontal, vertical)` of a planar 8-bit Y'CbCr format,
384/// or `None` if the format isn't one.
385fn planar_subsampling(format: PixelFormat) -> Option<(usize, usize)> {
386    match format {
387        PixelFormat::Yuv444p => Some((1, 1)),
388        PixelFormat::Yuv422p => Some((2, 1)),
389        PixelFormat::Yuv420p => Some((2, 2)),
390        _ => None,
391    }
392}
393
394/// Encode a planar Y'CbCr frame with no colour conversion and no chroma
395/// resampling — the planes are handed to the encoder as-is.
396fn encode_planar(vf: &VideoFrame, settings: &JpegSettings, sub: (usize, usize)) -> Result<Vec<u8>> {
397    if vf.planes.len() < 3 || vf.strides.len() < 3 {
398        return Err(Error::invalid(
399            "jpeg encode: planar yuv frame is missing planes",
400        ));
401    }
402    let image = rusty_jpeg::encode::PlanarYcbcrImage::new(
403        &vf.planes[0],
404        &vf.planes[1],
405        &vf.planes[2],
406        [vf.strides[0], vf.strides[1], vf.strides[2]],
407        vf.width as u16,
408        vf.height as u16,
409        sub,
410    )
411    .ok_or_else(|| Error::invalid("jpeg encode: planar yuv planes too small for the frame"))?;
412
413    // The planar path is only lossless while the encoder's sampling factor
414    // matches the source's. Honouring a conflicting `-sampling` would mean
415    // resampling chroma here, silently undoing the reason this path exists — so
416    // say so instead.
417    let native = image.sampling_factor();
418    if let Some(requested) = settings.sampling {
419        if requested != native {
420            return Err(Error::unsupported(format!(
421                "jpeg encode: -sampling conflicts with the source's chroma layout \
422                 (`{}`); convert first with `-vf format=...`, or drop -sampling to \
423                 encode the source layout as-is",
424                vf.format.name()
425            )));
426        }
427    }
428
429    let mut out = Vec::new();
430    let mut encoder = Encoder::new(&mut out, settings.quality);
431    encoder.set_sampling_factor(native);
432    encoder.set_progressive(settings.progressive);
433    encoder.set_optimized_huffman_tables(settings.optimize_huffman);
434    encoder.set_trellis(settings.trellis);
435    if let Some(interval) = settings.restart_interval {
436        encoder.set_restart_interval(interval);
437    }
438    encoder
439        .encode_image(image)
440        .map_err(|e| Error::invalid(format!("jpeg encode: {e}")))?;
441    Ok(out)
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn jpeg_encode_decode_roundtrips_approximately() {
450        let (w, h) = (48u32, 32u32);
451        let (wi, hi) = (w as usize, h as usize);
452        let mut rgb = vec![0u8; wi * hi * 3];
453        for j in 0..hi {
454            for i in 0..wi {
455                let o = (j * wi + i) * 3;
456                rgb[o] = (i * 255 / (wi - 1)) as u8;
457                rgb[o + 1] = (j * 255 / (hi - 1)) as u8;
458                rgb[o + 2] = 128;
459            }
460        }
461        let src = VideoFrame {
462            width: w,
463            height: h,
464            format: PixelFormat::Rgb24,
465            planes: vec![rgb.clone()],
466            strides: vec![wi * 3],
467            pts: None,
468        };
469
470        let bytes = encode_jpeg(&src, &JpegSettings::default()).unwrap();
471        assert_eq!(&bytes[0..3], &[0xFF, 0xD8, 0xFF]); // SOI marker
472
473        let Frame::Video(decoded) = decode_jpeg(&bytes).unwrap() else {
474            unreachable!()
475        };
476        assert_eq!((decoded.width, decoded.height), (w, h));
477        // Decode now returns PLANAR Y'CbCr, not packed RGB: a 3-component JPEG
478        // is tapped before the decoder's chroma upsampler and colour conversion.
479        // This encode is 4:4:4 (quality 90), so the planes are full resolution.
480        assert_eq!(decoded.format, PixelFormat::Yuv444p);
481        assert_eq!(decoded.planes.len(), 3);
482
483        // JPEG is lossy; a smooth gradient should stay close. Convert the planes
484        // back to RGB (BT.601 full-range, matching the encoder's own matrix) so
485        // the comparison is against the original source.
486        let (yp, cb, cr) = (&decoded.planes[0], &decoded.planes[1], &decoded.planes[2]);
487        let (ys, cbs, crs) = (decoded.strides[0], decoded.strides[1], decoded.strides[2]);
488        let mut total = 0u64;
489        for j in 0..hi {
490            for i in 0..wi {
491                let yy = yp[j * ys + i] as f32;
492                let b = cb[j * cbs + i] as f32 - 128.0;
493                let r = cr[j * crs + i] as f32 - 128.0;
494                let got = [
495                    yy + 1.402 * r,
496                    yy - 0.344_136 * b - 0.714_136 * r,
497                    yy + 1.772 * b,
498                ];
499                for (c, g) in got.iter().enumerate() {
500                    let want = rgb[(j * wi + i) * 3 + c] as f32;
501                    total += (g.clamp(0.0, 255.0) - want).abs() as u64;
502                }
503            }
504        }
505        let mean = total as f64 / (wi * hi * 3) as f64;
506        assert!(mean < 12.0, "jpeg round-trip drifted too far: {mean:.2}");
507    }
508}