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
85use crate::cms::ColorManagement;
86use crate::error::ConvertError;
87use crate::hdr::HdrMetadata;
88use crate::{
89    Cicp, ColorAuthority, ColorOrigin, ColorPrimaries, PixelBuffer, PixelDescriptor, PixelFormat,
90    PixelSlice, TransferFunction,
91};
92use whereat::{At, ResultAtExt};
93
94/// Target output color profile.
95#[derive(Clone, Debug)]
96#[non_exhaustive]
97pub enum OutputProfile {
98    /// Re-encode with the original ICC/CICP from the source file.
99    SameAsOrigin,
100    /// Use a well-known CICP-described profile.
101    Named(Cicp),
102    /// Use specific ICC profile bytes.
103    Icc(Arc<[u8]>),
104}
105
106/// Metadata that the encoder should embed alongside the pixel data.
107///
108/// Generated atomically by [`finalize_for_output`] to guarantee that
109/// the metadata matches the pixel values.
110#[derive(Clone, Debug)]
111#[non_exhaustive]
112pub struct OutputMetadata {
113    /// ICC profile bytes to embed, if any.
114    pub icc: Option<Arc<[u8]>>,
115    /// CICP code points to embed, if any.
116    pub cicp: Option<Cicp>,
117    /// HDR metadata to embed (content light level, mastering display), if any.
118    pub hdr: Option<HdrMetadata>,
119}
120
121/// Pixel data bundled with matching metadata, ready for encoding.
122///
123/// The only way to create an `EncodeReady` is through [`finalize_for_output`],
124/// which guarantees that the pixels and metadata are consistent.
125///
126/// Use [`into_parts()`](Self::into_parts) to destructure if needed, but
127/// the default path keeps them coupled.
128#[non_exhaustive]
129pub struct EncodeReady {
130    pixels: PixelBuffer,
131    metadata: OutputMetadata,
132}
133
134impl EncodeReady {
135    /// Borrow the pixel data.
136    pub fn pixels(&self) -> PixelSlice<'_> {
137        self.pixels.as_slice()
138    }
139
140    /// Borrow the output metadata.
141    pub fn metadata(&self) -> &OutputMetadata {
142        &self.metadata
143    }
144
145    /// Consume and split into pixel buffer and metadata.
146    pub fn into_parts(self) -> (PixelBuffer, OutputMetadata) {
147        (self.pixels, self.metadata)
148    }
149}
150
151/// Atomically convert pixel data and generate matching encoder metadata.
152///
153/// This function does three things as a single operation:
154///
155/// 1. Determines the current pixel color state from `PixelDescriptor` +
156///    optional ICC profile on `ColorContext`.
157/// 2. Converts pixels to the target profile's space. For named profiles,
158///    uses hardcoded matrices. For custom ICC profiles, uses the CMS.
159/// 3. Bundles the converted pixels with matching metadata ([`EncodeReady`]).
160///
161/// # Arguments
162///
163/// - `buffer` — Source pixel data with its current descriptor.
164/// - `origin` — How the source file described its color (for `SameAsOrigin`).
165/// - `target` — Desired output color profile.
166/// - `pixel_format` — Target pixel format for the output.
167/// - `cms` — Color management system for ICC profile transforms.
168///
169/// # Errors
170///
171/// Returns [`ConvertError`] if:
172/// - The target format requires a conversion that isn't supported.
173/// - The CMS fails to build a transform for ICC profiles.
174/// - Buffer allocation fails.
175#[track_caller]
176pub fn finalize_for_output<C: ColorManagement>(
177    buffer: &PixelBuffer,
178    origin: &ColorOrigin,
179    target: OutputProfile,
180    pixel_format: PixelFormat,
181    cms: &C,
182) -> Result<EncodeReady, At<ConvertError>> {
183    let source_desc = buffer.descriptor();
184    let target_desc = pixel_format.descriptor();
185
186    // Reject HDR→SDR without tone mapping.
187    if origin_has_hdr_transfer(origin) && !target_has_hdr_transfer(&target) {
188        return Err(whereat::at!(ConvertError::HdrTransferRequiresToneMapping));
189    }
190
191    // Determine output metadata based on target profile.
192    let (metadata, needs_cms_transform) = match &target {
193        OutputProfile::SameAsOrigin => {
194            let metadata = OutputMetadata {
195                icc: origin.icc.clone(),
196                cicp: origin.cicp,
197                hdr: None,
198            };
199            // SameAsOrigin = keep the source color space. No CMS conversion.
200            // Pixel format changes (depth, layout) are handled by RowConverter.
201            (metadata, false)
202        }
203        OutputProfile::Named(cicp) => {
204            let metadata = OutputMetadata {
205                icc: None,
206                cicp: Some(*cicp),
207                hdr: None,
208            };
209            (metadata, false)
210        }
211        OutputProfile::Icc(icc) => {
212            let metadata = OutputMetadata {
213                icc: Some(icc.clone()),
214                cicp: None,
215                hdr: None,
216            };
217            (metadata, true)
218        }
219    };
220
221    // Apply CMS transform if needed, respecting color_authority.
222    if needs_cms_transform
223        && let Some(transform) =
224            build_cms_transform(origin, &metadata, &source_desc, pixel_format, cms)?
225    {
226        let src_slice = buffer.as_slice();
227        let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc)
228            .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
229
230        {
231            let mut dst_slice = out.as_slice_mut();
232            for y in 0..buffer.height() {
233                let src_row = src_slice.row(y);
234                let dst_row = dst_slice.row_mut(y);
235                transform.transform_row(src_row, dst_row, buffer.width());
236            }
237        }
238
239        return Ok(EncodeReady {
240            pixels: out,
241            metadata,
242        });
243    }
244
245    // Named profile conversion: use hardcoded matrices via RowConverter.
246    let target_desc_full = target_desc
247        .with_transfer(resolve_transfer(&target, &source_desc))
248        .with_primaries(resolve_primaries(&target, &source_desc));
249
250    if source_desc.layout_compatible(target_desc_full)
251        && descriptors_match(&source_desc, &target_desc_full)
252    {
253        // No conversion needed — copy the buffer.
254        let src_slice = buffer.as_slice();
255        let bytes = src_slice.contiguous_bytes();
256        let out = PixelBuffer::from_vec(
257            bytes.into_owned(),
258            buffer.width(),
259            buffer.height(),
260            target_desc_full,
261        )
262        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
263        return Ok(EncodeReady {
264            pixels: out,
265            metadata,
266        });
267    }
268
269    // Use RowConverter for format conversion.
270    let mut converter = crate::RowConverter::new(source_desc, target_desc_full).at()?;
271    let src_slice = buffer.as_slice();
272    let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc_full)
273        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
274
275    {
276        let mut dst_slice = out.as_slice_mut();
277        for y in 0..buffer.height() {
278            let src_row = src_slice.row(y);
279            let dst_row = dst_slice.row_mut(y);
280            converter.convert_row(src_row, dst_row, buffer.width());
281        }
282    }
283
284    Ok(EncodeReady {
285        pixels: out,
286        metadata,
287    })
288}
289
290/// Build a CMS transform from the origin's authoritative color metadata.
291///
292/// Respects [`ColorAuthority`]: when `Icc`, builds from ICC bytes; when `Cicp`,
293/// builds from CICP codes via [`build_transform_from_cicp`](ColorManagement::build_transform_from_cicp).
294///
295/// Falls back to the non-authoritative field when the authoritative one is
296/// missing (e.g., `Icc` authority but no ICC → tries CICP). This handles
297/// codec bugs (wrong authority) and incomplete metadata gracefully.
298///
299/// Returns `Ok(None)` when no source profile can be determined.
300fn build_cms_transform<C: ColorManagement>(
301    origin: &ColorOrigin,
302    metadata: &OutputMetadata,
303    source_desc: &PixelDescriptor,
304    dst_format: PixelFormat,
305    cms: &C,
306) -> Result<Option<alloc::boxed::Box<dyn crate::cms::RowTransform>>, At<ConvertError>> {
307    let src_format = source_desc.format;
308    let Some(ref dst_icc) = metadata.icc else {
309        return Ok(None);
310    };
311
312    // Try the authoritative source first, then fall back to the other field.
313    match origin.color_authority {
314        ColorAuthority::Icc => {
315            if let Some(ref src_icc) = origin.icc {
316                let transform = cms
317                    .build_transform_for_format(src_icc, dst_icc, src_format, dst_format)
318                    .map_err(|e| whereat::at!(ConvertError::CmsError(alloc::format!("{e:?}"))))?;
319                return Ok(Some(transform));
320            }
321            // Fallback: ICC authority but no ICC — try CICP if available.
322            if let Some(cicp) = origin.cicp {
323                let transform = cms
324                    .build_transform_from_cicp(cicp, dst_icc, src_format, dst_format)
325                    .map_err(|e| whereat::at!(ConvertError::CmsError(alloc::format!("{e:?}"))))?;
326                return Ok(Some(transform));
327            }
328            Ok(None)
329        }
330        ColorAuthority::Cicp => {
331            if let Some(cicp) = origin.cicp {
332                let transform = cms
333                    .build_transform_from_cicp(cicp, dst_icc, src_format, dst_format)
334                    .map_err(|e| whereat::at!(ConvertError::CmsError(alloc::format!("{e:?}"))))?;
335                return Ok(Some(transform));
336            }
337            // Fallback: CICP authority but no CICP — try ICC if available.
338            if let Some(ref src_icc) = origin.icc {
339                let transform = cms
340                    .build_transform_for_format(src_icc, dst_icc, src_format, dst_format)
341                    .map_err(|e| whereat::at!(ConvertError::CmsError(alloc::format!("{e:?}"))))?;
342                return Ok(Some(transform));
343            }
344            Ok(None)
345        }
346    }
347}
348
349/// Check if the origin describes HDR content (PQ or HLG transfer).
350fn origin_has_hdr_transfer(origin: &ColorOrigin) -> bool {
351    origin
352        .cicp
353        .is_some_and(|c| matches!(c.transfer_characteristics, 16 | 18))
354}
355
356/// Check if the output target is HDR-capable (PQ or HLG).
357fn target_has_hdr_transfer(target: &OutputProfile) -> bool {
358    match target {
359        OutputProfile::SameAsOrigin => true, // same as source — if source is HDR, output is too
360        OutputProfile::Named(cicp) => matches!(cicp.transfer_characteristics, 16 | 18),
361        OutputProfile::Icc(_) => false, // can't determine from ICC bytes alone — assume SDR
362    }
363}
364
365/// Resolve the target transfer function.
366fn resolve_transfer(target: &OutputProfile, source: &PixelDescriptor) -> TransferFunction {
367    match target {
368        OutputProfile::SameAsOrigin => source.transfer(),
369        OutputProfile::Named(cicp) => TransferFunction::from_cicp(cicp.transfer_characteristics)
370            .unwrap_or(TransferFunction::Unknown),
371        OutputProfile::Icc(_) => TransferFunction::Unknown,
372    }
373}
374
375/// Resolve the target color primaries.
376fn resolve_primaries(target: &OutputProfile, source: &PixelDescriptor) -> ColorPrimaries {
377    match target {
378        OutputProfile::SameAsOrigin => source.primaries,
379        OutputProfile::Named(cicp) => {
380            ColorPrimaries::from_cicp(cicp.color_primaries).unwrap_or(ColorPrimaries::Unknown)
381        }
382        OutputProfile::Icc(_) => ColorPrimaries::Unknown,
383    }
384}
385
386/// Check if two descriptors match in all conversion-relevant fields.
387fn descriptors_match(a: &PixelDescriptor, b: &PixelDescriptor) -> bool {
388    a.format == b.format
389        && a.transfer == b.transfer
390        && a.primaries == b.primaries
391        && a.signal_range == b.signal_range
392}