oxideav-mjpeg 0.1.8

Pure-Rust JPEG / Motion-JPEG codec for oxideav — baseline, extended-sequential, and progressive decode; baseline and progressive encode
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
//! `oxideav-core` integration layer for `oxideav-mjpeg`.
//!
//! Gated behind the default-on `registry` feature so image-library
//! consumers can depend on `oxideav-mjpeg` with `default-features = false`
//! and skip the `oxideav-core` dependency entirely.
//!
//! The module exposes:
//! * [`register`] / [`register_codecs`] / [`register_containers`] — the
//!   `CodecRegistry` / `ContainerRegistry` entry points the umbrella
//!   `oxideav` crate calls during framework initialisation.
//! * The [`MjpegEncoder`] struct that implements the framework
//!   `Encoder` trait, plus the corresponding `MjpegDecoder` that
//!   implements the `Decoder` trait. Both wrap the framework-free
//!   [`crate::decoder::decode_jpeg`] / `encode_jpeg_*` entry points
//!   defined in [`crate::encoder`].
//! * The `From<MjpegError> for oxideav_core::Error` and
//!   `From<MjpegFrame> for oxideav_core::VideoFrame` /
//!   `From<MjpegPixelFormat> for oxideav_core::PixelFormat`
//!   conversions used by the trait impls below.

use std::collections::VecDeque;

use oxideav_core::frame::VideoPlane;
use oxideav_core::{
    CodecCapabilities, CodecId, CodecInfo, CodecParameters, CodecRegistry, CodecTag,
    ContainerRegistry, Decoder, Encoder, Error, Frame, MediaType, Packet, PixelFormat, Result,
    RuntimeContext, TimeBase, VideoFrame,
};

use crate::container;
use crate::decoder::decode_jpeg;
use crate::encoder::{
    encode_jpeg_cmyk, encode_jpeg_cmyk_progressive, encode_jpeg_grayscale_with_opts,
    encode_jpeg_progressive, encode_jpeg_progressive_grayscale, encode_jpeg_rgb24_with_opts,
    encode_jpeg_with_opts, encode_lossless_jpeg_grayscale, DEFAULT_QUALITY,
};
use crate::error::MjpegError;
use crate::image::{MjpegFrame, MjpegPixelFormat, MjpegPlane};
use crate::mjpeg_container;
use crate::CODEC_ID_STR;

// ---- Error / pixel-format / frame conversions --------------------------

impl From<MjpegError> for Error {
    fn from(e: MjpegError) -> Self {
        match e {
            MjpegError::InvalidData(s) => Error::InvalidData(s),
            MjpegError::Unsupported(s) => Error::Unsupported(s),
            MjpegError::Other(s) => Error::Other(s),
            MjpegError::Eof => Error::Eof,
            MjpegError::NeedMore => Error::NeedMore,
        }
    }
}

impl From<MjpegPixelFormat> for PixelFormat {
    fn from(p: MjpegPixelFormat) -> Self {
        match p {
            MjpegPixelFormat::Gray8 => PixelFormat::Gray8,
            MjpegPixelFormat::Gray10Le => PixelFormat::Gray10Le,
            MjpegPixelFormat::Gray12Le => PixelFormat::Gray12Le,
            MjpegPixelFormat::Gray16Le => PixelFormat::Gray16Le,
            MjpegPixelFormat::Cmyk => PixelFormat::Cmyk,
            MjpegPixelFormat::Rgb24 => PixelFormat::Rgb24,
            MjpegPixelFormat::Rgb48Le => PixelFormat::Rgb48Le,
            MjpegPixelFormat::Gbrp10Le => PixelFormat::Gbrp10Le,
            MjpegPixelFormat::Gbrp12Le => PixelFormat::Gbrp12Le,
            MjpegPixelFormat::Gbrp14Le => PixelFormat::Gbrp14Le,
            MjpegPixelFormat::Yuv411P => PixelFormat::Yuv411P,
            MjpegPixelFormat::Yuv420P => PixelFormat::Yuv420P,
            MjpegPixelFormat::Yuv422P => PixelFormat::Yuv422P,
            MjpegPixelFormat::Yuv444P => PixelFormat::Yuv444P,
            MjpegPixelFormat::Yuv420P12Le => PixelFormat::Yuv420P12Le,
            MjpegPixelFormat::Yuv422P12Le => PixelFormat::Yuv422P12Le,
            MjpegPixelFormat::Yuv444P12Le => PixelFormat::Yuv444P12Le,
        }
    }
}

/// Inverse of [`From<MjpegPixelFormat> for PixelFormat`]. Returns
/// `None` for any pixel format the JPEG codec does not produce or
/// accept (so the encoder can reject unsupported `CodecParameters`
/// up-front rather than failing inside `encode_jpeg_*`).
fn pix_to_local(p: PixelFormat) -> Option<MjpegPixelFormat> {
    Some(match p {
        PixelFormat::Gray8 => MjpegPixelFormat::Gray8,
        PixelFormat::Gray10Le => MjpegPixelFormat::Gray10Le,
        PixelFormat::Gray12Le => MjpegPixelFormat::Gray12Le,
        PixelFormat::Gray16Le => MjpegPixelFormat::Gray16Le,
        PixelFormat::Cmyk => MjpegPixelFormat::Cmyk,
        PixelFormat::Rgb24 => MjpegPixelFormat::Rgb24,
        PixelFormat::Rgb48Le => MjpegPixelFormat::Rgb48Le,
        PixelFormat::Gbrp10Le => MjpegPixelFormat::Gbrp10Le,
        PixelFormat::Gbrp12Le => MjpegPixelFormat::Gbrp12Le,
        PixelFormat::Gbrp14Le => MjpegPixelFormat::Gbrp14Le,
        PixelFormat::Yuv411P => MjpegPixelFormat::Yuv411P,
        PixelFormat::Yuv420P => MjpegPixelFormat::Yuv420P,
        PixelFormat::Yuv422P => MjpegPixelFormat::Yuv422P,
        PixelFormat::Yuv444P => MjpegPixelFormat::Yuv444P,
        PixelFormat::Yuv420P12Le => MjpegPixelFormat::Yuv420P12Le,
        PixelFormat::Yuv422P12Le => MjpegPixelFormat::Yuv422P12Le,
        PixelFormat::Yuv444P12Le => MjpegPixelFormat::Yuv444P12Le,
        _ => return None,
    })
}

impl From<MjpegFrame> for VideoFrame {
    fn from(f: MjpegFrame) -> Self {
        VideoFrame {
            pts: f.pts,
            planes: f
                .planes
                .into_iter()
                .map(|p| VideoPlane {
                    stride: p.stride,
                    data: p.data,
                })
                .collect(),
        }
    }
}

impl From<MjpegPlane> for VideoPlane {
    fn from(p: MjpegPlane) -> Self {
        VideoPlane {
            stride: p.stride,
            data: p.data,
        }
    }
}

// ---- CodecRegistry / ContainerRegistry entry points --------------------

/// Register the JPEG / MJPEG codec (decoder + encoder) into the
/// supplied [`CodecRegistry`].
///
/// Kept as a free function (rather than a method on a registry handle)
/// so it matches the registration shape used by the umbrella
/// `oxideav` crate.
pub fn register_codecs(reg: &mut CodecRegistry) {
    let caps = CodecCapabilities::video("mjpeg_sw")
        .with_lossy(true)
        .with_intra_only(true)
        .with_max_size(16384, 16384);
    reg.register(
        CodecInfo::new(CodecId::new(CODEC_ID_STR))
            .capabilities(caps)
            .decoder(make_decoder)
            .encoder(make_encoder)
            .tags([
                // AVI FourCC claims — all unambiguous MJPEG variants.
                CodecTag::fourcc(b"MJPG"),
                CodecTag::fourcc(b"AVRN"),
                CodecTag::fourcc(b"LJPG"),
                CodecTag::fourcc(b"JPGL"),
            ]),
    );
}

/// Register both JPEG-family containers:
///
/// - `jpeg` — still-image (`.jpg` / `.jpeg` / `.jpe` / `.jfif`), single
///   packet per file.
/// - `mjpeg-raw` — raw Motion-JPEG (`.mjpeg` / `.mjpg`), concatenated
///   SOI..EOI frames, one packet per frame, with seek support.
///
/// Must be called alongside [`register_codecs`] when wiring up a
/// pipeline that expects to read or write JPEG-family files.
pub fn register_containers(reg: &mut ContainerRegistry) {
    container::register(reg);
    mjpeg_container::register(reg);
}

/// Unified entry point: install every codec and container provided by
/// `oxideav-mjpeg` into a [`RuntimeContext`].
///
/// Also wired into [`oxideav_meta::register_all`] via the
/// [`oxideav_core::register!`] macro below.
pub fn register(ctx: &mut RuntimeContext) {
    register_codecs(&mut ctx.codecs);
    register_containers(&mut ctx.containers);
}

oxideav_core::register!("mjpeg", register);

// ---- Decoder trait impl ------------------------------------------------

pub fn make_decoder(params: &CodecParameters) -> Result<Box<dyn Decoder>> {
    let codec_id = params.codec_id.clone();
    Ok(Box::new(MjpegDecoder {
        codec_id,
        pending: None,
        eof: false,
    }))
}

struct MjpegDecoder {
    codec_id: CodecId,
    pending: Option<Packet>,
    eof: bool,
}

impl Decoder for MjpegDecoder {
    fn codec_id(&self) -> &CodecId {
        &self.codec_id
    }

    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
        if self.pending.is_some() {
            return Err(Error::other(
                "MJPEG decoder: receive_frame must be called before sending another packet",
            ));
        }
        self.pending = Some(packet.clone());
        Ok(())
    }

    fn receive_frame(&mut self) -> Result<Frame> {
        let Some(pkt) = self.pending.take() else {
            return if self.eof {
                Err(Error::Eof)
            } else {
                Err(Error::NeedMore)
            };
        };
        // With the `registry` feature on, `decode_jpeg` already
        // returns `oxideav_core::VideoFrame` (see the conditional
        // alias in `decoder.rs`), so the trait surface needs nothing
        // more than wrapping it in `Frame::Video`.
        let vf = decode_jpeg(&pkt.data, pkt.pts)?;
        Ok(Frame::Video(vf))
    }

    fn flush(&mut self) -> Result<()> {
        self.eof = true;
        Ok(())
    }
}

// ---- Encoder trait impl ------------------------------------------------

pub fn make_encoder(params: &CodecParameters) -> Result<Box<dyn Encoder>> {
    Ok(MjpegEncoder::from_params(params)?)
}

/// JPEG encoder. Emits one self-contained JPEG bitstream (baseline SOF0
/// or progressive SOF2) per video frame.
pub struct MjpegEncoder {
    output_params: CodecParameters,
    pub(crate) width: u32,
    pub(crate) height: u32,
    pub(crate) pix: MjpegPixelFormat,
    quality: u8,
    /// MCU-per-restart-interval count. 0 disables DRI / `RSTn` emission.
    /// Restart intervals are only honoured on the baseline (SOF0) path
    /// for now; progressive emission ignores this field.
    restart_interval: u16,
    /// When true, emit SOF2 + multi-scan (spectral selection only).
    progressive: bool,
    /// When true, take the lossless (SOF3) path for single-component
    /// grayscale input. Ignored for any non-grayscale `pix`.
    lossless: bool,
    /// Lossless predictor selector (T.81 Table H.1, 1..=7). Only
    /// consulted on the lossless path. Defaults to 1 (Ra / left).
    lossless_predictor: u8,
    /// Adobe APP14 colour-transform marker for 4-component
    /// (`MjpegPixelFormat::Cmyk`) input.
    ///
    /// * `None`     — no APP14 segment (plain "regular" CMYK).
    /// * `Some(0)`  — Adobe CMYK: encoder inverts every component on
    ///   the wire; decoder un-inverts on output.
    /// * `Some(2)`  — Adobe YCCK: the packed input is interpreted as
    ///   `[Y, Cb, Cr, K]` and only the K plane is inverted on the
    ///   wire; the decoder performs YCbCr→RGB→CMY and flips K to
    ///   recover CMYK.
    ///
    /// Defaults to `None`. Ignored for non-CMYK pixel formats.
    cmyk_adobe_transform: Option<u8>,
    time_base: TimeBase,
    pending: VecDeque<Packet>,
    eof: bool,
}

impl MjpegEncoder {
    /// Build a concrete `MjpegEncoder` from video codec parameters.
    /// Preferred over `make_encoder` when the caller wants to tweak
    /// encoder-specific knobs (e.g. progressive mode, restart interval)
    /// before feeding frames.
    pub fn from_params(params: &CodecParameters) -> Result<Box<Self>> {
        let width = params
            .width
            .ok_or_else(|| Error::invalid("MJPEG encoder: missing width"))?;
        let height = params
            .height
            .ok_or_else(|| Error::invalid("MJPEG encoder: missing height"))?;
        let pix_core = params.pixel_format.unwrap_or(PixelFormat::Yuv420P);
        let pix = pix_to_local(pix_core).ok_or_else(|| {
            Error::unsupported(format!(
                "MJPEG encoder: pixel format {pix_core:?} not supported"
            ))
        })?;
        match pix {
            MjpegPixelFormat::Yuv420P | MjpegPixelFormat::Yuv422P | MjpegPixelFormat::Yuv444P => {}
            // Grayscale takes the lossless (SOF3) path when requested via
            // `set_lossless(true)`. Accepting it here lets callers wire a
            // grayscale `CodecParameters` through the trait API directly
            // rather than dropping to the standalone `encode_lossless_*`
            // function.
            MjpegPixelFormat::Gray8
            | MjpegPixelFormat::Gray10Le
            | MjpegPixelFormat::Gray12Le
            | MjpegPixelFormat::Gray16Le => {}
            // 4-component CMYK / YCCK input takes the dedicated CMYK
            // encode path (baseline SOF0 by default, SOF2 when
            // `set_progressive(true)` is used). Adobe APP14 transform
            // selection comes from `set_adobe_transform`; default is
            // no APP14 (plain "regular" CMYK).
            MjpegPixelFormat::Cmyk => {}
            // Packed `Rgb24` input takes the baseline-SOF0 RGB encode
            // path: three components at IDs 'R'/'G'/'B', all H = V = 1,
            // all bound to one quant table + one DC/AC Huffman pair.
            // Adobe APP14 with transform = 0 is emitted so any
            // conformant decoder honouring the colour-transform flag
            // round-trips the samples as plain R/G/B. Progressive (SOF2)
            // emission of RGB stays a follow-up for now; the lossless
            // (SOF3) path is already available via `set_lossless(true)`
            // on the existing 3-component lossless encoder if a caller
            // needs bit-exactness.
            MjpegPixelFormat::Rgb24 => {}
            _ => {
                return Err(Error::unsupported(format!(
                    "MJPEG encoder: pixel format {pix_core:?} not supported"
                )))
            }
        }

        let mut output_params = params.clone();
        output_params.media_type = MediaType::Video;
        output_params.codec_id = CodecId::new(CODEC_ID_STR);
        output_params.width = Some(width);
        output_params.height = Some(height);
        output_params.pixel_format = Some(pix.into());

        Ok(Box::new(Self {
            output_params,
            width,
            height,
            pix,
            quality: DEFAULT_QUALITY,
            restart_interval: 0,
            progressive: false,
            // Lossless mode is opt-in even for grayscale. `Gray8` input
            // takes the baseline (SOF0) single-component DCT path by
            // default; flip `set_lossless(true)` to switch to the
            // bit-exact SOF3 path instead.
            lossless: false,
            lossless_predictor: 1,
            cmyk_adobe_transform: None,
            time_base: params
                .frame_rate
                .map_or(TimeBase::new(1, 90_000), |r| TimeBase::new(r.den, r.num)),
            pending: VecDeque::new(),
            eof: false,
        }))
    }

    /// Set the restart interval in MCUs (JPEG DRI field). `0` disables
    /// restart marker emission (matches the default).
    ///
    /// Values are clamped to `u16::MAX` since the JPEG DRI field is a
    /// 16-bit big-endian unsigned integer.
    ///
    /// Currently only applied on the baseline encode path; enabling
    /// progressive output via [`Self::set_progressive`] suppresses
    /// restart-marker emission.
    pub fn set_restart_interval(&mut self, mcus: u32) {
        self.restart_interval = mcus.min(u16::MAX as u32) as u16;
    }

    /// Current restart interval (MCUs between `RSTn` markers; 0 = off).
    pub fn restart_interval(&self) -> u16 {
        self.restart_interval
    }

    /// Enable or disable progressive (SOF2) JPEG emission. When enabled
    /// the encoder produces one DC-first scan plus two per-component AC
    /// band scans (Ss=1..5 then Ss=6..63). See module-level docs.
    pub fn set_progressive(&mut self, on: bool) {
        self.progressive = on;
    }

    /// True when progressive emission is enabled.
    pub fn progressive(&self) -> bool {
        self.progressive
    }

    /// Enable or disable lossless (SOF3) emission. Only honoured when
    /// the input pixel format is `Gray8` / `Gray10Le` / `Gray12Le` /
    /// `Gray16Le`; ignored for YUV inputs (which always take the
    /// baseline / progressive DCT path).
    ///
    /// For `Gray8` input the flag is a real toggle: `false` takes the
    /// baseline (SOF0) single-component DCT path (lossy, scaled by
    /// `quality`), `true` takes the bit-exact lossless (SOF3) path.
    /// The three higher-precision grayscale variants
    /// (`Gray10Le` / `Gray12Le` / `Gray16Le`) require `set_lossless(true)`
    /// — the DCT path is 8-bit by spec.
    ///
    /// The lossless path is bit-exact and reuses the predictor selected
    /// by [`Self::set_lossless_predictor`] (default 1 = Ra / left). It
    /// ignores [`Self::set_progressive`] and [`Self::set_restart_interval`].
    pub fn set_lossless(&mut self, on: bool) {
        self.lossless = on;
    }

    /// True when lossless (SOF3) emission is enabled.
    pub fn lossless(&self) -> bool {
        self.lossless
    }

    /// Set the lossless predictor selector (T.81 Table H.1, 1..=7).
    /// Values outside `1..=7` are silently clamped to 1 so the setter
    /// can't fail; the value is consulted only when [`Self::set_lossless`]
    /// has been enabled and the input is grayscale.
    pub fn set_lossless_predictor(&mut self, predictor: u8) {
        self.lossless_predictor = if (1..=7).contains(&predictor) {
            predictor
        } else {
            1
        };
    }

    /// Current lossless predictor selector.
    pub fn lossless_predictor(&self) -> u8 {
        self.lossless_predictor
    }

    /// Configure the Adobe APP14 colour-transform marker for 4-component
    /// (`MjpegPixelFormat::Cmyk`) input. Only honoured when the input
    /// pixel format is `Cmyk`; ignored for every other format.
    ///
    /// * `None`     — emit no APP14 segment (the decoder treats the
    ///   result as plain "regular" CMYK).
    /// * `Some(0)`  — Adobe CMYK: every component is inverted on the
    ///   wire; the decoder un-inverts on output.
    /// * `Some(2)`  — Adobe YCCK: the packed input is interpreted as
    ///   `[Y, Cb, Cr, K]`, and only the K plane is inverted on the
    ///   wire. The decoder converts YCbCr→RGB→CMY (BT.601, full-range)
    ///   and flips K to recover CMYK.
    ///
    /// Any other `Some(t)` value is rejected with `Error::InvalidData`
    /// (only `0` and `2` round-trip through this crate's decoder).
    pub fn set_adobe_transform(&mut self, transform: Option<u8>) -> Result<()> {
        if let Some(t) = transform {
            if t != 0 && t != 2 {
                return Err(Error::invalid(
                    "MJPEG encoder: Adobe APP14 transform must be 0 (CMYK) or 2 (YCCK)",
                ));
            }
        }
        self.cmyk_adobe_transform = transform;
        Ok(())
    }

    /// Current Adobe APP14 colour-transform marker selection.
    pub fn adobe_transform(&self) -> Option<u8> {
        self.cmyk_adobe_transform
    }
}

impl Encoder for MjpegEncoder {
    fn codec_id(&self) -> &CodecId {
        &self.output_params.codec_id
    }

    fn output_params(&self) -> &CodecParameters {
        &self.output_params
    }

    fn send_frame(&mut self, frame: &Frame) -> Result<()> {
        match frame {
            Frame::Video(v) => {
                // With the `registry` feature on, the public
                // `encode_jpeg_*` functions already accept
                // `&oxideav_core::VideoFrame` directly (see the
                // conditional alias in `encoder.rs`), so we can pass
                // the frame through without local-type bounce.
                let pix = self.pix.into();
                let data = match (self.pix, self.lossless) {
                    // Grayscale + lossless → SOF3 path. Precision is
                    // implied by the pixel format and we read row bytes
                    // straight from plane 0.
                    (MjpegPixelFormat::Gray8, true)
                    | (MjpegPixelFormat::Gray10Le, true)
                    | (MjpegPixelFormat::Gray12Le, true)
                    | (MjpegPixelFormat::Gray16Le, true) => {
                        if v.planes.is_empty() {
                            return Err(Error::invalid(
                                "MJPEG encoder: grayscale frame missing plane 0",
                            ));
                        }
                        let plane = &v.planes[0];
                        let precision: u8 = match self.pix {
                            MjpegPixelFormat::Gray8 => 8,
                            MjpegPixelFormat::Gray10Le => 10,
                            MjpegPixelFormat::Gray12Le => 12,
                            MjpegPixelFormat::Gray16Le => 16,
                            _ => unreachable!(),
                        };
                        encode_lossless_jpeg_grayscale(
                            self.width,
                            self.height,
                            &plane.data,
                            plane.stride,
                            precision,
                            self.lossless_predictor,
                        )?
                    }
                    // 8-bit grayscale without lossless mode takes the
                    // baseline (SOF0) or progressive (SOF2) single-
                    // component DCT path. The baseline bitstream layout
                    // mirrors `encode_jpeg` reduced to one luma component
                    // (one DQT + DC/AC luma Huffman tables + a one-entry
                    // SOS); flipping `set_progressive(true)` takes the
                    // matching SOF2 path (DC + AC-low + AC-high scans,
                    // spectral-selection decomposition). Either way any
                    // conformant decoder produces a `Gray8` frame
                    // round-tripping with the usual DCT-quantise
                    // distortion floor. `restart_interval` is ignored
                    // on the progressive path because the 3-component
                    // progressive encoder doesn't expose DRI emission
                    // either — kept consistent so the flag has the same
                    // meaning across every progressive variant.
                    (MjpegPixelFormat::Gray8, false) => {
                        if v.planes.is_empty() {
                            return Err(Error::invalid(
                                "MJPEG encoder: grayscale frame missing plane 0",
                            ));
                        }
                        let plane = &v.planes[0];
                        if self.progressive {
                            encode_jpeg_progressive_grayscale(
                                self.width,
                                self.height,
                                &plane.data,
                                plane.stride,
                                self.quality,
                            )?
                        } else {
                            encode_jpeg_grayscale_with_opts(
                                self.width,
                                self.height,
                                &plane.data,
                                plane.stride,
                                self.quality,
                                self.restart_interval,
                            )?
                        }
                    }
                    // Higher-precision grayscale (10 / 12 / 16-bit)
                    // still requires `set_lossless(true)` — the
                    // baseline DCT path is 8-bit by spec. Surface a
                    // clear error rather than silently downgrading.
                    (
                        MjpegPixelFormat::Gray10Le
                        | MjpegPixelFormat::Gray12Le
                        | MjpegPixelFormat::Gray16Le,
                        false,
                    ) => {
                        return Err(Error::unsupported(
                            "MJPEG encoder: high-bit-depth grayscale input requires set_lossless(true)",
                        ));
                    }
                    // Packed `Rgb24` input takes the baseline-SOF0 RGB
                    // path. The single plane is laid out as
                    // `[R, G, B]` at 3 bytes per pixel, matching the
                    // decoder's `Rgb24` output shape. Progressive
                    // (SOF2) RGB is not yet wired in here — flipping
                    // `set_progressive(true)` with `Rgb24` input still
                    // takes the baseline path.
                    (MjpegPixelFormat::Rgb24, _) => {
                        if v.planes.is_empty() {
                            return Err(Error::invalid(
                                "MJPEG encoder: RGB24 frame missing plane 0",
                            ));
                        }
                        let plane = &v.planes[0];
                        let min_stride = (self.width as usize) * 3;
                        if plane.stride < min_stride {
                            return Err(Error::invalid(
                                "MJPEG encoder: RGB24 plane stride must be at least width * 3",
                            ));
                        }
                        encode_jpeg_rgb24_with_opts(
                            self.width,
                            self.height,
                            &plane.data,
                            plane.stride,
                            self.quality,
                            self.restart_interval,
                        )?
                    }
                    // 4-component CMYK / YCCK input takes the dedicated
                    // CMYK encode path. The single packed plane is laid
                    // out as `[C, M, Y, K]` (or `[Y, Cb, Cr, K]` for
                    // `set_adobe_transform(Some(2))`) at 4 bytes per
                    // pixel, matching the decoder's output shape.
                    (MjpegPixelFormat::Cmyk, _) => {
                        if v.planes.is_empty() {
                            return Err(Error::invalid(
                                "MJPEG encoder: CMYK frame missing plane 0",
                            ));
                        }
                        let plane = &v.planes[0];
                        let min_stride = (self.width as usize) * 4;
                        if plane.stride < min_stride {
                            return Err(Error::invalid(
                                "MJPEG encoder: CMYK plane stride must be at least width * 4",
                            ));
                        }
                        if self.progressive {
                            encode_jpeg_cmyk_progressive(
                                self.width,
                                self.height,
                                &plane.data,
                                plane.stride,
                                self.quality,
                                self.cmyk_adobe_transform,
                            )?
                        } else {
                            encode_jpeg_cmyk(
                                self.width,
                                self.height,
                                &plane.data,
                                plane.stride,
                                self.quality,
                                self.cmyk_adobe_transform,
                            )?
                        }
                    }
                    // YUV inputs take the baseline / progressive DCT path.
                    _ => {
                        if self.progressive {
                            encode_jpeg_progressive(v, self.width, self.height, pix, self.quality)?
                        } else {
                            encode_jpeg_with_opts(
                                v,
                                self.width,
                                self.height,
                                pix,
                                self.quality,
                                self.restart_interval,
                            )?
                        }
                    }
                };
                let mut pkt = Packet::new(0, self.time_base, data);
                pkt.pts = v.pts;
                pkt.dts = v.pts;
                pkt.flags.keyframe = true;
                self.pending.push_back(pkt);
                Ok(())
            }
            _ => Err(Error::invalid("MJPEG encoder: video frames only")),
        }
    }

    fn receive_packet(&mut self) -> Result<Packet> {
        self.pending.pop_front().ok_or(Error::NeedMore)
    }

    fn flush(&mut self) -> Result<()> {
        self.eof = true;
        Ok(())
    }
}