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 — no CMS needed.
65//!   Fast and deterministic.
66//!
67//! - **`Icc(bytes)`**: Convert to a specific ICC profile. Requires a
68//!   [`ColorManagement`] implementation to build the source→destination
69//!   transform. Use this for print workflows, custom profiles, or any
70//!   profile that isn't a standard CICP combination.
71//!
72//! # CMS requirement
73//!
74//! The `cms` parameter is only used when:
75//! - `OutputProfile::Icc` is selected, or
76//! - `OutputProfile::SameAsOrigin` and the source has an ICC profile.
77//!
78//! For `OutputProfile::Named`, the CMS is unused — gamut conversion uses
79//! hardcoded matrices. Codecs that don't need ICC support can pass a
80//! no-op CMS implementation.
81
82use alloc::sync::Arc;
83
84use crate::cms::ColorManagement;
85use crate::error::ConvertError;
86use crate::hdr::HdrMetadata;
87use crate::{
88    Cicp, ColorOrigin, ColorPrimaries, PixelBuffer, PixelDescriptor, PixelFormat, PixelSlice,
89    TransferFunction,
90};
91use whereat::{At, ResultAtExt};
92
93/// Target output color profile.
94#[derive(Clone, Debug)]
95#[non_exhaustive]
96pub enum OutputProfile {
97    /// Re-encode with the original ICC/CICP from the source file.
98    SameAsOrigin,
99    /// Use a well-known CICP-described profile.
100    Named(Cicp),
101    /// Use specific ICC profile bytes.
102    Icc(Arc<[u8]>),
103}
104
105/// Metadata that the encoder should embed alongside the pixel data.
106///
107/// Generated atomically by [`finalize_for_output`] to guarantee that
108/// the metadata matches the pixel values.
109#[derive(Clone, Debug)]
110#[non_exhaustive]
111pub struct OutputMetadata {
112    /// ICC profile bytes to embed, if any.
113    pub icc: Option<Arc<[u8]>>,
114    /// CICP code points to embed, if any.
115    pub cicp: Option<Cicp>,
116    /// HDR metadata to embed (content light level, mastering display), if any.
117    pub hdr: Option<HdrMetadata>,
118}
119
120/// Pixel data bundled with matching metadata, ready for encoding.
121///
122/// The only way to create an `EncodeReady` is through [`finalize_for_output`],
123/// which guarantees that the pixels and metadata are consistent.
124///
125/// Use [`into_parts()`](Self::into_parts) to destructure if needed, but
126/// the default path keeps them coupled.
127#[non_exhaustive]
128pub struct EncodeReady {
129    pixels: PixelBuffer,
130    metadata: OutputMetadata,
131}
132
133impl EncodeReady {
134    /// Borrow the pixel data.
135    pub fn pixels(&self) -> PixelSlice<'_> {
136        self.pixels.as_slice()
137    }
138
139    /// Borrow the output metadata.
140    pub fn metadata(&self) -> &OutputMetadata {
141        &self.metadata
142    }
143
144    /// Consume and split into pixel buffer and metadata.
145    pub fn into_parts(self) -> (PixelBuffer, OutputMetadata) {
146        (self.pixels, self.metadata)
147    }
148}
149
150/// Atomically convert pixel data and generate matching encoder metadata.
151///
152/// This function does three things as a single operation:
153///
154/// 1. Determines the current pixel color state from `PixelDescriptor` +
155///    optional ICC profile on `ColorContext`.
156/// 2. Converts pixels to the target profile's space. For named profiles,
157///    uses hardcoded matrices. For custom ICC profiles, uses the CMS.
158/// 3. Bundles the converted pixels with matching metadata ([`EncodeReady`]).
159///
160/// # Arguments
161///
162/// - `buffer` — Source pixel data with its current descriptor.
163/// - `origin` — How the source file described its color (for `SameAsOrigin`).
164/// - `target` — Desired output color profile.
165/// - `pixel_format` — Target pixel format for the output.
166/// - `cms` — Color management system for ICC profile transforms.
167///
168/// # Errors
169///
170/// Returns [`ConvertError`] if:
171/// - The target format requires a conversion that isn't supported.
172/// - The CMS fails to build a transform for ICC profiles.
173/// - Buffer allocation fails.
174#[track_caller]
175pub fn finalize_for_output<C: ColorManagement>(
176    buffer: &PixelBuffer,
177    origin: &ColorOrigin,
178    target: OutputProfile,
179    pixel_format: PixelFormat,
180    cms: &C,
181) -> Result<EncodeReady, At<ConvertError>> {
182    let source_desc = buffer.descriptor();
183    let target_desc = pixel_format.descriptor();
184
185    // Determine output metadata based on target profile.
186    let (metadata, needs_cms_transform) = match &target {
187        OutputProfile::SameAsOrigin => {
188            let metadata = OutputMetadata {
189                icc: origin.icc.clone(),
190                cicp: origin.cicp,
191                hdr: None,
192            };
193            // If origin has ICC, we may need a CMS transform.
194            (metadata, origin.icc.is_some())
195        }
196        OutputProfile::Named(cicp) => {
197            let metadata = OutputMetadata {
198                icc: None,
199                cicp: Some(*cicp),
200                hdr: None,
201            };
202            (metadata, false)
203        }
204        OutputProfile::Icc(icc) => {
205            let metadata = OutputMetadata {
206                icc: Some(icc.clone()),
207                cicp: None,
208                hdr: None,
209            };
210            (metadata, true)
211        }
212    };
213
214    // If source has an ICC profile and we need CMS, use it.
215    if needs_cms_transform
216        && let Some(src_icc) = buffer.color_context().and_then(|c| c.icc.as_ref())
217        && let Some(dst_icc) = &metadata.icc
218    {
219        let transform = cms
220            .build_transform_for_format(src_icc, dst_icc, source_desc.format, pixel_format)
221            .map_err(|e| whereat::at!(ConvertError::CmsError(alloc::format!("{e:?}"))))?;
222
223        let src_slice = buffer.as_slice();
224        let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc)
225            .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
226
227        {
228            let mut dst_slice = out.as_slice_mut();
229            for y in 0..buffer.height() {
230                let src_row = src_slice.row(y);
231                let dst_row = dst_slice.row_mut(y);
232                transform.transform_row(src_row, dst_row, buffer.width());
233            }
234        }
235
236        return Ok(EncodeReady {
237            pixels: out,
238            metadata,
239        });
240    }
241
242    // Named profile conversion: use hardcoded matrices via RowConverter.
243    let target_desc_full = target_desc
244        .with_transfer(resolve_transfer(&target, &source_desc))
245        .with_primaries(resolve_primaries(&target, &source_desc));
246
247    if source_desc.layout_compatible(target_desc_full)
248        && descriptors_match(&source_desc, &target_desc_full)
249    {
250        // No conversion needed — copy the buffer.
251        let src_slice = buffer.as_slice();
252        let bytes = src_slice.contiguous_bytes();
253        let out = PixelBuffer::from_vec(
254            bytes.into_owned(),
255            buffer.width(),
256            buffer.height(),
257            target_desc_full,
258        )
259        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
260        return Ok(EncodeReady {
261            pixels: out,
262            metadata,
263        });
264    }
265
266    // Use RowConverter for format conversion.
267    let mut converter = crate::RowConverter::new(source_desc, target_desc_full).at()?;
268    let src_slice = buffer.as_slice();
269    let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc_full)
270        .map_err(|_| whereat::at!(ConvertError::AllocationFailed))?;
271
272    {
273        let mut dst_slice = out.as_slice_mut();
274        for y in 0..buffer.height() {
275            let src_row = src_slice.row(y);
276            let dst_row = dst_slice.row_mut(y);
277            converter.convert_row(src_row, dst_row, buffer.width());
278        }
279    }
280
281    Ok(EncodeReady {
282        pixels: out,
283        metadata,
284    })
285}
286
287/// Resolve the target transfer function.
288fn resolve_transfer(target: &OutputProfile, source: &PixelDescriptor) -> TransferFunction {
289    match target {
290        OutputProfile::SameAsOrigin => source.transfer(),
291        OutputProfile::Named(cicp) => TransferFunction::from_cicp(cicp.transfer_characteristics)
292            .unwrap_or(TransferFunction::Unknown),
293        OutputProfile::Icc(_) => TransferFunction::Unknown,
294    }
295}
296
297/// Resolve the target color primaries.
298fn resolve_primaries(target: &OutputProfile, source: &PixelDescriptor) -> ColorPrimaries {
299    match target {
300        OutputProfile::SameAsOrigin => source.primaries,
301        OutputProfile::Named(cicp) => {
302            ColorPrimaries::from_cicp(cicp.color_primaries).unwrap_or(ColorPrimaries::Unknown)
303        }
304        OutputProfile::Icc(_) => ColorPrimaries::Unknown,
305    }
306}
307
308/// Check if two descriptors match in all conversion-relevant fields.
309fn descriptors_match(a: &PixelDescriptor, b: &PixelDescriptor) -> bool {
310    a.format == b.format
311        && a.transfer == b.transfer
312        && a.primaries == b.primaries
313        && a.signal_range == b.signal_range
314}