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//! ```
49//!
50//! # Output profiles
51//!
52//! [`OutputProfile`] controls what color space the output should be in:
53//!
54//! - **`SameAsOrigin`**: Re-embed the original ICC/CICP from the source file.
55//! Pixels are converted only if the target pixel format differs from the
56//! source. Used for transcoding without color changes. If the source had
57//! an ICC profile, it is passed through; if CICP, the CICP codes are
58//! preserved.
59//!
60//! - **`Named(cicp)`**: Convert to a well-known CICP profile (sRGB, Display P3,
61//! BT.2020, PQ, HLG). Uses hardcoded 3×3 gamut matrices and transfer
62//! function conversion via `RowConverter` — no CMS needed. Fast and
63//! deterministic.
64//!
65//! - **`Icc(bytes)`**: Convert to a specific ICC profile. Requires a
66//! [`ColorManagement`] implementation to build the source→destination
67//! transform. Use this for print workflows, custom profiles, or any
68//! profile that isn't a standard CICP combination.
69//!
70//! # CMS requirement
71//!
72//! The `cms` parameter is only used when:
73//! - `OutputProfile::Icc` is selected, or
74//! - `OutputProfile::SameAsOrigin` and the source has an ICC profile.
75//!
76//! For `OutputProfile::Named`, the CMS is unused — gamut conversion uses
77//! hardcoded matrices. Codecs that don't need ICC support can pass a
78//! no-op CMS implementation.
79
80use alloc::sync::Arc;
81
82#[allow(deprecated)]
83use crate::cms::ColorManagement;
84use crate::error::ConvertError;
85#[allow(deprecated)]
86use crate::hdr::HdrMetadata;
87use crate::{
88 Cicp, ColorAuthority, ColorOrigin, ColorPrimaries, PixelBuffer, PixelDescriptor, PixelFormat,
89 PixelSlice, 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// TODO(0.3.0): Add HdrPolicy enum and ConvertOutputOptions here once
106// ConvertError is #[non_exhaustive] and can carry HdrTransferRequiresToneMapping.
107// See imazen/zenpixels#10 for the full HDR provenance plan.
108
109/// Metadata that the encoder should embed alongside the pixel data.
110///
111/// Generated atomically by [`finalize_for_output`] to guarantee that
112/// the metadata matches the pixel values.
113///
114/// # Not the same as `ColorContext` / `ColorOrigin`
115///
116/// Three carriers touch color and overlap on `icc` / `cicp`, but each answers
117/// a different question and lives at a different point in the pipeline:
118///
119/// - [`ColorOrigin`] — *how the source file described its color* (provenance +
120/// which field is authoritative). Immutable, set once at decode, consulted
121/// for re-encode decisions.
122/// - [`ColorContext`](zenpixels::ColorContext) — *what the working pixels are
123/// right now* (the profile needed to interpret their values: `icc` / `cicp`
124/// / `diffuse_white`). Rides on the `PixelSlice` via `Arc` and is rewritten
125/// as conversions change the pixels. It deliberately carries **no** content
126/// light level / mastering display — those don't change how a value is
127/// interpreted, only how a display should present it.
128/// - `OutputMetadata` (this type) — *the color blocks the encoder writes into
129/// the container* (`icc` / `cicp`). It is the lowering target of a codec's
130/// color plan (`zencodec::ColorEmitPlan` is itself just `{ cicp, icc }`) and
131/// mirrors that shape. Its reason to exist as a distinct type is the
132/// [`EncodeReady`] atomicity guarantee: the converted bytes and the embedded
133/// color are produced together and cannot drift apart — which a re-used
134/// `ColorContext` would not give. HDR content descriptors (content light
135/// level, mastering display, `diffuse_white`) are deliberately **not** here:
136/// they are not color-profile data and ride the codec-boundary metadata
137/// carrier (`zencodec::Metadata`, which already holds all three) instead.
138#[derive(Clone, Debug)]
139#[non_exhaustive]
140// The `hdr` field references the deprecated `HdrMetadata`; suppress the
141// definition-site lint here (external uses still see the field/type
142// deprecation). The field is never wired (see TODO(0.3.0) below).
143#[allow(deprecated)]
144pub struct OutputMetadata {
145 /// ICC profile bytes to embed, if any.
146 pub icc: Option<Arc<[u8]>>,
147 /// CICP code points to embed, if any.
148 pub cicp: Option<Cicp>,
149 /// HDR metadata to embed (content light level, mastering display), if any.
150 ///
151 /// **Deprecated and never wired** — `finalize_for_output` always sets it to
152 /// `None`. The bundled [`HdrMetadata`](crate::hdr::HdrMetadata) carrier is
153 /// being removed at 0.3.0 (it has frozen public fields and bundles
154 /// `transfer`, which the prior art keeps on the descriptor).
155 ///
156 /// **What replaces it: nothing — and that is correct by design, not a
157 /// stub.** Removing this leaves `OutputMetadata { icc, cicp }`, which
158 /// mirrors the *color* plan a codec lowers here (`zencodec::ColorEmitPlan`
159 /// is itself just `{ cicp, icc }`). The HDR content descriptors — content
160 /// light level, mastering display, and the `diffuse_white` /
161 /// `intensity_target` anchor — are **not** color-profile data: they ride
162 /// the codec-boundary metadata carrier instead. `zencodec::Metadata`
163 /// already carries all three as sibling fields (the un-bundled shape this
164 /// `HdrMetadata` bundle should have been), threaded by its metadata policy,
165 /// and the codec embeds them from there. Nothing ever read them off this
166 /// field — `HdrMetadata` had zero consumers across `~/work`, and zencodec
167 /// routed around it from the start. See `CHANGELOG.md`
168 /// "QUEUED BREAKING CHANGES".
169 #[deprecated(
170 since = "0.2.14",
171 note = "unwired bundled HDR carrier; replaced by sibling content_light_level / mastering_display fields when the encoder path that populates them lands (0.3.0)."
172 )]
173 pub hdr: Option<HdrMetadata>,
174}
175
176/// Pixel data bundled with matching metadata, ready for encoding.
177///
178/// The only way to create an `EncodeReady` is through [`finalize_for_output`],
179/// which guarantees that the pixels and metadata are consistent.
180///
181/// Use [`into_parts()`](Self::into_parts) to destructure if needed, but
182/// the default path keeps them coupled.
183#[non_exhaustive]
184pub struct EncodeReady {
185 pixels: PixelBuffer,
186 metadata: OutputMetadata,
187}
188
189impl EncodeReady {
190 /// Borrow the pixel data.
191 pub fn pixels(&self) -> PixelSlice<'_> {
192 self.pixels.as_slice()
193 }
194
195 /// Borrow the output metadata.
196 pub fn metadata(&self) -> &OutputMetadata {
197 &self.metadata
198 }
199
200 /// Consume and split into pixel buffer and metadata.
201 pub fn into_parts(self) -> (PixelBuffer, OutputMetadata) {
202 (self.pixels, self.metadata)
203 }
204}
205
206/// Atomically convert pixel data and generate matching encoder metadata.
207///
208/// This function does three things as a single operation:
209///
210/// 1. Determines the current pixel color state from `PixelDescriptor` +
211/// optional ICC profile on `ColorContext`.
212/// 2. Converts pixels to the target profile's space. For named profiles,
213/// uses hardcoded matrices. For custom ICC profiles, uses the CMS.
214/// 3. Bundles the converted pixels with matching metadata ([`EncodeReady`]).
215///
216/// # Arguments
217///
218/// - `buffer` — Source pixel data with its current descriptor.
219/// - `origin` — How the source file described its color (for `SameAsOrigin`).
220/// - `target` — Desired output color profile.
221/// - `pixel_format` — Target pixel format for the output.
222/// - `cms` — Color management system for ICC profile transforms.
223///
224/// # Errors
225///
226/// Returns [`ConvertError`] if:
227/// - The target format requires a conversion that isn't supported.
228/// - The CMS fails to build a transform for ICC profiles.
229/// - Buffer allocation fails.
230// TODO(0.3.0): Add HDR→SDR policy gate here once ConvertError has
231// HdrTransferRequiresToneMapping. See imazen/zenpixels#10.
232#[track_caller]
233#[deprecated(
234 since = "0.2.8",
235 note = "use finalize_for_output_with with a PluggableCms"
236)]
237#[allow(deprecated)]
238pub fn finalize_for_output<C: ColorManagement>(
239 buffer: &PixelBuffer,
240 origin: &ColorOrigin,
241 target: OutputProfile,
242 pixel_format: PixelFormat,
243 cms: &C,
244) -> Result<EncodeReady, At<ConvertError>> {
245 let source_desc = buffer.descriptor();
246 let target_desc = pixel_format.descriptor();
247
248 // Determine output metadata based on target profile.
249 let (metadata, needs_cms_transform) = match &target {
250 OutputProfile::SameAsOrigin => {
251 let metadata = OutputMetadata {
252 icc: origin.icc.clone(),
253 cicp: origin.cicp,
254 hdr: None,
255 };
256 // SameAsOrigin = keep the source color space. No CMS conversion.
257 // Pixel format changes (depth, layout) are handled by RowConverter.
258 (metadata, false)
259 }
260 OutputProfile::Named(cicp) => {
261 let metadata = OutputMetadata {
262 icc: None,
263 cicp: Some(*cicp),
264 hdr: None,
265 };
266 (metadata, false)
267 }
268 OutputProfile::Icc(icc) => {
269 let metadata = OutputMetadata {
270 icc: Some(icc.clone()),
271 cicp: None,
272 hdr: None,
273 };
274 (metadata, true)
275 }
276 };
277
278 // Apply CMS transform if needed, respecting color_authority.
279 if needs_cms_transform
280 && let Some(transform) =
281 build_cms_transform(origin, &metadata, &source_desc, pixel_format, cms)?
282 {
283 let src_slice = buffer.as_slice();
284 let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc)
285 .map_err_at(ConvertError::from)?;
286
287 {
288 let mut dst_slice = out.as_slice_mut();
289 for y in 0..buffer.height() {
290 let src_row = src_slice.row(y);
291 let dst_row = dst_slice.row_mut(y);
292 transform.transform_row(src_row, dst_row, buffer.width());
293 }
294 }
295
296 return Ok(EncodeReady {
297 pixels: out,
298 metadata,
299 });
300 }
301
302 // Named profile conversion: use hardcoded matrices via RowConverter.
303 let target_desc_full = target_desc
304 .with_transfer(resolve_transfer(&target, &source_desc))
305 .with_primaries(resolve_primaries(&target, &source_desc));
306
307 if source_desc.layout_compatible(target_desc_full)
308 && descriptors_match(&source_desc, &target_desc_full)
309 {
310 // No conversion needed — copy the buffer.
311 let src_slice = buffer.as_slice();
312 let bytes = src_slice.contiguous_bytes();
313 let out = PixelBuffer::from_vec(
314 bytes.into_owned(),
315 buffer.width(),
316 buffer.height(),
317 target_desc_full,
318 )
319 .map_err_at(ConvertError::from)?;
320 return Ok(EncodeReady {
321 pixels: out,
322 metadata,
323 });
324 }
325
326 // Use RowConverter for format conversion.
327 let mut converter = crate::RowConverter::new(source_desc, target_desc_full).at()?;
328 let src_slice = buffer.as_slice();
329 let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc_full)
330 .map_err_at(ConvertError::from)?;
331
332 {
333 let mut dst_slice = out.as_slice_mut();
334 for y in 0..buffer.height() {
335 let src_row = src_slice.row(y);
336 let dst_row = dst_slice.row_mut(y);
337 converter.convert_row(src_row, dst_row, buffer.width());
338 }
339 }
340
341 Ok(EncodeReady {
342 pixels: out,
343 metadata,
344 })
345}
346
347/// Build a CMS transform from the origin's color metadata.
348///
349/// Respects [`ColorAuthority`]: when `Icc`, builds from ICC bytes; when `Cicp`,
350/// builds from CICP codes via the CMS's `build_transform_from_cicp`. Falls
351/// back to the non-authoritative field when the authoritative one is missing.
352///
353/// Returns `Ok(None)` when no source profile can be determined.
354#[allow(deprecated)]
355fn build_cms_transform<C: ColorManagement>(
356 origin: &ColorOrigin,
357 metadata: &OutputMetadata,
358 source_desc: &PixelDescriptor,
359 dst_format: PixelFormat,
360 cms: &C,
361) -> Result<Option<alloc::boxed::Box<dyn crate::cms::RowTransform>>, At<ConvertError>> {
362 let src_format = source_desc.format;
363 let Some(ref dst_icc) = metadata.icc else {
364 return Ok(None);
365 };
366
367 // Try ICC path first (or second, depending on authority).
368 let try_icc = |src_icc: &[u8]| -> Result<Option<_>, At<ConvertError>> {
369 let transform = cms
370 .build_transform_for_format(src_icc, dst_icc, src_format, dst_format)
371 .map_err(|e| whereat::at!(ConvertError::CmsError(alloc::format!("{e:?}"))))?;
372 Ok(Some(transform))
373 };
374
375 match origin.color_authority {
376 ColorAuthority::Icc => {
377 if let Some(ref src_icc) = origin.icc {
378 return try_icc(src_icc);
379 }
380 // Fallback: ICC authority but no ICC bytes — can't build transform.
381 Ok(None)
382 }
383 ColorAuthority::Cicp => {
384 // CICP authority — but build_transform_from_cicp needs ICC bytes
385 // on the dst side, so we still need ICC. Try src ICC if available.
386 if let Some(ref src_icc) = origin.icc {
387 return try_icc(src_icc);
388 }
389 Ok(None)
390 }
391 }
392}
393
394// TODO(0.3.0): restore origin_has_hdr_transfer / target_has_hdr_transfer
395// helpers here for the HDR→SDR policy gate.
396
397/// Finalize a pixel buffer for output using the [`PluggableCms`](crate::cms::PluggableCms) dispatch chain.
398///
399/// Modern replacement for [`finalize_for_output`].
400///
401/// When a CMS plugin is supplied, it is offered the conversion first; on
402/// decline the built-in `ZenCmsLite` dispatcher handles named-profile
403/// matlut fast paths. When `cms` is `None`, only `ZenCmsLite` runs.
404///
405/// Pass `cms = Some(&MoxCms)` (or another `PluggableCms`) for full ICC
406/// support; pass `None` for named-profile-only builds that avoid pulling
407/// in a full CMS dependency.
408///
409/// # Errors
410///
411/// Returns [`ConvertError`] when no conversion path is available, buffer
412/// allocation fails, or a policy gate (alpha/depth/luma) forbids a
413/// required operation.
414#[track_caller]
415#[allow(deprecated)] // sets the unwired, deprecated OutputMetadata::hdr to None
416pub fn finalize_for_output_with(
417 buffer: &PixelBuffer,
418 origin: &ColorOrigin,
419 target: OutputProfile,
420 pixel_format: PixelFormat,
421 cms: Option<&dyn crate::cms::PluggableCms>,
422) -> Result<EncodeReady, At<ConvertError>> {
423 let _ = origin; // reserved for future HDR/intent policy gate
424 let source_desc = buffer.descriptor();
425 let target_desc = pixel_format.descriptor();
426
427 // Determine output metadata based on target profile.
428 let metadata = match &target {
429 OutputProfile::SameAsOrigin => OutputMetadata {
430 icc: origin.icc.clone(),
431 cicp: origin.cicp,
432 hdr: None,
433 },
434 OutputProfile::Named(cicp) => OutputMetadata {
435 icc: None,
436 cicp: Some(*cicp),
437 hdr: None,
438 },
439 OutputProfile::Icc(icc) => OutputMetadata {
440 icc: Some(icc.clone()),
441 cicp: None,
442 hdr: None,
443 },
444 };
445
446 // Build the target descriptor with resolved primaries + transfer so
447 // RowConverter's CMS dispatch chain has a concrete ColorProfileSource
448 // on both sides.
449 let target_desc_full = target_desc
450 .with_transfer(resolve_transfer(&target, &source_desc))
451 .with_primaries(resolve_primaries(&target, &source_desc));
452
453 // Fast path: no conversion needed.
454 if source_desc.layout_compatible(target_desc_full)
455 && descriptors_match(&source_desc, &target_desc_full)
456 {
457 let src_slice = buffer.as_slice();
458 let bytes = src_slice.contiguous_bytes();
459 let out = PixelBuffer::from_vec(
460 bytes.into_owned(),
461 buffer.width(),
462 buffer.height(),
463 target_desc_full,
464 )
465 .map_err_at(ConvertError::from)?;
466 return Ok(EncodeReady {
467 pixels: out,
468 metadata,
469 });
470 }
471
472 // Dispatch through RowConverter — plugin (if Some) → ZenCmsLite default.
473 let mut converter = crate::RowConverter::new_explicit_with_cms(
474 source_desc,
475 target_desc_full,
476 &crate::policy::ConvertOptions::permissive(),
477 cms,
478 )?;
479 let src_slice = buffer.as_slice();
480 let mut out = PixelBuffer::try_new(buffer.width(), buffer.height(), target_desc_full)
481 .map_err_at(ConvertError::from)?;
482
483 {
484 let mut dst_slice = out.as_slice_mut();
485 for y in 0..buffer.height() {
486 let src_row = src_slice.row(y);
487 let dst_row = dst_slice.row_mut(y);
488 converter.convert_row(src_row, dst_row, buffer.width());
489 }
490 }
491
492 Ok(EncodeReady {
493 pixels: out,
494 metadata,
495 })
496}
497
498/// Resolve the target transfer function.
499fn resolve_transfer(target: &OutputProfile, source: &PixelDescriptor) -> TransferFunction {
500 match target {
501 OutputProfile::SameAsOrigin => source.transfer(),
502 OutputProfile::Named(cicp) => TransferFunction::from_cicp(cicp.transfer_characteristics)
503 .unwrap_or(TransferFunction::Unknown),
504 OutputProfile::Icc(_) => TransferFunction::Unknown,
505 }
506}
507
508/// Resolve the target color primaries.
509fn resolve_primaries(target: &OutputProfile, source: &PixelDescriptor) -> ColorPrimaries {
510 match target {
511 OutputProfile::SameAsOrigin => source.primaries,
512 OutputProfile::Named(cicp) => {
513 ColorPrimaries::from_cicp(cicp.color_primaries).unwrap_or(ColorPrimaries::Unknown)
514 }
515 OutputProfile::Icc(_) => ColorPrimaries::Unknown,
516 }
517}
518
519/// Check if two descriptors match in all conversion-relevant fields.
520fn descriptors_match(a: &PixelDescriptor, b: &PixelDescriptor) -> bool {
521 a.format == b.format
522 && a.transfer == b.transfer
523 && a.primaries == b.primaries
524 && a.signal_range == b.signal_range
525}