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