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