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    restart_interval: Option<u16>,
167}
168
169impl Default for JpegSettings {
170    fn default() -> Self {
171        Self {
172            quality: DEFAULT_QUALITY,
173            sampling: None,
174            progressive: false,
175            // ON by default, matching FFmpeg's mjpeg (`-huffman optimal`).
176            // Building Huffman tables from the image's own histogram is
177            // lossless — decoded pixels are bit-identical — and measured 5.4%
178            // (q95) to 8.0% (q70) smaller on 1080p detail. It costs ~60% encode
179            // time in our implementation (measured in-process, not via the CLI),
180            // which is a known speed brick: FFmpeg does the same work far
181            // cheaper. `-optimize_huffman 0` opts out.
182            optimize_huffman: true,
183            restart_interval: None,
184        }
185    }
186}
187
188/// Map FFmpeg's `-q:v` qscale (2 = best … 31 = worst) onto JPEG quality (1–100).
189///
190/// Calibrated, not derived. Deriving it from the two encoders' nominal table
191/// multipliers put us 4–6 dB low at every qscale, so this is fitted to measured
192/// equal-PSNR points on 1080p detail at 4:4:4 — the quality at which our output
193/// matches what FFmpeg's mjpeg produces at that qscale:
194///
195/// | qscale | FFmpeg PSNR | our matching quality | curve |
196/// |---|---|---|---|
197/// | 2 | 40.07 dB | 94.7 | 95.1 |
198/// | 3 | 37.15 dB | 91.8 | 91.9 |
199/// | 4 | 35.22 dB | 88.7 | 88.4 |
200/// | 6 | 33.08 dB | 82.3 | 80.8 |
201/// | 10 | 31.05 dB | 63.9 | 63.5 |
202///
203/// The intent is that `-q:v N` yields the same *visual quality* as FFmpeg's
204/// `-q:v N`. File sizes still differ — our rate at matched PSNR is behind, which
205/// is a separate open problem, not something to hide by bending this curve.
206fn quality_from_qscale(qscale: f32) -> u8 {
207    let q = 100.0 - 2.05 * qscale.max(1.0).powf(1.25);
208    q.round().clamp(1.0, 100.0) as u8
209}
210
211fn parse_sampling(value: &str) -> Option<SamplingFactor> {
212    match value.trim() {
213        "444" | "4:4:4" => Some(SamplingFactor::R_4_4_4),
214        "440" | "4:4:0" => Some(SamplingFactor::R_4_4_0),
215        "422" | "4:2:2" => Some(SamplingFactor::R_4_2_2),
216        "420" | "4:2:0" => Some(SamplingFactor::R_4_2_0),
217        "411" | "4:1:1" => Some(SamplingFactor::R_4_1_1),
218        _ => None,
219    }
220}
221
222fn parse_bool(value: &str) -> Option<bool> {
223    match value.trim() {
224        "1" | "true" | "yes" | "on" => Some(true),
225        "0" | "false" | "no" | "off" => Some(false),
226        _ => None,
227    }
228}
229
230#[derive(Default)]
231struct JpegEncoder {
232    packet: Option<Packet>,
233    eof: bool,
234    settings: JpegSettings,
235}
236
237impl RffEncoder for JpegEncoder {
238    /// Planar Y'CbCr first — JPEG's own colour space, encoded with no
239    /// conversion and no chroma resampling. Packed RGB is accepted and
240    /// converted internally; anything else the pipeline converts to `yuv420p`.
241    fn accepted_pixel_formats(&self) -> Option<Vec<PixelFormat>> {
242        Some(vec![
243            PixelFormat::Yuv420p,
244            PixelFormat::Yuv422p,
245            PixelFormat::Yuv444p,
246            PixelFormat::Rgb24,
247            PixelFormat::Rgba,
248        ])
249    }
250
251    fn configure(&mut self, options: &Dictionary) -> Result<()> {
252        // `-q:v` / `-qscale:v` follow FFmpeg's mjpeg convention so the CLI stays
253        // drop-in; `-jpeg_quality` is the direct 1–100 knob.
254        for key in ["q", "qscale", "qp", "crf"] {
255            if let Some(v) = options.get(key).and_then(|v| v.trim().parse::<f32>().ok()) {
256                self.settings.quality = quality_from_qscale(v);
257                break;
258            }
259        }
260        if let Some(v) = options
261            .get("jpeg_quality")
262            .and_then(|v| v.trim().parse::<u8>().ok())
263        {
264            self.settings.quality = v.clamp(1, 100);
265        }
266        if let Some(v) = options
267            .get("sampling")
268            .or_else(|| options.get("jpeg_sampling"))
269        {
270            match parse_sampling(v) {
271                Some(s) => self.settings.sampling = Some(s),
272                None => {
273                    return Err(Error::unsupported(format!(
274                        "jpeg encode: unknown sampling `{v}` (want 444/440/422/420/411)"
275                    )))
276                }
277            }
278        }
279        if let Some(v) = options.get("progressive").and_then(|v| parse_bool(v)) {
280            self.settings.progressive = v;
281        }
282        if let Some(v) = options.get("optimize_huffman").and_then(|v| parse_bool(v)) {
283            self.settings.optimize_huffman = v;
284        }
285        if let Some(v) = options
286            .get("restart_interval")
287            .and_then(|v| v.trim().parse::<u16>().ok())
288        {
289            self.settings.restart_interval = Some(v);
290        }
291        Ok(())
292    }
293
294    fn send_frame(&mut self, frame: &Frame) -> Result<()> {
295        let vf = match frame {
296            Frame::Video(v) => v,
297            Frame::Audio(_) => {
298                return Err(Error::unsupported(
299                    "jpeg encode: audio frame on an image codec",
300                ))
301            }
302        };
303        self.packet = Some(Packet::from_data(0, encode_jpeg(vf, &self.settings)?));
304        Ok(())
305    }
306
307    fn receive_packet(&mut self) -> Result<Packet> {
308        if let Some(packet) = self.packet.take() {
309            return Ok(packet);
310        }
311        if self.eof {
312            Err(Error::Eof)
313        } else {
314            Err(Error::Again)
315        }
316    }
317
318    fn flush(&mut self) {
319        self.eof = true;
320    }
321}
322
323fn encode_jpeg(vf: &VideoFrame, settings: &JpegSettings) -> Result<Vec<u8>> {
324    if vf.width > u16::MAX as u32 || vf.height > u16::MAX as u32 {
325        return Err(Error::unsupported("jpeg encode: dimensions exceed 65535"));
326    }
327    // Planar Y'CbCr goes straight in. JPEG is a Y'CbCr codec, so a planar frame
328    // is already in the codec's own colour space at the codec's own chroma
329    // resolution — routing it through RGB would convert twice and resample
330    // chroma up then back down, which is both slower and lossy.
331    if let Some(sub) = planar_subsampling(vf.format) {
332        return encode_planar(vf, settings, sub);
333    }
334    let (color, channels) = match vf.format {
335        PixelFormat::Rgb24 => (ColorType::Rgb, 3usize),
336        PixelFormat::Rgba => (ColorType::Rgba, 4usize),
337        other => {
338            return Err(Error::unsupported(format!(
339            "jpeg encode: needs rgb24/rgba or planar yuv, got `{}` (convert with -vf format=rgb24)",
340            other.name()
341        )))
342        }
343    };
344    let (w, h) = (vf.width as usize, vf.height as usize);
345    let row = w * channels;
346    let stride = vf.strides[0];
347
348    // rusty_jpeg::encode wants tightly packed rows.
349    let packed: Vec<u8> = if stride == row {
350        vf.planes[0].clone()
351    } else {
352        let mut p = Vec::with_capacity(row * h);
353        for j in 0..h {
354            p.extend_from_slice(&vf.planes[0][j * stride..j * stride + row]);
355        }
356        p
357    };
358
359    let mut out = Vec::new();
360    let mut encoder = Encoder::new(&mut out, settings.quality);
361    if let Some(sampling) = settings.sampling {
362        encoder.set_sampling_factor(sampling);
363    }
364    encoder.set_progressive(settings.progressive);
365    encoder.set_optimized_huffman_tables(settings.optimize_huffman);
366    if let Some(interval) = settings.restart_interval {
367        encoder.set_restart_interval(interval);
368    }
369    encoder
370        .encode(&packed, vf.width as u16, vf.height as u16, color)
371        .map_err(|e| Error::invalid(format!("jpeg encode: {e}")))?;
372    Ok(out)
373}
374
375/// Chroma subsampling `(horizontal, vertical)` of a planar 8-bit Y'CbCr format,
376/// or `None` if the format isn't one.
377fn planar_subsampling(format: PixelFormat) -> Option<(usize, usize)> {
378    match format {
379        PixelFormat::Yuv444p => Some((1, 1)),
380        PixelFormat::Yuv422p => Some((2, 1)),
381        PixelFormat::Yuv420p => Some((2, 2)),
382        _ => None,
383    }
384}
385
386/// Encode a planar Y'CbCr frame with no colour conversion and no chroma
387/// resampling — the planes are handed to the encoder as-is.
388fn encode_planar(vf: &VideoFrame, settings: &JpegSettings, sub: (usize, usize)) -> Result<Vec<u8>> {
389    if vf.planes.len() < 3 || vf.strides.len() < 3 {
390        return Err(Error::invalid(
391            "jpeg encode: planar yuv frame is missing planes",
392        ));
393    }
394    let image = rusty_jpeg::encode::PlanarYcbcrImage::new(
395        &vf.planes[0],
396        &vf.planes[1],
397        &vf.planes[2],
398        [vf.strides[0], vf.strides[1], vf.strides[2]],
399        vf.width as u16,
400        vf.height as u16,
401        sub,
402    )
403    .ok_or_else(|| Error::invalid("jpeg encode: planar yuv planes too small for the frame"))?;
404
405    // The planar path is only lossless while the encoder's sampling factor
406    // matches the source's. Honouring a conflicting `-sampling` would mean
407    // resampling chroma here, silently undoing the reason this path exists — so
408    // say so instead.
409    let native = image.sampling_factor();
410    if let Some(requested) = settings.sampling {
411        if requested != native {
412            return Err(Error::unsupported(format!(
413                "jpeg encode: -sampling conflicts with the source's chroma layout \
414                 (`{}`); convert first with `-vf format=...`, or drop -sampling to \
415                 encode the source layout as-is",
416                vf.format.name()
417            )));
418        }
419    }
420
421    let mut out = Vec::new();
422    let mut encoder = Encoder::new(&mut out, settings.quality);
423    encoder.set_sampling_factor(native);
424    encoder.set_progressive(settings.progressive);
425    encoder.set_optimized_huffman_tables(settings.optimize_huffman);
426    if let Some(interval) = settings.restart_interval {
427        encoder.set_restart_interval(interval);
428    }
429    encoder
430        .encode_image(image)
431        .map_err(|e| Error::invalid(format!("jpeg encode: {e}")))?;
432    Ok(out)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn jpeg_encode_decode_roundtrips_approximately() {
441        let (w, h) = (48u32, 32u32);
442        let (wi, hi) = (w as usize, h as usize);
443        let mut rgb = vec![0u8; wi * hi * 3];
444        for j in 0..hi {
445            for i in 0..wi {
446                let o = (j * wi + i) * 3;
447                rgb[o] = (i * 255 / (wi - 1)) as u8;
448                rgb[o + 1] = (j * 255 / (hi - 1)) as u8;
449                rgb[o + 2] = 128;
450            }
451        }
452        let src = VideoFrame {
453            width: w,
454            height: h,
455            format: PixelFormat::Rgb24,
456            planes: vec![rgb.clone()],
457            strides: vec![wi * 3],
458            pts: None,
459        };
460
461        let bytes = encode_jpeg(&src, &JpegSettings::default()).unwrap();
462        assert_eq!(&bytes[0..3], &[0xFF, 0xD8, 0xFF]); // SOI marker
463
464        let Frame::Video(decoded) = decode_jpeg(&bytes).unwrap() else {
465            unreachable!()
466        };
467        assert_eq!((decoded.width, decoded.height), (w, h));
468        assert_eq!(decoded.format, PixelFormat::Rgb24);
469        // JPEG is lossy; a smooth gradient should stay close.
470        let total: u64 = rgb
471            .iter()
472            .zip(&decoded.planes[0])
473            .map(|(a, b)| (*a as i16 - *b as i16).unsigned_abs() as u64)
474            .sum();
475        let mean = total as f64 / (wi * hi * 3) as f64;
476        assert!(mean < 12.0, "jpeg round-trip drifted too far: {mean:.2}");
477    }
478}