Skip to main content

zencodec_testkit/
reference.rs

1//! A faithful in-memory reference codec.
2//!
3//! It round-trips both pixels *and* metadata (ICC / EXIF / XMP / CICP /
4//! orientation), so the conformance checks in this crate can be validated
5//! against a known-correct implementation. It also doubles as a worked example
6//! of implementing the `zencodec` traits end to end.
7//!
8//! Wire format (little-endian), one metadata header shared across frames:
9//!
10//! ```text
11//! "ZCR1"            magic (4 bytes)
12//! width   : u32
13//! height  : u32
14//! frames  : u32     frame count (>= 1)
15//! bpp     : u8      3 = RGB8, 4 = RGBA8, 6 = RGB16, 8 = RGBA16
16//! orient  : u8      EXIF orientation 1..=8
17//! flags   : u8      bit0 icc, bit1 exif, bit2 xmp, bit3 cicp, bit4 clli, bit5 mdcv, bit6 gain map
18//! [icc ]  : u32 len + bytes
19//! [exif]  : u32 len + bytes
20//! [xmp ]  : u32 len + bytes
21//! [cicp]  : cp:u8 tc:u8 mc:u8 range:u8
22//! [clli]  : max_cll:u16 max_fall:u16
23//! [mdcv]  : 10 × f32 (rx ry gx gy bx by wx wy max_lum min_lum)
24//! [gmap]  : w:u32 h:u32 channels:u8 (1 or 3) alt_cicp:u8 [cp tc mc range]
25//!           iso_len:u32 + ISO 21496-1 bytes (bare `JxlJhgm` payload)
26//!           pixels (w*h*channels, 8-bit)
27//! per frame: duration:u32 + pixels (width*height*bpp)
28//! ```
29//!
30//! The decoded descriptor is stamped from the CICP transfer/primaries when
31//! present (a PQ file decodes to a PQ-labelled buffer, never an sRGB-labelled
32//! one) — the "descriptor describes the current pixels" rule.
33//!
34//! The gain map is the worked example of the encode-side contract
35//! ([`EncodeJob::with_gain_map_pixels`] / [`with_gain_map_encoded`](EncodeJob::with_gain_map_encoded)):
36//! the job validates and stores it, the wire carries the pixels and the ISO
37//! 21496-1 metadata, and the decoder surfaces it as a [`DecodedGainMap`] only
38//! when asked ([`GainMapRender::Components`], or `ReconstructHdr` — which the
39//! reference cannot honour, so it falls back to surfacing the components, as
40//! the `reconstructs_hdr = false` contract says).
41
42use std::borrow::Cow;
43use std::sync::Arc;
44
45use enough::StopReason;
46use whereat::At;
47use zencodec::decode::{
48    AnimationFrameDecoder, BatchMeta, Decode, DecodeCapabilities, DecodeJob, DecodeOutput,
49    DecodeRowSink, DecoderConfig, Event, IncrementalDecode, Input, InputRequest, MetadataUpdate,
50    OutputInfo, Progress, SinkError, SourceColor, StepError, StreamingDecode,
51};
52use zencodec::encode::{
53    AnimationFrameEncoder, AnimationOutputTiming, EncodeCapabilities, EncodeJob, EncodeOutput,
54    EncodePolicy, Encoder, EncoderConfig, OutputBuffer, OutputTiming,
55};
56use zencodec::gainmap::{
57    DecodedGainMap, GainMapInfo, GainMapPresence, GainMapRender, GainMapSource, Iso21496Format,
58    parse_iso21496_fmt, serialize_iso21496_fmt,
59};
60use zencodec::source::{AccessPattern, DynReadAt};
61use zencodec::{
62    AnimationFrame, CategorizedError, Cicp, CodecError, CodecIoKind, ContentLightLevel,
63    ErrorCategory, ImageFormat, ImageFormatDefinition, ImageInfo, ImageSequence, MasteringDisplay,
64    Metadata, Orientation, ResourceLimits, SharedPulse, UnsupportedOperation,
65};
66use zenpixels::{
67    ColorContext, ColorModel, PixelBuffer, PixelDescriptor, PixelSlice, TransferFunction,
68};
69
70// ===========================================================================
71// Error
72// ===========================================================================
73
74/// Error type for the reference codec.
75#[derive(Debug)]
76pub enum RefError {
77    /// An operation the reference codec does not implement.
78    Unsupported(UnsupportedOperation),
79    /// Malformed wire data.
80    Invalid(String),
81    /// Cooperative cancellation fired. The [`CategorizedError`] impl maps it
82    /// (and a timeout) to the right [`ErrorCategory`] without naming `RefError`.
83    Cancelled(StopReason),
84    /// A resource limit was exceeded.
85    Limit(zencodec::LimitExceeded),
86    /// A decode sink reported an error.
87    Sink(SinkError),
88}
89
90impl std::fmt::Display for RefError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Self::Unsupported(e) => write!(f, "reference: unsupported: {e}"),
94            Self::Invalid(s) => write!(f, "reference: invalid: {s}"),
95            Self::Cancelled(r) => write!(f, "reference: cancelled: {r}"),
96            Self::Limit(e) => write!(f, "reference: limit: {e}"),
97            Self::Sink(e) => write!(f, "reference: sink: {e}"),
98        }
99    }
100}
101
102impl std::error::Error for RefError {
103    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
104        match self {
105            Self::Unsupported(e) => Some(e),
106            Self::Limit(e) => Some(e),
107            Self::Sink(e) => Some(e.as_ref()),
108            // StopReason is not an Error, so cancellation isn't a source link.
109            Self::Cancelled(_) | Self::Invalid(_) => None,
110        }
111    }
112}
113
114impl From<UnsupportedOperation> for RefError {
115    fn from(e: UnsupportedOperation) -> Self {
116        Self::Unsupported(e)
117    }
118}
119impl From<StopReason> for RefError {
120    fn from(r: StopReason) -> Self {
121        Self::Cancelled(r)
122    }
123}
124
125/// Opt into the codec-agnostic error taxonomy: map each variant to a category,
126/// delegating to the zencodec cause types where they apply.
127impl CategorizedError for RefError {
128    fn codec_name(&self) -> Option<&'static str> {
129        Some(MINIMAL_CODEC_NAME)
130    }
131    fn category(&self) -> ErrorCategory {
132        match self {
133            Self::Unsupported(e) => e.category(),
134            Self::Invalid(_) => ErrorCategory::Image(zencodec::ImageError::Malformed),
135            Self::Cancelled(r) => r.category(),
136            Self::Limit(e) => e.category(),
137            Self::Sink(_) => ErrorCategory::Io(CodecIoKind::opaque()),
138        }
139    }
140}
141impl From<zencodec::LimitExceeded> for RefError {
142    fn from(e: zencodec::LimitExceeded) -> Self {
143        Self::Limit(e)
144    }
145}
146
147/// The one-impl bridge a codec adds to return the shared envelope as
148/// `At<CodecError>` (the internal `minimal` codec does this; the
149/// [`reference`](crate::reference) keeps the simpler `type Error = RefError`).
150///
151/// `.start_at()` begins the location trace; `CodecError::of` then takes that
152/// `At<RefError>` and maps it to `At<CodecError>`, keeping the trace on the
153/// outside and reading the category *and* codec name
154/// ([`RefError::codec_name`](CategorizedError::codec_name)) from the value. With
155/// this in place, `?` on any `Result<_, RefError>` auto-wraps into
156/// `At<CodecError>`, so existing fallible internals need no rewrite.
157impl From<RefError> for At<CodecError> {
158    #[track_caller]
159    fn from(e: RefError) -> Self {
160        use whereat::ErrorAtExt;
161        CodecError::of(e.start_at())
162    }
163}
164
165/// Codec name the envelope reports (via [`RefError`]'s `codec_name`). The only
166/// consumer of the bridge is the internal `minimal` codec (the
167/// `reference` returns `RefError` directly), so it names that codec.
168pub(crate) const MINIMAL_CODEC_NAME: &str = "zencodec-testkit/minimal";
169
170// ===========================================================================
171// Wire format
172// ===========================================================================
173
174const MAGIC: &[u8; 4] = b"ZCR1";
175
176pub(crate) fn descriptor_for_bpp(bpp: u8) -> Result<PixelDescriptor, RefError> {
177    match bpp {
178        3 => Ok(PixelDescriptor::RGB8_SRGB),
179        4 => Ok(PixelDescriptor::RGBA8_SRGB),
180        6 => Ok(PixelDescriptor::RGB16_SRGB),
181        8 => Ok(PixelDescriptor::RGBA16_SRGB),
182        b => Err(RefError::Invalid(format!("unsupported bpp {b}"))),
183    }
184}
185
186/// The output descriptor for a parsed header: the layout from `bpp`, with the
187/// transfer function and primaries stamped from the file's CICP when it has
188/// one. This is what makes a PQ/HLG file decode to a PQ/HLG-labelled buffer.
189pub(crate) fn descriptor_for_header(h: &Header) -> Result<PixelDescriptor, RefError> {
190    let base = descriptor_for_bpp(h.bpp)?;
191    Ok(match h.meta.cicp {
192        Some(c) => {
193            let with_transfer = match TransferFunction::from_cicp(c.transfer_characteristics) {
194                Some(t) => base.with_transfer(t),
195                None => base,
196            };
197            with_transfer.with_primaries(c.color_primaries_enum())
198        }
199        None => base,
200    })
201}
202
203/// A gain map as the wire carries it: the ISO 21496-1 description plus the
204/// 8-bit pixel bytes (`width * height * channels`, tightly packed).
205#[derive(Clone, Debug)]
206pub(crate) struct GainMapWire {
207    pub(crate) info: GainMapInfo,
208    pub(crate) pixels: Vec<u8>,
209}
210
211impl GainMapWire {
212    /// The reference stores 8-bit gain maps with 1 (luminance) or 3
213    /// (per-channel) samples per pixel. Geometry and channel count come from
214    /// the pixels — the authoritative source on the decoded-pixel path — and
215    /// are stamped back into the metadata so the two can never disagree.
216    fn from_decoded(gm: DecodedGainMap) -> Result<Self, RefError> {
217        let ps = gm.pixels.as_slice();
218        let channels = gm.channels();
219        let bytes_per_sample = ps.descriptor().bytes_per_pixel() / channels.max(1) as usize;
220        if !matches!(channels, 1 | 3) || bytes_per_sample != 1 {
221            return Err(RefError::Invalid(format!(
222                "gain map must be 8-bit with 1 or 3 channels, got {:?}",
223                ps.descriptor()
224            )));
225        }
226        let mut pixels =
227            Vec::with_capacity(ps.width() as usize * ps.rows() as usize * channels as usize);
228        let rb = ps.width() as usize * channels as usize;
229        for y in 0..ps.rows() {
230            pixels.extend_from_slice(&ps.row(y)[..rb]);
231        }
232        let mut info = gm.metadata;
233        info.width = ps.width();
234        info.height = ps.rows();
235        info.channels = channels;
236        info.bit_depth = 8;
237        Ok(Self { info, pixels })
238    }
239
240    fn descriptor(&self) -> PixelDescriptor {
241        // Gain, not colour: the transfer is deliberately `Unknown`.
242        if self.info.channels == 1 {
243            PixelDescriptor::GRAY8
244        } else {
245            PixelDescriptor::RGB8
246        }
247    }
248
249    fn to_decoded(&self) -> Result<DecodedGainMap, RefError> {
250        let buf = PixelBuffer::from_vec(
251            self.pixels.clone(),
252            self.info.width,
253            self.info.height,
254            self.descriptor(),
255        )
256        .map_err(|e| RefError::Invalid(format!("gain map buffer: {e}")))?;
257        Ok(DecodedGainMap::new(buf, self.info.clone()))
258    }
259}
260
261/// Parsed header plus the byte offset where frame data starts.
262pub(crate) struct Header {
263    pub(crate) width: u32,
264    pub(crate) height: u32,
265    pub(crate) frame_count: u32,
266    pub(crate) bpp: u8,
267    pub(crate) meta: Metadata,
268    pub(crate) gain_map: Option<GainMapWire>,
269    pub(crate) frames_offset: usize,
270}
271
272fn push_u32(out: &mut Vec<u8>, v: u32) {
273    out.extend_from_slice(&v.to_le_bytes());
274}
275
276fn read_u32(data: &[u8], at: usize) -> Result<u32, RefError> {
277    data.get(at..at + 4)
278        .map(|s| u32::from_le_bytes(s.try_into().unwrap()))
279        .ok_or_else(|| RefError::Invalid("truncated u32".into()))
280}
281
282fn push_cicp(out: &mut Vec<u8>, c: &Cicp) {
283    out.push(c.color_primaries);
284    out.push(c.transfer_characteristics);
285    out.push(c.matrix_coefficients);
286    out.push(c.full_range as u8);
287}
288
289fn write_header(
290    out: &mut Vec<u8>,
291    w: u32,
292    h: u32,
293    frames: u32,
294    bpp: u8,
295    meta: &Metadata,
296    gain_map: Option<&GainMapWire>,
297) {
298    out.extend_from_slice(MAGIC);
299    push_u32(out, w);
300    push_u32(out, h);
301    push_u32(out, frames);
302    out.push(bpp);
303    out.push(meta.orientation.to_exif());
304
305    let mut flags = 0u8;
306    if meta.icc_profile.is_some() {
307        flags |= 1;
308    }
309    if meta.exif.is_some() {
310        flags |= 2;
311    }
312    if meta.xmp.is_some() {
313        flags |= 4;
314    }
315    if meta.cicp.is_some() {
316        flags |= 8;
317    }
318    if meta.content_light_level.is_some() {
319        flags |= 16;
320    }
321    if meta.mastering_display.is_some() {
322        flags |= 32;
323    }
324    if gain_map.is_some() {
325        flags |= 64;
326    }
327    out.push(flags);
328
329    if let Some(icc) = &meta.icc_profile {
330        push_u32(out, icc.len() as u32);
331        out.extend_from_slice(icc);
332    }
333    if let Some(exif) = &meta.exif {
334        push_u32(out, exif.len() as u32);
335        out.extend_from_slice(exif);
336    }
337    if let Some(xmp) = &meta.xmp {
338        push_u32(out, xmp.len() as u32);
339        out.extend_from_slice(xmp);
340    }
341    if let Some(c) = &meta.cicp {
342        push_cicp(out, c);
343    }
344    if let Some(cll) = &meta.content_light_level {
345        out.extend_from_slice(&cll.max_content_light_level.to_le_bytes());
346        out.extend_from_slice(&cll.max_frame_average_light_level.to_le_bytes());
347    }
348    if let Some(md) = &meta.mastering_display {
349        for xy in md.primaries_xy {
350            out.extend_from_slice(&xy[0].to_le_bytes());
351            out.extend_from_slice(&xy[1].to_le_bytes());
352        }
353        out.extend_from_slice(&md.white_point_xy[0].to_le_bytes());
354        out.extend_from_slice(&md.white_point_xy[1].to_le_bytes());
355        out.extend_from_slice(&md.max_luminance.to_le_bytes());
356        out.extend_from_slice(&md.min_luminance.to_le_bytes());
357    }
358    if let Some(gm) = gain_map {
359        push_u32(out, gm.info.width);
360        push_u32(out, gm.info.height);
361        out.push(gm.info.channels);
362        match &gm.info.alternate_cicp {
363            Some(c) => {
364                out.push(1);
365                push_cicp(out, c);
366            }
367            None => out.push(0),
368        }
369        let iso = serialize_iso21496_fmt(&gm.info.params, Iso21496Format::JxlJhgm);
370        push_u32(out, iso.len() as u32);
371        out.extend_from_slice(&iso);
372        out.extend_from_slice(&gm.pixels);
373    }
374}
375
376fn read_cicp(data: &[u8], at: usize) -> Result<Cicp, RefError> {
377    let c = data
378        .get(at..at + 4)
379        .ok_or_else(|| RefError::Invalid("truncated cicp".into()))?;
380    Ok(Cicp::new(c[0], c[1], c[2], c[3] != 0))
381}
382
383fn read_f32(data: &[u8], at: usize) -> Result<f32, RefError> {
384    data.get(at..at + 4)
385        .map(|s| f32::from_le_bytes(s.try_into().unwrap()))
386        .ok_or_else(|| RefError::Invalid("truncated f32".into()))
387}
388
389pub(crate) fn parse_header(data: &[u8]) -> Result<Header, RefError> {
390    // Fixed header is 19 bytes: magic(4) + w(4) + h(4) + frames(4) + bpp(1) +
391    // orient(1) + flags(1); data[18] (flags) is read below.
392    if data.len() < 19 || &data[..4] != MAGIC {
393        return Err(RefError::Invalid("bad magic / short header".into()));
394    }
395    let width = read_u32(data, 4)?;
396    let height = read_u32(data, 8)?;
397    let frame_count = read_u32(data, 12)?;
398    let bpp = data[16];
399    let orient = data[17];
400    let flags = data[18];
401    descriptor_for_bpp(bpp)?; // validate
402    if width == 0 || height == 0 || frame_count == 0 {
403        return Err(RefError::Invalid("zero dimension / frame count".into()));
404    }
405
406    let mut meta = Metadata::none()
407        .with_orientation(Orientation::from_exif(orient).unwrap_or(Orientation::Identity));
408    let mut at = 19usize;
409
410    let read_blob = |at: &mut usize| -> Result<Vec<u8>, RefError> {
411        let len = read_u32(data, *at)? as usize;
412        *at += 4;
413        let bytes = data
414            .get(*at..*at + len)
415            .ok_or_else(|| RefError::Invalid("truncated blob".into()))?
416            .to_vec();
417        *at += len;
418        Ok(bytes)
419    };
420
421    if flags & 1 != 0 {
422        meta = meta.with_icc(read_blob(&mut at)?);
423    }
424    if flags & 2 != 0 {
425        meta = meta.with_exif(read_blob(&mut at)?);
426    }
427    if flags & 4 != 0 {
428        meta = meta.with_xmp(read_blob(&mut at)?);
429    }
430    if flags & 8 != 0 {
431        meta = meta.with_cicp(read_cicp(data, at)?);
432        at += 4;
433    }
434    if flags & 16 != 0 {
435        let c = data
436            .get(at..at + 4)
437            .ok_or_else(|| RefError::Invalid("truncated clli".into()))?;
438        meta = meta.with_content_light_level(ContentLightLevel::new(
439            u16::from_le_bytes([c[0], c[1]]),
440            u16::from_le_bytes([c[2], c[3]]),
441        ));
442        at += 4;
443    }
444    if flags & 32 != 0 {
445        let mut f = [0f32; 10];
446        for (i, v) in f.iter_mut().enumerate() {
447            *v = read_f32(data, at + i * 4)?;
448        }
449        at += 40;
450        meta = meta.with_mastering_display(MasteringDisplay::new(
451            [[f[0], f[1]], [f[2], f[3]], [f[4], f[5]]],
452            [f[6], f[7]],
453            f[8],
454            f[9],
455        ));
456    }
457    let gain_map = if flags & 64 != 0 {
458        let gw = read_u32(data, at)?;
459        let gh = read_u32(data, at + 4)?;
460        let channels = *data
461            .get(at + 8)
462            .ok_or_else(|| RefError::Invalid("truncated gain map".into()))?;
463        let has_alt = *data
464            .get(at + 9)
465            .ok_or_else(|| RefError::Invalid("truncated gain map".into()))?;
466        at += 10;
467        let alternate_cicp = if has_alt != 0 {
468            let c = read_cicp(data, at)?;
469            at += 4;
470            Some(c)
471        } else {
472            None
473        };
474        let iso = read_blob(&mut at)?;
475        let params = parse_iso21496_fmt(&iso, Iso21496Format::JxlJhgm)
476            .map_err(|e| RefError::Invalid(format!("gain map metadata: {e}")))?;
477        if !matches!(channels, 1 | 3) || gw == 0 || gh == 0 {
478            return Err(RefError::Invalid("bad gain map geometry".into()));
479        }
480        let len = gw as usize * gh as usize * channels as usize;
481        let pixels = data
482            .get(at..at + len)
483            .ok_or_else(|| RefError::Invalid("truncated gain map pixels".into()))?
484            .to_vec();
485        at += len;
486        let mut info = GainMapInfo::new(params, gw, gh, channels);
487        if let Some(c) = alternate_cicp {
488            info = info.with_alternate_cicp(c);
489        }
490        Some(GainMapWire { info, pixels })
491    } else {
492        None
493    };
494
495    Ok(Header {
496        width,
497        height,
498        frame_count,
499        bpp,
500        meta,
501        gain_map,
502        frames_offset: at,
503    })
504}
505
506/// Byte offset of frame `i`'s pixel data (skipping its leading duration u32).
507pub(crate) fn frame_pixels_offset(h: &Header, i: u32) -> usize {
508    let frame_bytes = 4 + h.width as usize * h.height as usize * h.bpp as usize;
509    h.frames_offset + i as usize * frame_bytes + 4
510}
511
512pub(crate) fn frame_pixel_len(h: &Header) -> usize {
513    h.width as usize * h.height as usize * h.bpp as usize
514}
515
516/// Structural inventory of a `ZCR1` stream: the worked example of
517/// [`DecodeJob::inventory`].
518///
519/// Walks the wire format with checked arithmetic and no pixel allocation, and
520/// never fails on malformed input: whatever cannot be read becomes one
521/// [`Malformed`](zencodec::inventory::Disposition::Malformed) part, and bytes
522/// after the last frame are [`Trailing`](zencodec::inventory::Disposition::Trailing)
523/// because the decoder never reads them. The only error is the part cap.
524pub(crate) fn zcr_inventory(
525    data: &[u8],
526    format: ImageFormat,
527) -> Result<zencodec::inventory::Inventory, RefError> {
528    use zencodec::inventory::{
529        Disposition, Inventory, InventoryError, MetadataKind, Part, PartKind, PartTag,
530    };
531    let len = data.len() as u64;
532    let mut inv = Inventory::new(format, len);
533    let cap = |e: InventoryError| RefError::Invalid(format!("inventory: {e}"));
534    let malformed_from = |inv: &mut Inventory, at: u64, why: &str| -> Result<(), RefError> {
535        if at < len {
536            inv.push(
537                None,
538                Part::new(
539                    PartKind::Gap,
540                    PartTag::None,
541                    at..len,
542                    Disposition::Malformed,
543                )
544                .with_detail(why.to_owned()),
545            )
546            .map_err(cap)?;
547        }
548        Ok(())
549    };
550
551    let header_ok = data.len() >= 19
552        && &data[..4] == MAGIC
553        && descriptor_for_bpp(data[16]).is_ok()
554        && read_u32(data, 4).is_ok_and(|w| w > 0)
555        && read_u32(data, 8).is_ok_and(|h| h > 0)
556        && read_u32(data, 12).is_ok_and(|f| f > 0);
557    if !header_ok {
558        malformed_from(&mut inv, 0, "not a ZCR1 header")?;
559        return Ok(inv);
560    }
561    let (w, h, frames, bpp, flags) = (
562        u64::from(read_u32(data, 4)?),
563        u64::from(read_u32(data, 8)?),
564        u64::from(read_u32(data, 12)?),
565        u64::from(data[16]),
566        data[18],
567    );
568    inv.push(
569        None,
570        Part::new(
571            PartKind::Header,
572            PartTag::None,
573            0..19,
574            Disposition::Structure,
575        )
576        .with_label("ZCR1")
577        .with_detail("magic, geometry, frame count, layout, orientation, flags"),
578    )
579    .map_err(cap)?;
580
581    let mut at: u64 = 19;
582    // A length-prefixed blob: `u32` length, then the bytes.
583    let blob_end = |at: u64| -> Option<u64> {
584        let n = read_u32(data, usize::try_from(at).ok()?).ok()?;
585        let end = at.checked_add(4)?.checked_add(u64::from(n))?;
586        (end <= len).then_some(end)
587    };
588    let fixed_end = |at: u64, n: u64| -> Option<u64> {
589        let end = at.checked_add(n)?;
590        (end <= len).then_some(end)
591    };
592    let sections: [(u8, &str, MetadataKind); 6] = [
593        (1, "icc", MetadataKind::Icc),
594        (2, "exif", MetadataKind::Exif),
595        (4, "xmp", MetadataKind::Xmp),
596        (8, "cicp", MetadataKind::Cicp),
597        (16, "clli", MetadataKind::HdrStatic),
598        (32, "mdcv", MetadataKind::HdrStatic),
599    ];
600    for (bit, name, kind) in sections {
601        if flags & bit == 0 {
602            continue;
603        }
604        let end = match bit {
605            1 | 2 | 4 => blob_end(at),
606            8 | 16 => fixed_end(at, 4),
607            _ => fixed_end(at, 40),
608        };
609        let Some(end) = end else {
610            malformed_from(&mut inv, at, &format!("truncated {name} section"))?;
611            return Ok(inv);
612        };
613        inv.push(
614            None,
615            Part::new(
616                PartKind::Chunk,
617                PartTag::Name(name.into()),
618                at..end,
619                Disposition::Metadata(kind),
620            ),
621        )
622        .map_err(cap)?;
623        at = end;
624    }
625    if flags & 64 != 0 {
626        // w:u32 h:u32 channels:u8 has_alt:u8 [cicp:4] iso_len:u32 iso pixels
627        let gm = (|| -> Option<u64> {
628            let at_us = usize::try_from(at).ok()?;
629            let gw = u64::from(read_u32(data, at_us).ok()?);
630            let gh = u64::from(read_u32(data, at_us.checked_add(4)?).ok()?);
631            let channels = u64::from(*data.get(at_us.checked_add(8)?)?);
632            let has_alt = *data.get(at_us.checked_add(9)?)? != 0;
633            let iso_at = at + 10 + if has_alt { 4 } else { 0 };
634            let iso_end = blob_end(iso_at)?;
635            fixed_end(iso_end, gw.checked_mul(gh)?.checked_mul(channels)?)
636        })();
637        let Some(end) = gm else {
638            malformed_from(&mut inv, at, "truncated gain-map section")?;
639            return Ok(inv);
640        };
641        inv.push(
642            None,
643            Part::new(
644                PartKind::Chunk,
645                PartTag::Name("gain-map".into()),
646                at..end,
647                Disposition::Metadata(MetadataKind::GainMap),
648            ),
649        )
650        .map_err(cap)?;
651        at = end;
652    }
653
654    let pixels = w.checked_mul(h).and_then(|n| n.checked_mul(bpp));
655    for i in 0..frames {
656        let (Some(dur_end), Some(px_end)) = (
657            fixed_end(at, 4),
658            pixels.and_then(|n| fixed_end(at.saturating_add(4), n)),
659        ) else {
660            malformed_from(&mut inv, at, &format!("truncated frame {i}"))?;
661            return Ok(inv);
662        };
663        inv.push(
664            None,
665            Part::new(
666                PartKind::Chunk,
667                PartTag::Code(i as u32),
668                at..dur_end,
669                Disposition::Metadata(MetadataKind::Animation),
670            )
671            .with_label("duration"),
672        )
673        .map_err(cap)?;
674        inv.push(
675            None,
676            Part::new(
677                PartKind::Chunk,
678                PartTag::Code(i as u32),
679                dur_end..px_end,
680                Disposition::ImageData,
681            )
682            .with_label("pixels"),
683        )
684        .map_err(cap)?;
685        at = px_end;
686    }
687    inv.fill_gaps(None, Disposition::Trailing).map_err(cap)?;
688    Ok(inv)
689}
690
691pub(crate) fn build_info(h: &Header) -> ImageInfo {
692    let mut info =
693        ImageInfo::new(h.width, h.height, ImageFormat::Pnm).with_orientation(h.meta.orientation);
694    if let Some(icc) = &h.meta.icc_profile {
695        info = info.with_icc_profile(icc.to_vec());
696    }
697    if let Some(exif) = &h.meta.exif {
698        info = info.with_exif(exif.to_vec());
699    }
700    if let Some(xmp) = &h.meta.xmp {
701        info = info.with_xmp(xmp.to_vec());
702    }
703    if let Some(c) = h.meta.cicp {
704        info = info.with_cicp(c);
705    }
706    if let Some(cll) = h.meta.content_light_level {
707        info = info.with_content_light_level(cll);
708    }
709    if let Some(md) = h.meta.mastering_display {
710        info = info.with_mastering_display(md);
711    }
712    // The header is the whole container, so presence is definitive either way.
713    info = match &h.gain_map {
714        Some(gm) => {
715            let mut supplements = info.supplements.clone();
716            supplements.gain_map = true;
717            info.with_supplements(supplements)
718                .with_gain_map(GainMapPresence::Available(Box::new(gm.info.clone())))
719        }
720        None => info.with_gain_map(GainMapPresence::Absent),
721    };
722    if h.frame_count > 1 {
723        info = info.with_sequence(ImageSequence::Animation {
724            frame_count: Some(h.frame_count),
725            loop_count: None,
726            random_access: false,
727        });
728    }
729    info
730}
731
732/// The buffer-level colour description a decoder attaches to its output
733/// pixels — the worked example of the convention in `docs/IMPLEMENTING.md`
734/// ("Colour on the decoded buffer"):
735///
736/// 1. Start from the authoritative field only
737///    ([`SourceColor::to_color_context`] — drop-dupe: ICC *or* CICP, never a
738///    duplicated pair the CMS would have to arbitrate).
739/// 2. **Class-gate the ICC.** A profile rides a buffer only when its device
740///    class (header bytes 16..20, read via `zenpixels::icc::profile_color_space`)
741///    matches the buffer's colour model — `RGB ` on Rgb/Rgba/Bgr/Bgra, `GRAY` on
742///    Gray/GrayAlpha, `CMYK` on Cmyk. Crosswise pairing is invalid signaling.
743/// 3. On a mismatch, **derive the profile's CICP** (embedded `cICP` tag, then
744///    well-known-profile identification) and carry that alone; fall back to the
745///    signaled CICP. A decoder that can choose its output layout should prefer
746///    a layout the profile describes over reaching this branch (the reference
747///    can't — it only emits RGB/RGBA — so it documents the fallback instead).
748/// 4. An empty description is not attached at all — `None` means "nothing
749///    known", not "described as nothing".
750///
751/// Shared by every emission path (one-shot buffer, streaming strips, animation
752/// frames) because scratch/strip buffers are where a context silently dies;
753/// `check_color_context_consistency` diffs the paths to prove it didn't.
754pub(crate) fn class_gated_context(
755    source: &SourceColor,
756    model: ColorModel,
757) -> Option<Arc<ColorContext>> {
758    let mut ctx = source.to_color_context();
759    if let Some(icc) = ctx
760        .icc
761        .take_if(|icc| zenpixels::icc::profile_color_space(icc) != Some(model))
762    {
763        ctx.cicp = zenpixels::icc::extract_cicp(&icc)
764            .or_else(|| zenpixels::icc::identify_common(&icc).and_then(|id| id.to_cicp()))
765            .or(ctx.cicp)
766            .or(source.cicp);
767    }
768    if ctx.icc.is_none() && ctx.cicp.is_none() {
769        return None;
770    }
771    Some(Arc::new(ctx))
772}
773
774pub(crate) fn encode_single(
775    pixels: PixelSlice<'_>,
776    meta: &Metadata,
777    gain_map: Option<&GainMapWire>,
778) -> Vec<u8> {
779    let bpp = pixels.descriptor().bytes_per_pixel() as u8;
780    let mut out = Vec::new();
781    write_header(
782        &mut out,
783        pixels.width(),
784        pixels.rows(),
785        1,
786        bpp,
787        meta,
788        gain_map,
789    );
790    push_u32(&mut out, 0); // duration
791    for y in 0..pixels.rows() {
792        out.extend_from_slice(pixels.row(y));
793    }
794    out
795}
796
797// ===========================================================================
798// Capabilities
799// ===========================================================================
800
801// The reference is a lossless-only codec. Every capability declared here is
802// honored — that's the point: `check_capability_honesty` must pass against it.
803// (No `lossy`, `stop`, or quality-range: the reference stores raw pixels, encodes
804// instantly so there's nothing to cancel, and has no quality knob. Declaring them
805// would be the exact dishonesty the check exists to catch.)
806static ENCODE_CAPS: EncodeCapabilities = EncodeCapabilities::new()
807    .with_animation_timing(true)
808    .with_lossless(true)
809    .with_native_alpha(true)
810    .with_native_16bit(true)
811    .with_hdr(true) // PQ/HLG CICP + CLLI/MDCV round-trip (check_native_hdr_roundtrip)
812    .with_gain_map(true) // with_gain_map_pixels / _encoded (check_gain_map_roundtrip)
813    .with_animation(true)
814    .with_push_rows(true)
815    .with_streaming_output(true) // begin writes the header; rows stream out
816    .with_encode_from(false) // reference declines the pull path (see encoder)
817    .with_icc(true)
818    .with_exif(true)
819    .with_xmp(true)
820    .with_cicp(true);
821
822static DECODE_CAPS: DecodeCapabilities = DecodeCapabilities::new()
823    .with_icc(true)
824    .with_exif(true)
825    .with_xmp(true)
826    .with_cicp(true)
827    .with_cheap_probe(true)
828    .with_animation(true)
829    .with_streaming(true)
830    .with_native_alpha(true)
831    .with_native_16bit(true)
832    .with_hdr(true)
833    // Extracts / surfaces the gain map (`GainMapRender::Components`) but does
834    // NOT reconstruct HDR — zencodec carries no HDR math, so `reconstructs_hdr`
835    // stays false and a `ReconstructHdr` request falls back to `Components`.
836    .with_gain_map(true)
837    .with_inventory(true)
838    .with_incremental(true)
839    .with_seekable(true);
840
841/// Every layout the reference stores raw: 8- and 16-bit RGB/RGBA, with the
842/// 16-bit HDR-labelled variants listed so a PQ/HLG caller negotiates natively.
843static DESCRIPTORS: [PixelDescriptor; 6] = [
844    PixelDescriptor::RGB8_SRGB,
845    PixelDescriptor::RGBA8_SRGB,
846    PixelDescriptor::RGB16_SRGB,
847    PixelDescriptor::RGBA16_SRGB,
848    PixelDescriptor::RGB16_BT2100_PQ,
849    PixelDescriptor::RGB16_BT2100_HLG,
850];
851
852// ===========================================================================
853// Encode: Config -> Job -> Encoder / AnimationFrameEncoder
854// ===========================================================================
855
856/// Reference encoder configuration. Accepts RGB8 and RGBA8.
857#[derive(Clone, Debug, Default)]
858pub struct ReferenceEncoderConfig {}
859
860impl ReferenceEncoderConfig {
861    /// Construct a fresh config.
862    pub fn new() -> Self {
863        Self::default()
864    }
865}
866
867impl EncoderConfig for ReferenceEncoderConfig {
868    type Error = RefError;
869    type Job = RefEncodeJob;
870
871    fn format() -> ImageFormat {
872        ImageFormat::Pnm
873    }
874    fn supported_descriptors() -> &'static [PixelDescriptor] {
875        &DESCRIPTORS
876    }
877    fn capabilities() -> &'static EncodeCapabilities {
878        &ENCODE_CAPS
879    }
880    // The reference stores raw pixels, so it is lossless whatever was asked
881    // for: `is_lossless()` is `Some(true)` unconditionally, and a `Lossy`
882    // fidelity request resolves — honestly — to `Fidelity::Lossless` through
883    // the trait's default `resolved_target_fidelity`. This is the exemplar of
884    // the "promote and report" rule for a codec with no lossy mode
885    // (`check_fidelity_honesty`); a no-op `with_lossless` that left
886    // `is_lossless()` at `None` would deny the declared `lossless` capability,
887    // and recording `Some(false)` would claim a lossy mode it does not have.
888    fn with_lossless(self, _lossless: bool) -> Self {
889        self
890    }
891    fn is_lossless(&self) -> Option<bool> {
892        Some(true)
893    }
894    fn job(self) -> RefEncodeJob {
895        RefEncodeJob {
896            metadata: Metadata::none(),
897            policy: EncodePolicy::none(),
898            loop_count: None,
899            gain_map: None,
900        }
901    }
902}
903
904/// Reference encode job.
905pub struct RefEncodeJob {
906    metadata: Metadata,
907    policy: EncodePolicy,
908    loop_count: Option<u32>,
909    gain_map: Option<GainMapWire>,
910}
911
912fn apply_embed_policy(mut metadata: Metadata, policy: EncodePolicy) -> Metadata {
913    if policy.embed_icc == Some(false) {
914        metadata.icc_profile = None;
915    }
916    if policy.embed_exif == Some(false) {
917        metadata.exif = None;
918    }
919    if policy.embed_xmp == Some(false) {
920        metadata.xmp = None;
921    }
922    metadata
923}
924
925impl EncodeJob for RefEncodeJob {
926    type Error = RefError;
927    type Enc = RefEnc;
928    type AnimationFrameEnc = RefAnimEnc;
929
930    fn with_pulse(self, _stop: SharedPulse) -> Self {
931        self
932    }
933    fn with_limits(self, _limits: ResourceLimits) -> Self {
934        self
935    }
936    fn with_policy(mut self, policy: EncodePolicy) -> Self {
937        self.policy = policy;
938        self
939    }
940    fn try_set_policy(&mut self, policy: EncodePolicy) -> Result<(), UnsupportedOperation> {
941        let mut unsupported = policy;
942        unsupported.embed_icc = None;
943        unsupported.embed_exif = None;
944        unsupported.embed_xmp = None;
945        if unsupported != EncodePolicy::none() {
946            return Err(UnsupportedOperation::EncodePolicy);
947        }
948        self.policy = policy;
949        Ok(())
950    }
951    fn with_metadata(mut self, meta: Metadata) -> Self {
952        self.metadata = meta;
953        self
954    }
955    fn with_loop_count(mut self, count: Option<u32>) -> Self {
956        self.loop_count = count;
957        self
958    }
959
960    /// The pixel form: validate (8-bit, 1 or 3 channels) and store; a bad gain
961    /// map is the codec's own error, never a silent drop.
962    fn with_gain_map_pixels(mut self, gain_map: DecodedGainMap) -> Result<Self, RefError> {
963        self.gain_map = Some(GainMapWire::from_decoded(gain_map)?);
964        Ok(self)
965    }
966
967    /// The byte-carried form: only the reference's own format is accepted (a
968    /// single-frame RGB8 file of its own wire format — the reference has no
969    /// grayscale layout of its own, so a 1-channel map must come as pixels).
970    /// Any other `format` is the documented mismatch error: the caller decodes
971    /// to pixels and uses `with_gain_map_pixels`.
972    fn with_gain_map_encoded(mut self, gain_map: GainMapSource) -> Result<Self, RefError> {
973        if gain_map.format != ImageFormat::Pnm {
974            return Err(RefError::Invalid(format!(
975                "encoded gain map is {:?}, not the reference's own format — decode it and use \
976                 with_gain_map_pixels",
977                gain_map.format
978            )));
979        }
980        let h = parse_header(&gain_map.data)?;
981        if h.frame_count != 1 || h.bpp != 3 {
982            return Err(RefError::Invalid(
983                "encoded gain map must be a single-frame RGB8 reference image".into(),
984            ));
985        }
986        let start = frame_pixels_offset(&h, 0);
987        let len = frame_pixel_len(&h);
988        let pixels = gain_map
989            .data
990            .get(start..start + len)
991            .ok_or_else(|| RefError::Invalid("truncated gain map pixels".into()))?
992            .to_vec();
993        let mut info = gain_map.metadata;
994        info.width = h.width;
995        info.height = h.height;
996        info.channels = 3;
997        info.bit_depth = 8;
998        self.gain_map = Some(GainMapWire { info, pixels });
999        Ok(self)
1000    }
1001
1002    fn encoder(self) -> Result<RefEnc, RefError> {
1003        let metadata = apply_embed_policy(self.metadata, self.policy);
1004        Ok(RefEnc {
1005            metadata,
1006            gain_map: self.gain_map,
1007            accumulated: Vec::new(),
1008            out: OutputBuffer::new(),
1009            width: None,
1010            rows: 0,
1011            desc: None,
1012            declared_height: None,
1013        })
1014    }
1015
1016    fn animation_frame_encoder(self) -> Result<RefAnimEnc, RefError> {
1017        let metadata = apply_embed_policy(self.metadata, self.policy);
1018        Ok(RefAnimEnc {
1019            metadata,
1020            gain_map: self.gain_map,
1021            frames: Vec::new(),
1022            timing: zencodec::animation::TimingQuantizer::new(1000, u32::MAX.into()).unwrap(),
1023            streaming: None,
1024        })
1025    }
1026}
1027
1028/// Reference single-image encoder. Supports one-shot `encode` and incremental
1029/// `push_rows` + `finish`; declines the pull path (`encode_from`).
1030///
1031/// After [`begin`](Encoder::begin) the header is written at once and each
1032/// pushed row is appended to the output, which
1033/// [`pending_output`](Encoder::pending_output) exposes: the streaming
1034/// encoder pattern. Without `begin`, rows accumulate and the file is built
1035/// at `finish`.
1036pub struct RefEnc {
1037    metadata: Metadata,
1038    gain_map: Option<GainMapWire>,
1039    /// Without `begin`: raw rows.
1040    accumulated: Vec<u8>,
1041    /// With `begin`: the encoded file not yet handed out.
1042    out: OutputBuffer,
1043    width: Option<u32>,
1044    rows: u32,
1045    desc: Option<PixelDescriptor>,
1046    declared_height: Option<u32>,
1047}
1048
1049impl Encoder for RefEnc {
1050    type Error = RefError;
1051
1052    fn reject(op: UnsupportedOperation) -> RefError {
1053        RefError::Unsupported(op)
1054    }
1055
1056    fn preferred_strip_height(&self) -> u32 {
1057        4
1058    }
1059
1060    fn encode(self, pixels: PixelSlice<'_>) -> Result<EncodeOutput, RefError> {
1061        // The usage paths are exclusive: no one-shot encode after `begin` or
1062        // `push_rows`.
1063        if self.width.is_some() {
1064            return Err(RefError::Invalid("encode after begin or push_rows".into()));
1065        }
1066        Ok(EncodeOutput::new(
1067            encode_single(pixels, &self.metadata, self.gain_map.as_ref()),
1068            ImageFormat::Pnm,
1069        ))
1070    }
1071
1072    fn begin(
1073        &mut self,
1074        width: u32,
1075        height: u32,
1076        desc: PixelDescriptor,
1077    ) -> Result<OutputTiming, RefError> {
1078        if self.declared_height.is_some() {
1079            return Err(RefError::Invalid("begin called twice".into()));
1080        }
1081        if self.width.is_some() {
1082            return Err(RefError::Invalid("begin after push_rows".into()));
1083        }
1084        self.width = Some(width);
1085        self.desc = Some(desc);
1086        self.declared_height = Some(height);
1087        let file = self.out.vec_mut();
1088        write_header(
1089            file,
1090            width,
1091            height,
1092            1,
1093            desc.bytes_per_pixel() as u8,
1094            &self.metadata,
1095            self.gain_map.as_ref(),
1096        );
1097        push_u32(file, 0); // duration
1098        // Each row is appended as it arrives.
1099        Ok(OutputTiming::streaming(0))
1100    }
1101
1102    fn push_rows(&mut self, rows: PixelSlice<'_>) -> Result<(), RefError> {
1103        if self.width.is_none() {
1104            self.width = Some(rows.width());
1105            self.desc = Some(rows.descriptor());
1106        }
1107        if Some(rows.width()) != self.width || Some(rows.descriptor()) != self.desc {
1108            return Err(RefError::Invalid(
1109                "push_rows: width or format changed".into(),
1110            ));
1111        }
1112        if let Some(h) = self.declared_height
1113            && self.rows.saturating_add(rows.rows()) > h
1114        {
1115            return Err(RefError::Invalid(format!(
1116                "push_rows: more than the {h} rows declared at begin"
1117            )));
1118        }
1119        let dest = match self.declared_height {
1120            Some(_) => self.out.vec_mut(),
1121            None => &mut self.accumulated,
1122        };
1123        for y in 0..rows.rows() {
1124            dest.extend_from_slice(rows.row(y));
1125            self.rows += 1;
1126        }
1127        Ok(())
1128    }
1129
1130    fn pending_output(&self) -> &[u8] {
1131        self.out.pending()
1132    }
1133
1134    fn consume_output(&mut self, n: usize) {
1135        self.out.consume(n);
1136    }
1137
1138    fn take_output(&mut self, into: &mut Vec<u8>) {
1139        self.out.take_into(into);
1140    }
1141
1142    fn finish(self) -> Result<EncodeOutput, RefError> {
1143        if let Some(h) = self.declared_height {
1144            if self.rows != h {
1145                return Err(RefError::Invalid(format!(
1146                    "finish: {} rows pushed, {h} declared at begin",
1147                    self.rows
1148                )));
1149            }
1150            return Ok(EncodeOutput::new(
1151                self.out.into_remaining(),
1152                ImageFormat::Pnm,
1153            ));
1154        }
1155        let w = self
1156            .width
1157            .ok_or_else(|| RefError::Invalid("finish before push_rows".into()))?;
1158        let desc = self.desc.unwrap();
1159        let buf = PixelBuffer::from_vec(self.accumulated, w, self.rows, desc)
1160            .map_err(|e| RefError::Invalid(format!("buffer: {e}")))?;
1161        Ok(EncodeOutput::new(
1162            encode_single(buf.as_slice(), &self.metadata, self.gain_map.as_ref()),
1163            ImageFormat::Pnm,
1164        ))
1165    }
1166}
1167
1168/// Reference animation encoder.
1169///
1170/// The file starts with the frame count, so like APNG it streams only when
1171/// [`begin`](AnimationFrameEncoder::begin) declares one: then the header is
1172/// written at once and each frame as it is pushed. Otherwise frames
1173/// accumulate and the file is built at `finish`.
1174pub struct RefAnimEnc {
1175    timing: zencodec::animation::TimingQuantizer,
1176    metadata: Metadata,
1177    gain_map: Option<GainMapWire>,
1178    frames: Vec<(Vec<u8>, u32, u32, u32, PixelDescriptor)>, // bytes, w, h, dur, desc
1179    streaming: Option<RefAnimStream>,
1180}
1181
1182/// A streaming animation: what `begin` declared, and the file so far.
1183struct RefAnimStream {
1184    width: u32,
1185    height: u32,
1186    desc: PixelDescriptor,
1187    frame_count: u32,
1188    pushed: u32,
1189    out: OutputBuffer,
1190}
1191
1192impl AnimationFrameEncoder for RefAnimEnc {
1193    type Error = RefError;
1194
1195    fn reject(op: UnsupportedOperation) -> RefError {
1196        RefError::Unsupported(op)
1197    }
1198
1199    fn push_frame(
1200        &mut self,
1201        pixels: PixelSlice<'_>,
1202        duration_ms: u32,
1203        stop: Option<&dyn zencodec::Pulse>,
1204    ) -> Result<(), RefError> {
1205        if let Some(s) = stop {
1206            s.check()?;
1207        }
1208        let (w, h, desc) = (pixels.width(), pixels.rows(), pixels.descriptor());
1209        if let Some(st) = &mut self.streaming {
1210            if (w, h, desc) != (st.width, st.height, st.desc) {
1211                return Err(RefError::Invalid(
1212                    "push_frame: frame differs from the canvas declared at begin".into(),
1213                ));
1214            }
1215            if st.pushed == st.frame_count {
1216                return Err(RefError::Invalid(format!(
1217                    "push_frame: more than the {} frames declared at begin",
1218                    st.frame_count
1219                )));
1220            }
1221            let file = st.out.vec_mut();
1222            push_u32(file, duration_ms);
1223            for y in 0..h {
1224                file.extend_from_slice(pixels.row(y));
1225            }
1226            st.pushed += 1;
1227            return Ok(());
1228        }
1229        let mut bytes = Vec::with_capacity(w as usize * h as usize * desc.bytes_per_pixel());
1230        for y in 0..h {
1231            bytes.extend_from_slice(pixels.row(y));
1232        }
1233        self.frames.push((bytes, w, h, duration_ms, desc));
1234        Ok(())
1235    }
1236
1237    fn begin(
1238        &mut self,
1239        width: u32,
1240        height: u32,
1241        desc: PixelDescriptor,
1242        frame_count: Option<u32>,
1243    ) -> Result<AnimationOutputTiming, RefError> {
1244        if self.streaming.is_some() {
1245            return Err(RefError::Invalid("begin called twice".into()));
1246        }
1247        if !self.frames.is_empty() {
1248            return Err(RefError::Invalid("begin after push_frame".into()));
1249        }
1250        // The header carries the frame count: without one, nothing can be
1251        // written before `finish`.
1252        let Some(frame_count) = frame_count else {
1253            return Ok(AnimationOutputTiming::AT_FINISH);
1254        };
1255        if frame_count == 0 {
1256            return Err(RefError::Invalid("no frames".into()));
1257        }
1258        let mut out = OutputBuffer::new();
1259        write_header(
1260            out.vec_mut(),
1261            width,
1262            height,
1263            frame_count,
1264            desc.bytes_per_pixel() as u8,
1265            &self.metadata,
1266            self.gain_map.as_ref(),
1267        );
1268        self.streaming = Some(RefAnimStream {
1269            width,
1270            height,
1271            desc,
1272            frame_count,
1273            pushed: 0,
1274            out,
1275        });
1276        Ok(AnimationOutputTiming::streaming(0))
1277    }
1278
1279    fn pending_output(&self) -> &[u8] {
1280        self.streaming.as_ref().map_or(&[], |st| st.out.pending())
1281    }
1282
1283    fn consume_output(&mut self, n: usize) {
1284        if let Some(st) = &mut self.streaming {
1285            st.out.consume(n);
1286        }
1287    }
1288
1289    fn take_output(&mut self, into: &mut Vec<u8>) {
1290        if let Some(st) = &mut self.streaming {
1291            st.out.take_into(into);
1292        }
1293    }
1294
1295    fn push_frame_exact(
1296        &mut self,
1297        pixels: PixelSlice<'_>,
1298        duration: zencodec::animation::FrameDuration,
1299        stop: Option<&dyn zencodec::Pulse>,
1300    ) -> Result<(), RefError> {
1301        let ticks = duration
1302            .ticks_at(1000)
1303            .map_err(|e| RefError::Invalid(e.to_string()))?;
1304        let ticks =
1305            u32::try_from(ticks).map_err(|_| RefError::Invalid("duration overflow".into()))?;
1306        self.push_frame(pixels, ticks, stop)
1307    }
1308
1309    fn push_frame_timed(
1310        &mut self,
1311        pixels: PixelSlice<'_>,
1312        duration: zencodec::animation::FrameDuration,
1313        stop: Option<&dyn zencodec::Pulse>,
1314    ) -> Result<zencodec::animation::FrameDuration, RefError> {
1315        let mut timing = self.timing;
1316        let encoded = timing
1317            .quantize(duration)
1318            .map_err(|e| RefError::Invalid(e.to_string()))?;
1319        self.push_frame_exact(pixels, encoded, stop)?;
1320        self.timing = timing;
1321        Ok(encoded)
1322    }
1323
1324    fn finish(self, stop: Option<&dyn zencodec::Pulse>) -> Result<EncodeOutput, RefError> {
1325        if let Some(s) = stop {
1326            s.check()?;
1327        }
1328        if let Some(st) = self.streaming {
1329            if st.pushed != st.frame_count {
1330                return Err(RefError::Invalid(format!(
1331                    "finish: {} frames pushed, {} declared at begin",
1332                    st.pushed, st.frame_count
1333                )));
1334            }
1335            return Ok(EncodeOutput::new(st.out.into_remaining(), ImageFormat::Pnm));
1336        }
1337        let (first_bytes, w, h, _, desc) = self
1338            .frames
1339            .first()
1340            .ok_or_else(|| RefError::Invalid("no frames".into()))?;
1341        let bpp = desc.bytes_per_pixel() as u8;
1342        let mut out = Vec::new();
1343        write_header(
1344            &mut out,
1345            *w,
1346            *h,
1347            self.frames.len() as u32,
1348            bpp,
1349            &self.metadata,
1350            self.gain_map.as_ref(),
1351        );
1352        push_u32(&mut out, self.frames[0].3);
1353        out.extend_from_slice(first_bytes);
1354        for (bytes, _, _, dur, _) in &self.frames[1..] {
1355            push_u32(&mut out, *dur);
1356            out.extend_from_slice(bytes);
1357        }
1358        Ok(EncodeOutput::new(out, ImageFormat::Pnm))
1359    }
1360}
1361
1362// ===========================================================================
1363// Decode: Config -> Job -> Decode / StreamingDecode / AnimationFrameDecoder
1364// ===========================================================================
1365
1366/// Reference decoder configuration.
1367#[derive(Clone, Debug, Default)]
1368pub struct ReferenceDecoderConfig;
1369
1370impl DecoderConfig for ReferenceDecoderConfig {
1371    type Error = RefError;
1372    type Job<'a> = RefDecodeJob;
1373
1374    fn formats() -> &'static [ImageFormat] {
1375        &[ImageFormat::Pnm]
1376    }
1377    fn supported_descriptors() -> &'static [PixelDescriptor] {
1378        &DESCRIPTORS
1379    }
1380    fn capabilities() -> &'static DecodeCapabilities {
1381        &DECODE_CAPS
1382    }
1383    fn job<'a>(self) -> Self::Job<'a> {
1384        RefDecodeJob {
1385            limits: ResourceLimits::none(),
1386            render: GainMapRender::BaseOnly,
1387            format: ImageFormat::Pnm,
1388        }
1389    }
1390}
1391
1392fn detect_zcr(data: &[u8]) -> bool {
1393    data.starts_with(MAGIC)
1394}
1395
1396/// An [`ImageFormatDefinition`] whose `detect` matches the reference codec's
1397/// `ZCR1` wire magic — the piece [`ReferenceDecoderConfig`] lacks.
1398///
1399/// The reference decoder registers under [`ImageFormat::Pnm`] (standing in for
1400/// "a real codec"), but the bytes it produces are its own `ZCR1` container, not
1401/// PNM — so [`CodecSet::detect`](zencodec::CodecSet::detect) can't route encoded
1402/// reference bytes back to it. Register [`ReferenceZcrDecoderConfig`] under this
1403/// format and detection-based decode (`decode` / `probe` / `estimate_decode_of`)
1404/// works end to end, without every example hand-rolling its own custom format.
1405pub static ZCR_FORMAT: ImageFormatDefinition = ImageFormatDefinition::new(
1406    "zcr",
1407    None,
1408    "ZCR (reference codec wire format)",
1409    "zcr",
1410    &["zcr"],
1411    "image/x-zcr",
1412    &["image/x-zcr"],
1413    true,  // alpha
1414    true,  // animation
1415    true,  // lossless
1416    false, // lossy
1417    MAGIC.len(),
1418    detect_zcr,
1419);
1420
1421static ZCR_FORMATS: &[ImageFormat] = &[ImageFormat::Custom(&ZCR_FORMAT)];
1422
1423/// The reference decoder registered under the self-detecting [`ZCR_FORMAT`]
1424/// instead of [`ImageFormat::Pnm`].
1425///
1426/// Drop-in for [`ReferenceDecoderConfig`] on a [`CodecSet`](zencodec::CodecSet)
1427/// when you want `set.decode(bytes)` / `set.probe(bytes)` to detect the
1428/// reference codec's own bytes. Decode behavior is identical — only the
1429/// registration format differs.
1430#[derive(Clone, Debug, Default)]
1431pub struct ReferenceZcrDecoderConfig;
1432
1433impl DecoderConfig for ReferenceZcrDecoderConfig {
1434    type Error = RefError;
1435    type Job<'a> = RefDecodeJob;
1436
1437    fn formats() -> &'static [ImageFormat] {
1438        ZCR_FORMATS
1439    }
1440    fn supported_descriptors() -> &'static [PixelDescriptor] {
1441        ReferenceDecoderConfig::supported_descriptors()
1442    }
1443    fn capabilities() -> &'static DecodeCapabilities {
1444        ReferenceDecoderConfig::capabilities()
1445    }
1446    fn job<'a>(self) -> Self::Job<'a> {
1447        RefDecodeJob {
1448            limits: ResourceLimits::none(),
1449            render: GainMapRender::BaseOnly,
1450            format: ZCR_FORMATS[0],
1451        }
1452    }
1453}
1454
1455/// Reference decode job.
1456pub struct RefDecodeJob {
1457    limits: ResourceLimits,
1458    render: GainMapRender,
1459    /// The format the job's config registered under; inventories report it.
1460    format: ImageFormat,
1461}
1462
1463impl<'a> DecodeJob<'a> for RefDecodeJob {
1464    type Error = RefError;
1465    type Dec = RefDec<'a>;
1466    type StreamDec = RefStreamDec<'a>;
1467    type AnimationFrameDec = RefAnimDec;
1468
1469    fn with_pulse(self, _stop: SharedPulse) -> Self {
1470        self
1471    }
1472    fn with_limits(mut self, limits: ResourceLimits) -> Self {
1473        self.limits = limits;
1474        self
1475    }
1476    /// The 2-way toggle is exactly `Components` / `BaseOnly`.
1477    fn with_extract_gain_map(mut self, extract: bool) -> Self {
1478        self.render = if extract {
1479            GainMapRender::Components
1480        } else {
1481            GainMapRender::BaseOnly
1482        };
1483        self
1484    }
1485    fn with_gain_map_render(mut self, render: GainMapRender) -> Self {
1486        self.render = render;
1487        self
1488    }
1489
1490    fn probe(&self, data: &[u8]) -> Result<ImageInfo, RefError> {
1491        Ok(build_info(&parse_header(data)?))
1492    }
1493
1494    fn inventory(&self, data: &[u8]) -> Result<zencodec::inventory::Inventory, At<CodecError>> {
1495        self.limits
1496            .check_input_size(data.len() as u64)
1497            .map_err(RefError::from)?;
1498        zcr_inventory(data, self.format).map_err(Into::into)
1499    }
1500
1501    fn output_info(&self, data: &[u8]) -> Result<OutputInfo, RefError> {
1502        let h = parse_header(data)?;
1503        Ok(OutputInfo::full_decode(
1504            h.width,
1505            h.height,
1506            descriptor_for_header(&h)?,
1507        ))
1508    }
1509
1510    fn decoder(
1511        self,
1512        data: Cow<'a, [u8]>,
1513        _preferred: &[PixelDescriptor],
1514    ) -> Result<RefDec<'a>, RefError> {
1515        parse_header(&data)?; // validate eagerly
1516        Ok(RefDec {
1517            data,
1518            render: self.render,
1519        })
1520    }
1521
1522    fn push_decoder(
1523        self,
1524        data: Cow<'a, [u8]>,
1525        sink: &mut dyn DecodeRowSink,
1526        preferred: &[PixelDescriptor],
1527    ) -> Result<OutputInfo, RefError> {
1528        zencodec::helpers::copy_decode_to_sink(self, data, sink, preferred, RefError::Sink)
1529    }
1530
1531    fn streaming_decoder(
1532        self,
1533        data: Cow<'a, [u8]>,
1534        _preferred: &[PixelDescriptor],
1535    ) -> Result<RefStreamDec<'a>, RefError> {
1536        let h = parse_header(&data)?;
1537        let info = build_info(&h);
1538        let desc = descriptor_for_header(&h)?;
1539        let ctx = class_gated_context(&info.source_color, desc.color_model());
1540        Ok(RefStreamDec {
1541            data,
1542            info,
1543            width: h.width,
1544            height: h.height,
1545            bpp: h.bpp,
1546            desc,
1547            pixels_offset: frame_pixels_offset(&h, 0),
1548            next_row: 0,
1549            ctx,
1550        })
1551    }
1552
1553    fn dyn_incremental_decoder(
1554        self,
1555        _preferred: &[PixelDescriptor],
1556    ) -> Result<Box<zencodec::decode::DynIncrementalDecoder>, zencodec::decode::BoxedError> {
1557        Ok(zencodec::decode::erase_incremental(RefIncDec::new(
1558            self.limits,
1559        )))
1560    }
1561
1562    /// Pull-native path: the header is read by growing prefix (the wire
1563    /// format is self-delimiting, so a complete header parses and a short
1564    /// one does not), then strips are fetched two at a time through
1565    /// `read_many_at` so a batching source is exercised.
1566    fn push_decoder_from_source(
1567        self,
1568        source: &mut dyn DynReadAt,
1569        sink: &mut dyn DecodeRowSink,
1570        _preferred: &[PixelDescriptor],
1571    ) -> Result<OutputInfo, zencodec::decode::BoxedError> {
1572        // Short reads find the end; `len()` is never needed, so a source
1573        // over a stream is not consumed to learn its length.
1574        let mut want = 64usize;
1575        let h = loop {
1576            let mut prefix = vec![0u8; want];
1577            let n = source.read_at(0, &mut prefix)?;
1578            prefix.truncate(n);
1579            self.limits
1580                .check_input_size(n as u64)
1581                .map_err(RefError::from)?;
1582            match parse_header(&prefix) {
1583                Ok(h) => break h,
1584                Err(e) if n < want => return Err(Box::new(e)),
1585                Err(_) => want *= 4,
1586            }
1587        };
1588        let info = build_info(&h);
1589        let desc = descriptor_for_header(&h)?;
1590        let _ = info;
1591        let row_bytes = h.width as usize * h.bpp as usize;
1592        let base = frame_pixels_offset(&h, 0) as u64;
1593        sink.begin(h.width, h.height, desc)
1594            .map_err(RefError::Sink)?;
1595        let mut y = 0u32;
1596        while y < h.height {
1597            // Two strips of up to 4 rows per source round.
1598            let mut strips: Vec<(u32, u32, Vec<u8>)> = Vec::new();
1599            let mut yy = y;
1600            while yy < h.height && strips.len() < 2 {
1601                let rows = (h.height - yy).min(4);
1602                strips.push((yy, rows, vec![0u8; rows as usize * row_bytes]));
1603                yy += rows;
1604            }
1605            {
1606                let mut reqs: Vec<(u64, &mut [u8])> = strips
1607                    .iter_mut()
1608                    .map(|(sy, _, buf)| (base + *sy as u64 * row_bytes as u64, &mut buf[..]))
1609                    .collect();
1610                source.read_many_at(&mut reqs)?;
1611            }
1612            for (sy, rows, buf) in &strips {
1613                let mut dst = sink
1614                    .provide_next_buffer(*sy, *rows, h.width, desc)
1615                    .map_err(RefError::Sink)?;
1616                for r in 0..*rows {
1617                    let start = r as usize * row_bytes;
1618                    dst.row_mut(r)
1619                        .copy_from_slice(&buf[start..start + row_bytes]);
1620                }
1621            }
1622            y = yy;
1623        }
1624        sink.finish().map_err(RefError::Sink)?;
1625        Ok(OutputInfo::full_decode(h.width, h.height, desc))
1626    }
1627
1628    fn animation_frame_decoder(
1629        self,
1630        data: Cow<'a, [u8]>,
1631        _preferred: &[PixelDescriptor],
1632    ) -> Result<RefAnimDec, RefError> {
1633        let h = parse_header(&data)?;
1634        let info = build_info(&h);
1635        let desc = descriptor_for_header(&h)?;
1636        let ctx = class_gated_context(&info.source_color, desc.color_model());
1637        Ok(RefAnimDec {
1638            data: data.into_owned(),
1639            info,
1640            width: h.width,
1641            height: h.height,
1642            frame_count: h.frame_count,
1643            bpp: h.bpp,
1644            desc,
1645            frames_offset: h.frames_offset,
1646            next_frame: 0,
1647            ctx,
1648        })
1649    }
1650}
1651
1652/// Reference one-shot decoder.
1653#[derive(Debug)]
1654pub struct RefDec<'a> {
1655    data: Cow<'a, [u8]>,
1656    render: GainMapRender,
1657}
1658
1659impl Decode for RefDec<'_> {
1660    type Error = RefError;
1661
1662    fn decode(self) -> Result<DecodeOutput, RefError> {
1663        let h = parse_header(&self.data)?;
1664        let desc = descriptor_for_header(&h)?;
1665        let start = frame_pixels_offset(&h, 0);
1666        let len = frame_pixel_len(&h);
1667        let pixels = self
1668            .data
1669            .get(start..start + len)
1670            .ok_or_else(|| RefError::Invalid("truncated pixels".into()))?;
1671        let mut buf = PixelBuffer::from_vec(pixels.to_vec(), h.width, h.height, desc)
1672            .map_err(|e| RefError::Invalid(format!("buffer: {e}")))?;
1673        let info = build_info(&h);
1674        // Attach after the descriptor is final, before anyone negotiates on it.
1675        if let Some(ctx) = class_gated_context(&info.source_color, desc.color_model()) {
1676            buf = buf.with_color_context(ctx);
1677        }
1678        let mut out = DecodeOutput::new(buf, info);
1679        // Opt-in only: `BaseOnly` (the default) never carries the gain map.
1680        // `ReconstructHdr` is not honoured (`reconstructs_hdr` is false — no HDR
1681        // math here), so per the contract it surfaces the components instead of
1682        // relabelling the SDR base as HDR.
1683        let surface = matches!(
1684            self.render,
1685            GainMapRender::Components | GainMapRender::ReconstructHdr { .. }
1686        );
1687        if surface && let Some(gm) = &h.gain_map {
1688            out = out.with_extras(gm.to_decoded()?);
1689        }
1690        Ok(out)
1691    }
1692}
1693
1694/// Reference streaming decoder. Yields strips of up to 4 rows.
1695pub struct RefStreamDec<'a> {
1696    data: Cow<'a, [u8]>,
1697    info: ImageInfo,
1698    width: u32,
1699    height: u32,
1700    bpp: u8,
1701    desc: PixelDescriptor,
1702    pixels_offset: usize,
1703    next_row: u32,
1704    /// Computed once; re-applied to every emitted strip (strips are fresh
1705    /// slices, so the context has to be attached at emission).
1706    ctx: Option<Arc<ColorContext>>,
1707}
1708
1709impl StreamingDecode for RefStreamDec<'_> {
1710    type Error = RefError;
1711
1712    fn next_batch(&mut self) -> Result<Option<(u32, PixelSlice<'_>)>, RefError> {
1713        if self.next_row >= self.height {
1714            return Ok(None);
1715        }
1716        let strip = (self.height - self.next_row).min(4);
1717        let row_bytes = self.width as usize * self.bpp as usize;
1718        let off = self.pixels_offset + self.next_row as usize * row_bytes;
1719        let span = strip as usize * row_bytes;
1720        let bytes = self
1721            .data
1722            .get(off..off + span)
1723            .ok_or_else(|| RefError::Invalid("truncated strip".into()))?;
1724        let mut ps = PixelSlice::new(bytes, self.width, strip, row_bytes, self.desc)
1725            .map_err(|e| RefError::Invalid(format!("slice: {e}")))?;
1726        if let Some(ctx) = &self.ctx {
1727            ps = ps.with_color_context(ctx.clone());
1728        }
1729        let y = self.next_row;
1730        self.next_row += strip;
1731        Ok(Some((y, ps)))
1732    }
1733
1734    fn info(&self) -> &ImageInfo {
1735        &self.info
1736    }
1737}
1738
1739/// Reference incremental decoder: the hand-written template for the
1740/// [`IncrementalDecode`] contract.
1741///
1742/// It keeps no input buffer. It asks for a growing prefix until the
1743/// self-delimiting header parses, then for each strip of up to four rows
1744/// with `min_len` set to the strip's size, and decodes straight from the
1745/// driver's bytes into the sink. Termination and cancellation rules come
1746/// from [`Fused`](zencodec::decode::Fused) (applied by
1747/// [`erase_incremental`](zencodec::decode::erase_incremental)), so the state
1748/// machine below handles only decoding. Only the first frame is decoded,
1749/// like the one-shot path.
1750pub struct RefIncDec {
1751    limits: ResourceLimits,
1752    header: Option<(Header, PixelDescriptor, ImageInfo)>,
1753    /// Bytes of prefix asked for while the header does not parse yet.
1754    header_want: usize,
1755    next_row: u32,
1756    request: [InputRequest; 1],
1757}
1758
1759impl RefIncDec {
1760    fn new(limits: ResourceLimits) -> Self {
1761        Self {
1762            limits,
1763            header: None,
1764            header_want: 64,
1765            next_row: 0,
1766            request: [InputRequest::at_least(0, 64)],
1767        }
1768    }
1769
1770    fn need(&mut self, offset: u64, min_len: usize) -> Progress {
1771        self.request = [InputRequest::at_least(offset, min_len)];
1772        Progress::need(0)
1773    }
1774}
1775
1776impl IncrementalDecode for RefIncDec {
1777    type Error = RefError;
1778
1779    fn step(
1780        &mut self,
1781        input: Input<'_>,
1782        sink: &mut dyn DecodeRowSink,
1783        _stop: Option<&dyn zencodec::Pulse>,
1784    ) -> Result<Progress, StepError<RefError>> {
1785        if let Some(total) = input.total_len {
1786            self.limits
1787                .check_input_size(total)
1788                .map_err(|e| StepError::Codec(RefError::from(e)))?;
1789        }
1790        let Some((h, desc, _)) = &self.header else {
1791            if input.offset != 0 {
1792                return Ok(self.need(0, self.header_want));
1793            }
1794            if input.bytes.len() >= MAGIC.len() && !input.bytes.starts_with(MAGIC) {
1795                return Err(StepError::Codec(RefError::Invalid("bad magic".into())));
1796            }
1797            return match parse_header(input.bytes) {
1798                Ok(h) => {
1799                    let desc = descriptor_for_header(&h).map_err(StepError::Codec)?;
1800                    let info = build_info(&h);
1801                    sink.begin(h.width, h.height, desc)
1802                        .map_err(StepError::Sink)?;
1803                    // Take the header (and the frame's duration field):
1804                    // decoding continues at the first strip.
1805                    let consumed = (frame_pixels_offset(&h, 0)).min(input.bytes.len());
1806                    self.header = Some((h, desc, info));
1807                    Ok(Progress::new(
1808                        consumed,
1809                        Event::Metadata(MetadataUpdate::HeaderReady),
1810                    ))
1811                }
1812                Err(e) if input.reaches_end() => Err(StepError::Codec(e)),
1813                Err(_) => {
1814                    if input.bytes.len() >= self.header_want {
1815                        self.header_want *= 4;
1816                    }
1817                    Ok(self.need(0, self.header_want))
1818                }
1819            };
1820        };
1821        let (width, height, bpp, desc) = (h.width, h.height, h.bpp, *desc);
1822        let row_bytes = width as usize * bpp as usize;
1823        let pixels_start = frame_pixels_offset(h, 0) as u64;
1824        if self.next_row >= height {
1825            sink.finish().map_err(StepError::Sink)?;
1826            return Ok(Progress::new(
1827                0,
1828                Event::Done(OutputInfo::full_decode(width, height, desc)),
1829            ));
1830        }
1831        let rows = (height - self.next_row).min(4);
1832        let strip_start = pixels_start + self.next_row as u64 * row_bytes as u64;
1833        let strip_len = rows as usize * row_bytes;
1834        if input.offset != strip_start || input.bytes.len() < strip_len {
1835            if input.offset == strip_start && input.reaches_end() {
1836                return Err(StepError::Codec(RefError::Invalid(
1837                    "truncated strip".into(),
1838                )));
1839            }
1840            return Ok(self.need(strip_start, strip_len));
1841        }
1842        {
1843            let mut dst = sink
1844                .provide_next_buffer(self.next_row, rows, width, desc)
1845                .map_err(StepError::Sink)?;
1846            for r in 0..rows {
1847                let s = r as usize * row_bytes;
1848                dst.row_mut(r)
1849                    .copy_from_slice(&input.bytes[s..s + row_bytes]);
1850            }
1851        }
1852        let meta = BatchMeta::final_rows(self.next_row);
1853        self.next_row += rows;
1854        Ok(Progress::new(strip_len, Event::Rows { meta, rows }))
1855    }
1856
1857    fn requested(&self) -> &[InputRequest] {
1858        &self.request
1859    }
1860
1861    fn info(&self) -> Option<&ImageInfo> {
1862        self.header.as_ref().map(|(_, _, info)| info)
1863    }
1864
1865    fn output_descriptor(&self) -> Option<PixelDescriptor> {
1866        self.header.as_ref().map(|(_, desc, _)| *desc)
1867    }
1868
1869    fn access_pattern(&self) -> AccessPattern {
1870        AccessPattern::Sequential
1871    }
1872}
1873
1874/// Reference animation decoder.
1875pub struct RefAnimDec {
1876    data: Vec<u8>,
1877    info: ImageInfo,
1878    width: u32,
1879    height: u32,
1880    frame_count: u32,
1881    bpp: u8,
1882    desc: PixelDescriptor,
1883    frames_offset: usize,
1884    next_frame: u32,
1885    /// Re-applied to every yielded frame slice (same reason as the streaming
1886    /// decoder's).
1887    ctx: Option<Arc<ColorContext>>,
1888}
1889
1890impl AnimationFrameDecoder for RefAnimDec {
1891    type Error = RefError;
1892
1893    fn wrap_sink_error(err: SinkError) -> RefError {
1894        RefError::Sink(err)
1895    }
1896
1897    fn info(&self) -> &ImageInfo {
1898        &self.info
1899    }
1900    fn frame_count(&self) -> Option<u32> {
1901        Some(self.frame_count)
1902    }
1903    fn loop_count(&self) -> Option<u32> {
1904        Some(0)
1905    }
1906
1907    fn render_next_frame(
1908        &mut self,
1909        stop: Option<&dyn zencodec::Pulse>,
1910    ) -> Result<Option<AnimationFrame<'_>>, RefError> {
1911        if let Some(s) = stop {
1912            s.check()?;
1913        }
1914        if self.next_frame >= self.frame_count {
1915            return Ok(None);
1916        }
1917        let row_bytes = self.width as usize * self.bpp as usize;
1918        let frame_bytes = 4 + self.height as usize * row_bytes;
1919        let base = self.frames_offset + self.next_frame as usize * frame_bytes;
1920        let dur = read_u32(&self.data, base)?;
1921        let start = base + 4;
1922        let span = self.height as usize * row_bytes;
1923        let bytes = self
1924            .data
1925            .get(start..start + span)
1926            .ok_or_else(|| RefError::Invalid("truncated frame".into()))?;
1927        let mut ps = PixelSlice::new(bytes, self.width, self.height, row_bytes, self.desc)
1928            .map_err(|e| RefError::Invalid(format!("frame slice: {e}")))?;
1929        if let Some(ctx) = &self.ctx {
1930            ps = ps.with_color_context(ctx.clone());
1931        }
1932        let frame = AnimationFrame::new(ps, dur, self.next_frame);
1933        self.next_frame += 1;
1934        Ok(Some(frame))
1935    }
1936
1937    fn render_next_frame_to_sink(
1938        &mut self,
1939        stop: Option<&dyn zencodec::Pulse>,
1940        sink: &mut dyn DecodeRowSink,
1941    ) -> Result<Option<OutputInfo>, RefError> {
1942        zencodec::helpers::copy_frame_to_sink(self, stop, sink)
1943    }
1944}