Skip to main content

zenpixels_convert/
output.rs

1//! Atomic output preparation for encoders.
2//!
3//! [`finalize_for_output`] converts pixel data and generates matching metadata
4//! in a single atomic operation, preventing the most common color management
5//! bug: pixel values that don't match the embedded color metadata.
6//!
7//! # Why atomicity matters
8//!
9//! The most common color management bug looks like this:
10//!
11//! ```rust,ignore
12//! // BUG: pixels and metadata can diverge
13//! let pixels = convert_to_p3(&buffer);
14//! let metadata = OutputMetadata { icc: Some(srgb_icc), .. };
15//! // ^^^ pixels are Display P3 but metadata says sRGB — wrong!
16//! ```
17//!
18//! [`finalize_for_output`] prevents this by producing the pixels and metadata
19//! together. The [`EncodeReady`] struct bundles both, and the only way to
20//! create one is through this function. If the conversion fails, neither
21//! pixels nor metadata are produced.
22//!
23//! # Usage
24//!
25//! ```rust,ignore
26//! use zenpixels_convert::{
27//!     finalize_for_output, OutputProfile, PixelFormat,
28//! };
29//!
30//! let ready = finalize_for_output(
31//!     &buffer,              // source pixel data
32//!     &color_origin,        // how the source described its color
33//!     OutputProfile::SameAsOrigin,  // re-embed original metadata
34//!     PixelFormat::Rgb8,    // target byte layout
35//!     &cms,                 // CMS impl (for ICC transforms)
36//! )?;
37//!
38//! // Write pixels — these match the metadata
39//! encoder.write_pixels(ready.pixels())?;
40//!
41//! // Embed color metadata — guaranteed to match the pixels
42//! if let Some(icc) = &ready.metadata().icc {
43//!     encoder.write_icc_chunk(icc)?;
44//! }
45//! if let Some(cicp) = &ready.metadata().cicp {
46//!     encoder.write_cicp(cicp)?;
47//! }
48//! if let Some(hdr) = &ready.metadata().hdr {
49//!     encoder.write_hdr_metadata(hdr)?;
50//! }
51//! ```
52//!
53//! # Output profiles
54//!
55//! [`OutputProfile`] controls what color space the output should be in:
56//!
57//! - **`SameAsOrigin`**: Re-embed the original ICC/CICP from the source file.
58//!   Pixels are converted only if the target pixel format differs from the
59//!   source. Used for transcoding without color changes. If the source had
60//!   an ICC profile, it is passed through; if CICP, the CICP codes are
61//!   preserved.
62//!
63//! - **`Named(cicp)`**: Convert to a well-known CICP profile (sRGB, Display P3,
64//!   BT.2020, PQ, HLG). Uses hardcoded 3×3 gamut matrices and transfer
65//!   function conversion via `RowConverter` — no CMS needed. Fast and
66//!   deterministic.
67//!
68//! - **`Icc(bytes)`**: Convert to a specific ICC profile. Requires a
69//!   [`ColorManagement`] implementation to build the source→destination
70//!   transform. Use this for print workflows, custom profiles, or any
71//!   profile that isn't a standard CICP combination.
72//!
73//! # CMS requirement
74//!
75//! The `cms` parameter is only used when:
76//! - `OutputProfile::Icc` is selected, or
77//! - `OutputProfile::SameAsOrigin` and the source has an ICC profile.
78//!
79//! For `OutputProfile::Named`, the CMS is unused — gamut conversion uses
80//! hardcoded matrices. Codecs that don't need ICC support can pass a
81//! no-op CMS implementation.
82
83use alloc::sync::Arc;
84
85#[allow(deprecated)]
86use crate::cms::ColorManagement;
87use crate::error::ConvertError;
88use crate::hdr::HdrMetadata;
89use crate::{
90    Cicp, ColorAuthority, ColorOrigin, ColorPrimaries, PixelBuffer, PixelDescriptor, PixelFormat,
91    PixelSlice, TransferFunction,
92};
93use whereat::{At, ResultAtExt};
94
95/// Target output color profile.
96#[derive(Clone, Debug)]
97#[non_exhaustive]
98pub enum OutputProfile {
99    /// Re-encode with the original ICC/CICP from the source file.
100    SameAsOrigin,
101    /// Use a well-known CICP-described profile.
102    Named(Cicp),
103    /// Use specific ICC profile bytes.
104    Icc(Arc<[u8]>),
105}
106
107// TODO(0.3.0): Add HdrPolicy enum and ConvertOutputOptions here once
108// ConvertError is #[non_exhaustive] and can carry HdrTransferRequiresToneMapping.
109// See imazen/zenpixels#10 for the full HDR provenance plan.
110
111/// Metadata that the encoder should embed alongside the pixel data.
112///
113/// Generated atomically by [`finalize_for_output`] to guarantee that
114/// the metadata matches the pixel values.
115#[derive(Clone, Debug)]
116#[non_exhaustive]
117pub struct OutputMetadata {
118    /// ICC profile bytes to embed, if any.
119    pub icc: Option<Arc<[u8]>>,
120    /// CICP code points to embed, if any.
121    pub cicp: Option<Cicp>,
122    /// HDR metadata to embed (content light level, mastering display), if any.
123    pub hdr: Option<HdrMetadata>,
124}
125
126/// Pixel data bundled with matching metadata, ready for encoding.
127///
128/// The only way to create an `EncodeReady` is through [`finalize_for_output`],
129/// which guarantees that the pixels and metadata are consistent.
130///
131/// Use [`into_parts()`](Self::into_parts) to destructure if needed, but
132/// the default path keeps them coupled.
133#[non_exhaustive]
134pub struct EncodeReady {
135    pixels: PixelBuffer,
136    metadata: OutputMetadata,
137}
138
139impl EncodeReady {
140    /// Borrow the pixel data.
141    pub fn pixels(&self) -> PixelSlice<'_> {
142        self.pixels.as_slice()
143    }
144
145    /// Borrow the output metadata.
146    pub fn metadata(&self) -> &OutputMetadata {
147        &self.metadata
148    }
149
150    /// Consume and split into pixel buffer and metadata.
151    pub fn into_parts(self) -> (PixelBuffer, OutputMetadata) {
152        (self.pixels, self.metadata)
153    }
154}
155
156/// Atomically convert pixel data and generate matching encoder metadata.
157///
158/// This function does three things as a single operation:
159///
160/// 1. Determines the current pixel color state from `PixelDescriptor` +
161///    optional ICC profile on `ColorContext`.
162/// 2. Converts pixels to the target profile's space. For named profiles,
163///    uses hardcoded matrices. For custom ICC profiles, uses the CMS.
164/// 3. Bundles the converted pixels with matching metadata ([`EncodeReady`]).
165///
166/// # Arguments
167///
168/// - `buffer` — Source pixel data with its current descriptor.
169/// - `origin` — How the source file described its color (for `SameAsOrigin`).
170/// - `target` — Desired output color profile.
171/// - `pixel_format` — Target pixel format for the output.
172/// - `cms` — Color management system for ICC profile transforms.
173///
174/// # Errors
175///
176/// Returns [`ConvertError`] if:
177/// - The target format requires a conversion that isn't supported.
178/// - The CMS fails to build a transform for ICC profiles.
179/// - Buffer allocation fails.
180// TODO(0.3.0): Add HDR→SDR policy gate here once ConvertError has
181// HdrTransferRequiresToneMapping. See imazen/zenpixels#10.
182#[track_caller]
183#[deprecated(
184    since = "0.2.8",
185    note = "use finalize_for_output_with with a PluggableCms"
186)]
187#[allow(deprecated)]
188pub fn finalize_for_output<C: ColorManagement>(
189    buffer: &PixelBuffer,
190    origin: &ColorOrigin,
191    target: OutputProfile,
192    pixel_format: PixelFormat,
193    cms: &C,
194) -> Result<EncodeReady, At<ConvertError>> {
195    let source_desc = buffer.descriptor();
196    let target_desc = pixel_format.descriptor();
197
198    // Determine output metadata based on target profile.
199    let (metadata, needs_cms_transform) = match &target {
200        OutputProfile::SameAsOrigin => {
201            let metadata = OutputMetadata {
202                icc: origin.icc.clone(),
203                cicp: origin.cicp,
204                hdr: None,
205            };
206            // SameAsOrigin = keep the source color space. No CMS conversion.
207            // Pixel format changes (depth, layout) are handled by RowConverter.
208            (metadata, false)
209        }
210        OutputProfile::Named(cicp) => {
211            let metadata = OutputMetadata {
212                icc: None,
213                cicp: Some(*cicp),
214                hdr: None,
215            };
216            (metadata, false)
217        }
218        OutputProfile::Icc(icc) => {
219            let metadata = OutputMetadata {
220                icc: Some(icc.clone()),
221                cicp: None,
222                hdr: None,
223            };
224            (metadata, true)
225        }
226    };
227
228    // Apply CMS transform if needed, respecting color_authority.
229    if needs_cms_transform
230        && let Some(transform) =
231            build_cms_transform(origin, &metadata, &source_desc, pixel_format, cms)?
232    {
233        let src_slice = buffer.as_slice();
234        let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc)
235            .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
236
237        {
238            let mut dst_slice = out.as_slice_mut();
239            for y in 0..buffer.height() {
240                let src_row = src_slice.row(y);
241                let dst_row = dst_slice.row_mut(y);
242                transform.transform_row(src_row, dst_row, buffer.width());
243            }
244        }
245
246        return Ok(EncodeReady {
247            pixels: out,
248            metadata,
249        });
250    }
251
252    // Named profile conversion: use hardcoded matrices via RowConverter.
253    let target_desc_full = target_desc
254        .with_transfer(resolve_transfer(&target, &source_desc))
255        .with_primaries(resolve_primaries(&target, &source_desc));
256
257    if source_desc.layout_compatible(target_desc_full)
258        && descriptors_match(&source_desc, &target_desc_full)
259    {
260        // No conversion needed — copy the buffer.
261        let src_slice = buffer.as_slice();
262        let bytes = src_slice.contiguous_bytes();
263        let out = PixelBuffer::from_vec(
264            bytes.into_owned(),
265            buffer.width(),
266            buffer.height(),
267            target_desc_full,
268        )
269        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
270        return Ok(EncodeReady {
271            pixels: out,
272            metadata,
273        });
274    }
275
276    // Use RowConverter for format conversion.
277    let mut converter = crate::RowConverter::new(source_desc, target_desc_full).at()?;
278    let src_slice = buffer.as_slice();
279    let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc_full)
280        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
281
282    {
283        let mut dst_slice = out.as_slice_mut();
284        for y in 0..buffer.height() {
285            let src_row = src_slice.row(y);
286            let dst_row = dst_slice.row_mut(y);
287            converter.convert_row(src_row, dst_row, buffer.width());
288        }
289    }
290
291    Ok(EncodeReady {
292        pixels: out,
293        metadata,
294    })
295}
296
297/// Build a CMS transform from the origin's color metadata.
298///
299/// Respects [`ColorAuthority`]: when `Icc`, builds from ICC bytes; when `Cicp`,
300/// builds from CICP codes via the CMS's `build_transform_from_cicp`. Falls
301/// back to the non-authoritative field when the authoritative one is missing.
302///
303/// Returns `Ok(None)` when no source profile can be determined.
304#[allow(deprecated)]
305fn build_cms_transform<C: ColorManagement>(
306    origin: &ColorOrigin,
307    metadata: &OutputMetadata,
308    source_desc: &PixelDescriptor,
309    dst_format: PixelFormat,
310    cms: &C,
311) -> Result<Option<alloc::boxed::Box<dyn crate::cms::RowTransform>>, At<ConvertError>> {
312    let src_format = source_desc.format;
313    let Some(ref dst_icc) = metadata.icc else {
314        return Ok(None);
315    };
316
317    // Try ICC path first (or second, depending on authority).
318    let try_icc = |src_icc: &[u8]| -> Result<Option<_>, At<ConvertError>> {
319        let transform = cms
320            .build_transform_for_format(src_icc, dst_icc, src_format, dst_format)
321            .map_err(|e| whereat::at!(ConvertError::CmsError(alloc::format!("{e:?}"))))?;
322        Ok(Some(transform))
323    };
324
325    match origin.color_authority {
326        ColorAuthority::Icc => {
327            if let Some(ref src_icc) = origin.icc {
328                return try_icc(src_icc);
329            }
330            // Fallback: ICC authority but no ICC bytes — can't build transform.
331            Ok(None)
332        }
333        ColorAuthority::Cicp => {
334            // CICP authority — but build_transform_from_cicp needs ICC bytes
335            // on the dst side, so we still need ICC. Try src ICC if available.
336            if let Some(ref src_icc) = origin.icc {
337                return try_icc(src_icc);
338            }
339            Ok(None)
340        }
341    }
342}
343
344// TODO(0.3.0): restore origin_has_hdr_transfer / target_has_hdr_transfer
345// helpers here for the HDR→SDR policy gate.
346
347/// Finalize a pixel buffer for output using the [`PluggableCms`](crate::cms::PluggableCms) dispatch chain.
348///
349/// Modern replacement for [`finalize_for_output`].
350///
351/// When a CMS plugin is supplied, it is offered the conversion first; on
352/// decline the built-in `ZenCmsLite` dispatcher handles named-profile
353/// matlut fast paths. When `cms` is `None`, only `ZenCmsLite` runs.
354///
355/// Pass `cms = Some(&MoxCms)` (or another `PluggableCms`) for full ICC
356/// support; pass `None` for named-profile-only builds that avoid pulling
357/// in a full CMS dependency.
358///
359/// # Errors
360///
361/// Returns [`ConvertError`] when no conversion path is available, buffer
362/// allocation fails, or a policy gate (alpha/depth/luma) forbids a
363/// required operation.
364#[track_caller]
365pub fn finalize_for_output_with(
366    buffer: &PixelBuffer,
367    origin: &ColorOrigin,
368    target: OutputProfile,
369    pixel_format: PixelFormat,
370    cms: Option<&dyn crate::cms::PluggableCms>,
371) -> Result<EncodeReady, At<ConvertError>> {
372    let _ = origin; // reserved for future HDR/intent policy gate
373    let source_desc = buffer.descriptor();
374    let target_desc = pixel_format.descriptor();
375
376    // Determine output metadata based on target profile.
377    let metadata = match &target {
378        OutputProfile::SameAsOrigin => OutputMetadata {
379            icc: origin.icc.clone(),
380            cicp: origin.cicp,
381            hdr: None,
382        },
383        OutputProfile::Named(cicp) => OutputMetadata {
384            icc: None,
385            cicp: Some(*cicp),
386            hdr: None,
387        },
388        OutputProfile::Icc(icc) => OutputMetadata {
389            icc: Some(icc.clone()),
390            cicp: None,
391            hdr: None,
392        },
393    };
394
395    // Build the target descriptor with resolved primaries + transfer so
396    // RowConverter's CMS dispatch chain has a concrete ColorProfileSource
397    // on both sides.
398    let target_desc_full = target_desc
399        .with_transfer(resolve_transfer(&target, &source_desc))
400        .with_primaries(resolve_primaries(&target, &source_desc));
401
402    // Fast path: no conversion needed.
403    if source_desc.layout_compatible(target_desc_full)
404        && descriptors_match(&source_desc, &target_desc_full)
405    {
406        let src_slice = buffer.as_slice();
407        let bytes = src_slice.contiguous_bytes();
408        let out = PixelBuffer::from_vec(
409            bytes.into_owned(),
410            buffer.width(),
411            buffer.height(),
412            target_desc_full,
413        )
414        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
415        return Ok(EncodeReady {
416            pixels: out,
417            metadata,
418        });
419    }
420
421    // Dispatch through RowConverter — plugin (if Some) → ZenCmsLite default.
422    let mut converter = crate::RowConverter::new_explicit_with_cms(
423        source_desc,
424        target_desc_full,
425        &crate::policy::ConvertOptions::permissive(),
426        cms,
427    )?;
428    let src_slice = buffer.as_slice();
429    let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc_full)
430        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
431
432    {
433        let mut dst_slice = out.as_slice_mut();
434        for y in 0..buffer.height() {
435            let src_row = src_slice.row(y);
436            let dst_row = dst_slice.row_mut(y);
437            converter.convert_row(src_row, dst_row, buffer.width());
438        }
439    }
440
441    Ok(EncodeReady {
442        pixels: out,
443        metadata,
444    })
445}
446
447/// Resolve the target transfer function.
448fn resolve_transfer(target: &OutputProfile, source: &PixelDescriptor) -> TransferFunction {
449    match target {
450        OutputProfile::SameAsOrigin => source.transfer(),
451        OutputProfile::Named(cicp) => TransferFunction::from_cicp(cicp.transfer_characteristics)
452            .unwrap_or(TransferFunction::Unknown),
453        OutputProfile::Icc(_) => TransferFunction::Unknown,
454    }
455}
456
457/// Resolve the target color primaries.
458fn resolve_primaries(target: &OutputProfile, source: &PixelDescriptor) -> ColorPrimaries {
459    match target {
460        OutputProfile::SameAsOrigin => source.primaries,
461        OutputProfile::Named(cicp) => {
462            ColorPrimaries::from_cicp(cicp.color_primaries).unwrap_or(ColorPrimaries::Unknown)
463        }
464        OutputProfile::Icc(_) => ColorPrimaries::Unknown,
465    }
466}
467
468/// Check if two descriptors match in all conversion-relevant fields.
469fn descriptors_match(a: &PixelDescriptor, b: &PixelDescriptor) -> bool {
470    a.format == b.format
471        && a.transfer == b.transfer
472        && a.primaries == b.primaries
473        && a.signal_range == b.signal_range
474}