From a011ad2b7d966ff278e80d1d72ef84c4ef3c3249 Mon Sep 17 00:00:00 2001 From: Lilith River Date: Sun, 31 May 2026 23:46:14 -0600 Subject: [PATCH] feat(color): minimal CICP/ICC emission policy (resolve_color_emit + ColorPlan + caps) --- CHANGELOG.md | 10 + docs/cross-codec-color-metadata.md | 395 +++++++++++++++++++++++++++++ src/capabilities.rs | 40 +++ src/color.rs | 364 ++++++++++++++++++++++++++ src/lib.rs | 5 + src/metadata.rs | 20 +- 6 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 docs/cross-codec-color-metadata.md create mode 100644 src/color.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cc86b05..389c629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to zencodec are documented here. ### Added +- **Color-signaling production policy** (`zencodec::color`) — `resolve_color_emit` + reconciles a `SourceColor` against a target's `EncodeCapabilities` under a + `ColorPolicy` (`Compatibility`/`Balanced`/`Compact`/`Verbatim`/`Custom`) and + returns a `ColorPlan { cicp, icc: IccDisposition }`: derive CICP from an ICC and + drop the redundant profile only where CICP is the format's authority and safe + as the sole carrier (`EncodeCapabilities::{cicp_is_format_authority, + cicp_safe_sole_carrier}` — JXL today), else keep/synthesize the ICC; grayscale + and CMYK suppress CICP. `IccRetention` gains `DropIfCicpRepresentable` / + `DropIfCicpSafeSoleCarrier`. Deliberately minimal surface (HDR/gain-map + dispositions + warnings notes deferred; `ColorPlan` is `#[non_exhaustive]`). - **Field-level metadata retention** — `Metadata::filtered(&MetadataPolicy)`, the shared filter for re-encode / recompress pipelines: keep what a downstream image needs, strip the rest, without callers hand-parsing EXIF. diff --git a/docs/cross-codec-color-metadata.md b/docs/cross-codec-color-metadata.md new file mode 100644 index 0000000..a6b2601 --- /dev/null +++ b/docs/cross-codec-color-metadata.md @@ -0,0 +1,395 @@ +# Cross-codec color & metadata: asymmetries and defaults + +Status: **design analysis / proposal** (not yet implemented). Researched 2026-05-31 +against the zen workspace at the commit on `feat/metadata-policy`. Findings were +produced by parallel source reads of all codec crates plus zenpixels, then +adversarially verified — corrections from that pass are folded in below and the +unverified items are listed explicitly in the **Confidence ledger** at the end. + +## 1. TL;DR + +The hard part is already done, in the right place. Color authority (which of +ICC/CICP wins) is modeled by `zenpixels::ColorAuthority` and resolved by +`SourceColor::to_color_context()`; the actual pixel+color conversion and the +"emit metadata that matches the converted pixels" step is `zenpixels-convert`'s +`finalize_for_output() -> EncodeReady { PixelBuffer, OutputMetadata }`; and +`zencodec` already advertises per-channel carry capability +(`EncodeCapabilities::{icc, cicp, exif, xmp, hdr, gain_map, native_alpha, …}`) +and field-level retention (`MetadataPolicy` / `MetadataFields` / `ExifPolicy`). + +What is **missing** is the *seam* that reconciles all three against a concrete +target: nothing takes `(source color + metadata + gain map + orientation)` and a +target's `EncodeCapabilities` and decides keep / derive / synthesize / bake / +tone-map / drop. That logic is today scattered across codecs or left to the +caller. `negotiate.rs` reconciles **pixel layout** (`negotiate_pixel_format`) but +has no color/metadata analogue. + +The proposal: add that seam to `zencodec` as a *plan-producing* (no-`std`, no-CMS) +function — the mirror of `negotiate_pixel_format` — plus a handful of missing +capability flags and an **encode-side warnings channel** (without which every +"warn on lossy transcode" default is unimplementable). + +## 2. Layered architecture — who owns what + +This split is correct and should be preserved. The CLAUDE.md rule ("use zenpixels +pixel types but never re-export them; color-metadata value types may be +re-exported") already encodes most of it. + +| Layer | Crate | Owns | Key types | +|---|---|---|---| +| Pixel buffer color | `zenpixels` | The color state of the bytes in hand | `PixelDescriptor` (transfer, primaries, `SignalRange`, `AlphaMode`), `ColorAuthority`, `ColorContext`, `Cicp`, `ContentLightLevel`, `MasteringDisplay`, `Orientation` | +| Color conversion / CMS | `zenpixels-convert` | Converting pixels + emitting matching color metadata | `ConvertPlan`, `Provenance`, `ConversionCost`, `OutputProfile`, `OutputMetadata { icc, cicp, hdr }`, `finalize_for_output() -> EncodeReady` | +| Source description + retention + capability | `zencodec` | What the *file* said; what to keep; what a target *can carry* | `ImageInfo` / `SourceColor` / `EmbeddedMetadata`, `Metadata` / `MetadataPolicy` / `MetadataFields` / `ExifPolicy`, `EncodeCapabilities` / `DecodeCapabilities`, `gainmap::*` | +| Per-codec carrier mapping | each codec crate | Mapping semantic blobs ↔ container slots | e.g. JPEG APP1/APP2, PNG `eXIf`/`iCCP`/`cICP`, AVIF `colr`/`Exif` items | + +`zencodec` stays `no_std` + no-CMS. It produces **descriptions and plans**; the +actual pixel work (tone-map, gamut, premultiply, dither) executes one layer up in +`zenpixels-convert` / `zenpipe`. + +## 3. What already works (the foundation) + +- **ICC-vs-CICP authority.** `SourceColor` holds `cicp: Option` and + `icc_profile: Option>` *simultaneously* plus + `color_authority: ColorAuthority`. `to_color_context()` drops the + non-authoritative field, falling back to whichever is present + (`info.rs:335`). The per-format authority rules are pinned as tests + (`info.rs:1246–1334`): JPEG = ICC-only; AVIF-`nclx` = CICP; AVIF-`rICC` = ICC + (CICP kept for roundtrip); PNG-`cICP` = CICP, PNG-`iCCP`-only = ICC; JXL-enum = + CICP, JXL-ICC = ICC. +- **HDR transfer detection.** `SourceColor::has_hdr_transfer()` checks CICP + (16/18) then scans the ICC `cicp` tag — does not require a full ICC parse. +- **Field-level retention.** `MetadataPolicy` (`Web` default / `Preserve` / + `PreserveExact` / `ColorAndRotation` / `Custom`) → `MetadataFields` with + `IccRetention` (3-way Keep/KeepNonSrgb/Drop), `ExifPolicy` (7-category pruning + via zero-copy `Cow` — borrowed passthrough, owned only on actual prune), and + **separate** `cicp` vs `hdr` retention so the SDR-flatten case is expressible. + The `Metadata::filtered` doc (`metadata.rs:183`) already reasons about the + gain-map ↔ HDR-signaling coupling hazard. +- **Capability advertisement.** `EncodeCapabilities` already exposes + `icc / cicp / exif / xmp / hdr / gain_map / native_alpha / native_16bit / + native_f32 / native_gray` plus effort/quality/thread ranges; the structs are + `#[non_exhaustive]` with getter methods, so new flags are non-breaking. +- **Encode-side color finalization.** `zenpixels-convert::finalize_for_output()` + atomically converts pixels and emits `OutputMetadata { icc, cicp, hdr }` that + *matches* the converted pixels, with `Provenance` enabling lossless + round-trip detection (e.g. f32-widened-from-u8-JPEG → u8 is lossless). + +## 4. Capability matrix (corrected) + +`R/W` per channel. `N` = native, `via` = via container/sidecar, `exif` = via EXIF +tag, `part` = partial, `-` = none. Decode-only crates show `R/-`. Corrections +from the adversarial verification pass are marked **⚠**. + +| Codec | ICC | CICP | prim+TF | EXIF | XMP | MDCV | CLLI | gain-map | orientation | alpha | HDR-depth | +|---|---|---|---|---|---|---|---|---|---|---|---| +| zenjpeg | N/N | -/- | via-icc | N/N | N/N | -/- | -/- | N/N (UltraHDR/MPF) | exif/exif | -/- (no alpha) | part (8-bit DCT) | +| zenpng | N/N | N/N | part (cICP+cHRM) | N/N | N/N | N/N (mDCV) | N/N (cLLi) | -/- | exif/exif (not applied) | N/N (tRNS) | part (8/16; HDR pixels SDR-only ⚠) | +| zenwebp | N/N | -/- | -/- | N/N | N/N | -/- | -/- | -/- | exif/exif (not applied) | N/N (straight) | -/- (8-bit) | +| zenjxl | N/N | **-/- ⚠** | N/N | via/via | via/via | -/- | part R (MaxCLL approx) | N/N (jhgm) | N/N | N/N (assoc flag) | N (no depth-signal ctrl) | +| zenjxl-decoder | part/- | part/- (synth) | N/- | via/- | via/- | part/- | part/- | N/- (jhgm) | N/- | N/- | N/- | +| zenavif | N/N | N/N (nclx) | N/N | N/N | N/N | N/N (mdcv) | N/N (clli) | N/N (tmap ISO 21496-1) | N/N (irot+imir) | N/N (premul flag) | N/N (8/10/12) | +| zenavif-parse | N/- | N/- | N/- | N/- | N/- | N/- | N/- | N/- (tmap) | N/- | N/- | N/- | +| zengif | -/- | -/- | -/- | -/- | -/- | -/- | -/- | -/- | -/- (PAR only) | N/N (1 index→alpha) | -/- (8-bit palette) | +| image-tiff/zentiff | N/N | -/- | part R | N/N (sub-IFD) | N/N (Tag 700) | -/- | -/- | -/- | N/N (not applied) | N/N (ExtraSamples) | N/N (1–64 bit, no HDR) | +| heic | N/- | N/- (nclx) | N/- | N/- | N/- | N/- | N/- | N/- (Apple aux+tmap) | **baked/- ⚠** (applied; reports Identity) | N/- (aux not decoded) | N/- (8–16; gainmap→8) | +| ultrahdr | N/N | **sRGB-hardcoded ⚠** | part | part R (not extracted) | N/N (hdrgm:) | -/- | -/- | N/N (ISO 21496-1) | -/- (ignored) | part | -/- (SDR base; HDR via gainmap) | +| zenbitmaps | -/- (BMP v5 ICC skipped) | part (computed) | part R | -/- | -/- | -/- | -/- | -/- | part R | N/N (straight) | part (8/16/32; no HDR) | +| zenraw | -/- | -/- (OutputPrimaries enum, unsignaled) | part | N/- | N/- | -/- | -/- | N/- (Apple MPF, not applied) | N/part (applied, tag→1) | -/- (RGB only) | part (sensor→u16/f32) | + +**Verification corrections to the matrix:** + +- **zenjxl CICP write = none (not partial).** `build_jxl_metadata` + (`zenjxl/src/codec.rs:517`) only processes `icc_profile`/`exif`/`xmp` and never + reads `meta.cicp`; `JXL_ENCODE_CAPS` does not call `.with_cicp(true)`. CICP is + only parsed on the *decode* path. A JXL re-encode does **not** preserve a CICP + description except as the ICC the encoder derives from enum color. +- **UltraHDR base CICP is hardcoded `Cicp::SRGB` on decode** + (`ultrahdr/.../codec.rs:302`), not read from EXIF. If an encoder set a + Display-P3/BT.2020 base, decode would still report sRGB — a read/write + asymmetry, not a "via_exif" path. +- **zenavif is not lossless for gain maps.** ISO 21496-1 rational fields + (min/max/gamma/offsets/headroom) are continued-fraction-approximated by + `Fraction::from_f64_cf` (`gainmap.rs:685`), and `prefer_8bit` downscales + 10/12→8-bit. Both are lossy; `zenavif/tests/metadata_roundtrip.rs` covers + EXIF/XMP/CICP/rotation but **not** gain-map fidelity. +- **zenpng latent coupling bug (harmless today).** On gamut downcast + (`encode.rs:526`) `cICP`/chromaticities/source-gamma are cleared but + `mastering_display`/`content_light_level` are **not**, and the metadata writer + emits `mDCV`/`cLLi` unconditionally (`encoder/metadata.rs:127`). It can't fire + yet because the only recognized source gamut is SDR Display-P3 (PQ/HLG are + rejected, `gamut.rs:214`) — but it is a corruption waiting for HDR gamut + support. Worth a guard now. +- **zenjxl MaxCLL is an approximation that should warn.** It maps JXL + `intensity_target` → `MaxCLL` when >255 nits with `MaxFALL=0` + (`codec.rs:1340`). `intensity_target` is a tone-mapping target, not CEA-861.3 + content light level; faithful behavior is to surface it *and warn*. + +## 5. Asymmetry classes (verified) + +1. **Color authority: ICC-only vs CICP-only vs both vs neither.** JPEG/WebP/TIFF/ + UltraHDR carry ICC only; GIF/zenbitmaps carry neither (implicit sRGB); + PNG/AVIF/HEIC/JXL carry both with one authoritative. Sharpest mismatch: + ICC-only ↔ CICP-native. Modeled by `ColorAuthority` + `to_color_context()`. +2. **HDR transfer representation: native CICP enum vs ICC-encoded vs + gain-map-only vs unrepresentable.** PQ/HLG signaled as CICP 16/18, or inside + an ICC `cicp` tag, or implicitly via a gain map over an SDR base, or not at + all (JPEG baseline). `has_hdr_transfer()` exists; `EncodeCapabilities.hdr()` + conflates "carries transfer" / "carries gain map" / "carries CLLI/MDCV". +3. **Gain map: container-carried vs sidecar-JPEG vs unrepresentable.** Shared + payload (`GainMapParams`), per-format carrier (AVIF `tmap`, JXL `jhgm`, JPEG + MPF+XMP `hdrgm:`). PNG/WebP/GIF/TIFF cannot carry one. **Dropping a gain map + is trivial, not tone-mapping** — a gain-map image's base is already a complete + rendition (`GainMapDirection::BaseIsSdr`, the common UltraHDR/AVIF case: base + is SDR, sRGB-signaled, `base_hdr_headroom=0`). To get SDR you just keep the + base and drop the map (`apply_gainmap` at `display_boost=1.0` is the identity — + `weight=0 → gain=1`, confirmed in `ultrahdr-core/src/gainmap/apply.rs`). The + base's color signaling is *already* SDR, so there is nothing to rewrite and no + double-tone-map hazard. The only non-trivial drop is the rare + `BaseIsHdr`/subtractive map (base is HDR, alt is SDR — "typical for JXL", scarce + in the wild): to reach SDR you *apply* the stored gain ratio (still not + tone-mapping). This is categorically different from transfer-function HDR (#2). +4. **Orientation: EXIF tag vs container box (irot/imir) vs baked-into-pixels vs + none.** Codecs also differ on whether decode *applies* it. HEIC always bakes + and reports `Identity` with **no "was-baked" marker** (verified) — a + double-rotation hazard. +5. **Alpha: straight vs premultiplied vs single-index vs none, declared vs + inferred.** `AlphaMode {Straight, Premultiplied, Opaque, Undefined}` rides the + `PixelDescriptor`. AVIF reads a premul flag but the encoder requires it + *declared* (does not infer from pixels). No capability flag distinguishes + premultiplied support from alpha support. +6. **EXIF/XMP carrier asymmetry + XMP-only metadata.** Different container slots + per format; some carry only one. UltraHDR's primary metadata is XMP `hdrgm:`. + MakerNote (0x927C) and Interop-IFD (0xA005) have offset-rewrite hazards + (byte-exact only via keep-all). +7. **MDCV/CLLI presence asymmetry.** Native only in AVIF/HEIC/PNG. JXL has + MaxCLL-only (approx). `MetadataFields.hdr` is one switch over both CLLI and + MDCV — mismatching formats that carry them independently. +8. **Bit depth / HDR pixel depth.** AVIF/HEIC 8/10/12; PNG/TIFF 8/16 (PNG path + SDR-only today); JPEG/WebP/GIF 8-bit. `native_16bit` alone can't express + AVIF's 10/12-bit. +9. **Lossy ICC→CICP derivation gap.** Exact for the recognized-profile corpus and + ICC v4.4+ `cicp` tags; returns `(Unknown, Unknown)` otherwise. No parametric + fallback. Setting `(Unknown,Unknown)` on a target is worse than assumed-sRGB. + +## 6. The core gap: a capability-aware metadata seam + +``` +decode → ImageInfo { source_color, embedded_metadata, gain_map, orientation, resolution } + │ + zenpixels-convert::finalize_for_output(…) + │ → EncodeReady { PixelBuffer, OutputMetadata{icc,cicp,hdr} } (COLOR handled, but capability-BLIND) + ▼ + ┌─────────────────── MISSING SEAM ───────────────────┐ + │ reconcile(OutputMetadata + Metadata.filtered(policy)│ + │ + gain map + orientation, │ + │ target EncodeCapabilities) │ + │ → EmbedPlan + pixel-op requirements + warnings │ + └─────────────────────────────────────────────────────┘ + ▼ + codec.encode(pixels, plan) +``` + +`OutputMetadata` happily emits both ICC and CICP regardless of whether the target +container has slots for them; `Metadata::filtered` is source-driven and +target-blind and cannot see the gain map; `EncodeCapabilities` knows the target +but is wired to neither. Nobody composes the three. + +## 7. Representation proposal (concrete) + +Keep the layer split and the re-export rules. All struct additions are +non-breaking (`#[non_exhaustive]` + getters). + +**7.1 Split the conflated capability booleans** (`capabilities.rs`): +- `hdr()` → `can_carry_transfer_hdr()` (PQ/HLG CICP), `can_carry_content_light_level()`, + `can_carry_mastering_display()`; keep `hdr()` as a deprecated OR-alias. +- `gain_map()` → `can_encode_gain_map()` / `can_decode_gain_map()`; OR-alias kept. +- Add `can_roundtrip_orientation()` (true when an EXIF tag or container rotation + box survives without baking; false for GIF/zenbitmaps and for HEIC-decode which + bakes). +- Add `native_alpha_premultiplied()` distinct from `native_alpha()`. +- Add `max_bit_depth: Option` (or `native_10bit()`/`native_12bit()`), so + AVIF's 10/12-bit is expressible. + +**7.2 `SourceColor` helpers** (`info.rs`): +- `fn hdr_carrier(&self) -> HdrCarrier { None, CicpTransfer, IccEncoded, GainMap, + StaticMetadataOnly }` so transcode branches on *how* HDR is signaled, not just a + bool. The `GainMap` variant reads `ImageInfo.gain_map: GainMapPresence` — wire + it through, don't duplicate the gain map into `SourceColor`. +- `debug_assert` in `to_color_context()` that authority matches a present field + (authority=Cicp with `cicp=None` is a codec bug). + +**7.3 Orientation provenance** (`info.rs`): add `orientation_was_baked: bool` (or +promote the existing `OutputInfo::orientation_applied` concept to a queryable +`ImageInfo` flag) so the HEIC "baked, reports Identity, no marker" gap is closed +and re-encode never double-rotates. + +**7.4 The reconciler** (`negotiate.rs`): leave `negotiate_pixel_format` purely +physical. Add a *separate* function: +``` +fn reconcile_color(source: &SourceColor, target: &EncodeCapabilities) -> ColorPlan +enum ColorPlan { KeepIcc, KeepCicp, DeriveCicpFromIcc, SynthesizeIccFromCicp, + BakeToTargetSpace(Cicp), ToneMapToSdr, DropWithWarning(Reason) } +``` +plus a `NegotiationMode { Lenient, Strict }` so `Strict` rejects lossy color +reinterpretation instead of silently falling back to `available[0]` (the current +documented loss hazard). zencodec emits the *plan*; `zenpixels-convert` executes +any pixel op (synthesize ICC, tone-map, bake). + +**7.5 Split `MetadataFields.hdr`** into `clli: Retention` and `mdcv: Retention` +(keep an `hdr` convenience setter that sets both), matching AVIF/HEIC/PNG reality. + +**7.6 Gain-map disposition**: a helper +`prepare_gain_map_for(target, gm) -> GainMapPlan { Rewrap(Iso21496Format), +DropKeepSdrBase, ApplyToRecoverSdr }`. The common path is `DropKeepSdrBase` — the +base is already the SDR rendition, so dropping the map is a no-op on pixels and +signaling (no tone-map). `ApplyToRecoverSdr` is only for the rare `BaseIsHdr` +map. There is **no `ToneMapAndDrop`** — gain-map drop never tone-maps (that's the +transfer-HDR path, rule 10, a separate mechanism). + +**7.7 Add an encode-side warnings/lossy-report channel** (see §9 — this is a +prerequisite, not an option). + +## 8. Default rules — "the right thing by default" + +| # | Source → target situation | Default | Why | +|---|---|---|---| +| 1 | ICC source, target carries both | Keep ICC authoritative; *also* derive CICP (v4.4 tag or corpus) as roundtrip bonus | Never lose the ICC; CICP helps CICP-preferring consumers | +| 2 | CICP source, target ICC-only (JPEG/WebP/TIFF) | Synthesize ICC from CICP primaries+TF; pixels unchanged | No CICP slot exists; assumed-sRGB silent loss is unacceptable | +| 3 | CICP source, target CICP-native | Pass CICP through verbatim; no ICC synthesis | Lossless and cheap | +| 4 | Unrecognized ICC, target CICP-only-native | Bake conversion to a target-native space (sRGB/P3) in convert, tag matching CICP, warn | Unknown ICC can't reduce losslessly; `(Unknown,Unknown)` is worse than sRGB | +| 5 | Orientation present, target preserves it | Pass through, translating EXIF 0x0112 ↔ irot/imir; don't bake | Lossless, keeps it editable | +| 6 | Orientation present, target has no slot, **or** caller asked Correct, **or** web default | Bake into pixels (lossless DCT for JPEG), set Identity, mark baked | If the channel can't survive, applying it is the only faithful option | +| 7 | Decoder already baked (HEIC, zenraw) | `orientation == Identity`; encode must not re-apply | Closes the double-rotation gap | +| 8 | Gain map, target can carry it | Re-wrap `GainMapParams` into target `Iso21496Format`, re-encode gain image, keep `alternate_cicp/icc` | Shared payload; only carrier + gain-image codec change | +| 9 | Gain map, target cannot carry it | **Drop the map, keep the base verbatim** (`BaseIsSdr`: base is already the SDR rendition, sRGB-signaled — no pixel math, no signaling change). Only `BaseIsHdr` (rare) applies the stored gain to recover SDR. No tone-mapping either way | The base is a complete rendition; `apply_gainmap@boost=1` is the identity. Gain-map drop ≠ transfer-HDR tone-map | +| 10 | **Transfer-function** HDR (PQ/HLG *pixels*), target SDR-only | Tone-map to SDR, rewrite CICP transfer to SDR, drop CLLI/MDCV, warn | Here the *pixels* are HDR-encoded; a PQ tag on tone-mapped pixels mis-signals; colorimetric clip is a corruption. Distinct from #9 | +| 11 | Premultiplied source, target straight-only | Convert premul→straight in convert before encode | Mis-declared alpha corrupts edges | +| 12 | Alpha source, target has no alpha (JPEG) | Composite over a defined background (default opaque), warn — don't silently drop | Silent drop changes transparent-region pixels | +| 13 | Higher-depth source, lower-depth target | Negotiate to highest target depth ≥ source; reduce with error-diffusion, never truncate | Truncation banding is a precision-loss corruption | +| 14 | Default metadata retention on web transcode | `MetadataPolicy::Web`: keep ICC (drop redundant sRGB), EXIF orientation+rights, CICP+HDR; drop GPS/datetime/camera/thumbnail + all XMP | Privacy + bloat reduction while preserving color + attribution | + +## 9. Second-tier asymmetries & open questions (from the completeness critic) + +These are real, mostly unmodeled, and should be triaged before the design is +called complete. Several are "wrong pixels" issues and therefore non-negotiable +under this repo's zero-tolerance rule. + +- **Encode-side warnings channel is missing (prerequisite).** `ImageInfo.warnings` + exists on decode; `EncodeOutput`/`OutputInfo` have none (verified). Every + "warn" default above is unimplementable until an encode-side + warnings/lossy-report channel exists. **Add this first.** +- **Chroma siting (half-pixel shift).** `Cicp` carries only + `{primaries, transfer, matrix, full_range}` — **no `chroma_sample_position`** + (verified). JPEG sampling factors ↔ AVIF/HEIF `chroma_sample_position` ↔ JXL + mismatch shifts chroma by half a pixel = wrong pixels, with no channel to carry + the siting. Needs a field (would ride alongside `Cicp`) and a rule. +- **Full-range vs limited-range YCbCr.** `Cicp.full_range` exists but + `negotiate_pixel_format` explicitly ignores `signal_range` — a full↔limited + mismatch crushes blacks/whites silently. Same severity as the color-authority + mismatch; needs the `Strict` mode. +- **Resolution / DPI roundtrip.** `Resolution` is on `ImageInfo` (read) but **not + in `Metadata`**, so transcode has no carrier. JFIF density (in/cm/aspect-only) ↔ + PNG `pHYs` (integer pixels/meter — cannot represent 72 dpi exactly) ↔ TIFF + rational ↔ EXIF 0x011A/B are four incompatible encodings, and EXIF resolution + can disagree with the container's. No precedence rule. +- **Rendering intent.** ICC and PNG `sRGB` chunk both carry an intent byte; CICP + and JXL have no slot. Lost silently on ICC→CICP or sRGB-chunk→JPEG transcode. + No field holds it. +- **Grayscale has no CICP.** A gray ICC (single TRC) can't be expressed as CICP + (RGB/matrix-centric); gray-ICC-only → CICP-native is strictly unrepresentable, + and "bake to sRGB" would needlessly colorize. Gray+alpha → formats without + 2-channel native is also unhandled. +- **CMYK / N-channel.** `channel_count` is a bare `u8` with no colorspace tag; + RGB/CMYK/Lab/multispectral are indistinguishable. CMYK JPEG / separated TIFF + have no defined transcode behavior. +- **ICC v2 vs v4.** The `cicp` tag is v4.4+ only; a v2 profile can never + self-describe CICP even for a known space. The derivation path differs by + version — unstated in the ICC→CICP rule. +- **C2PA / JUMBF provenance.** Lives in JUMBF boxes *and* XMP, is signed, and + **any** pixel re-encode invalidates it. The correct default is to **drop** an + invalidated manifest, not preserve it — the inverse of the usual instinct. No + JUMBF carrier in the model. +- **Depth maps & segmentation mattes.** `Supplements.{depth_map, segmentation_mattes, + auxiliary}` are flagged but get no transcode class, despite the same + drop-or-rewrap structure as gain maps. HEIF stores alpha as an auxiliary image + ("aux not decoded") — HEIF-aux-alpha → PNG must promote it to a real alpha + channel. +- **Per-frame color in Multi/Animation.** `SourceColor` is canvas-level; APNG / + multi-page TIFF whose frames declare different ICC/CICP collapse to the + primary's color on transcode, silently recoloring the rest. No per-frame + channel, no warning. +- **Background color.** Rule 12 composites over white/black but no field carries + the source's declared background (PNG `bKGD`, GIF background index) to inform + the choice. + +## 10. Test coverage — what exists, what to build + +**Today (all in-`zencodec`, all in-memory):** +- `tests/exif_differential.rs` — 100+ well-formed blobs vs the kamadak-exif + oracle (orientation SHORT/LONG, copyright, artist, both byte orders). +- `tests/fuzz_regression.rs` — walks `fuzz/regression/*`, asserts + filter→serialize→parse→re-filter idempotence. +- `fuzz/fuzz_targets/{exif_parse,exif_filter,exif_roundtrip,metadata_filtered}.rs` + — serializer re-parsability, 7-category policy bitmap, accessor preservation, + filter idempotence. +- `benches/exif_filter.rs` — validates the zero-copy `Cow` contract. +- `tests/comprehensive.rs` — trait surface + Metadata-builder/orientation + plumbing, but only against a mock animation codec + PNM. It validates the + in-memory types, **not** real color/metadata serialization through any format. + +**The critical gap: there are ZERO cross-codec color/metadata round-trip tests.** +The only real metadata round-trip in the whole stack is +`zenavif/tests/metadata_roundtrip.rs` (single codec, AVIF→AVIF, and it skips +gain-map fidelity). Every default rule in §8 is currently **unverified**. + +**To build** — a workspace-level integration suite (a new crate that can depend on +the real codecs; `zencodec` itself has no codec to round-trip through): +1. Cross-codec pipeline: encode one source to JPEG/PNG/WebP/AVIF/JXL, assert which + of {orientation, ICC, EXIF-by-category, CICP, CLLI, MDCV} survives each hop and + that the §8 rules fire (CICP-only AVIF→JPEG synthesizes ICC; ICC JPEG→AVIF + derives CICP). +2. Authority reconciliation: both ICC+CICP+authority → `to_color_context()` drops + the right field; transcode keeps the non-authoritative field when the target + can carry it. +3. Orientation *application* (not just tag serialization): `Correct` rotates + pixels and reports Identity; a baked decoder (HEIC) does not double-rotate. +4. HDR round-trip: CLLI/MDCV through PNG/AVIF/HEIC; JXL MaxCLL-only path warns; + PQ→SDR tone-map rewrites CICP and drops CLLI/MDCV. +5. Gain-map cross-codec: AVIF `tmap` ↔ JPEG UltraHDR ↔ JXL `jhgm` re-wrap of + shared `GainMapParams`; **drop-keeps-SDR-base** path (assert the base survives + byte-for-byte and stays sRGB-signaled — no tone-map); `BaseIsHdr` apply path; + `alternate_cicp/icc` survival; compact-vs-full serialization (always full). +6. ICC→CICP derivation matrix: recognized → exact; unrecognized → bake-to-space, + **not** `(Unknown,Unknown)`. +7. XMP whole-segment Keep/Discard through PNG iTXt / JPEG APP1 / AVIF item / WebP. +8. `negotiate` strict mode rejects lossy color reinterpretation vs the current + silent fallback. +9. Premultiplied↔straight alpha and depth-reduction-with-dithering through real + codecs. + +## 11. Confidence ledger + +**Source-verified (high confidence):** +- The layer split, `SourceColor`/`to_color_context`, `MetadataPolicy`/`MetadataFields`, + `ExifPolicy` `Cow` contract, `EncodeCapabilities` flag set, `negotiate.rs` being + pixel-format-only, `zenpixels-convert::finalize_for_output`/`OutputMetadata` + shape — read directly. +- zenjxl does **not** write CICP (refutes the matrix's earlier "partial"). +- HEIC unconditionally bakes orientation and reports Identity with no marker. +- UltraHDR base CICP is hardcoded sRGB on decode (not an EXIF read). +- zenavif gain-map fractions + 10/12→8-bit are lossy (refutes "zero lossy"). +- zenpng gamut-downcast clears `cICP` but not `mDCV`/`cLLi` (latent, dormant). +- `Cicp` has no `chroma_sample_position`; encode path has no warnings channel. +- zenjxl MaxCLL is an `intensity_target` approximation, not normative CLLI. + +**Reported by readers, not independently re-verified (treat as likely, confirm +before coding):** the finer per-codec cells for `image-tiff`/`zentiff`, +`zenbitmaps`, `zenraw`, `heic` alpha/aux, and `zenwebp` decode caps. The +second-tier items in §9 are mostly *absences* (easy to confirm by grep) rather +than behaviors. + +**Open product decisions (need your call):** whether the reconciler/`ColorPlan` +lives in `zencodec` (plan-only) with execution in `zenpixels-convert` (recommended) +vs. a new `zentranscode` crate; whether the cross-codec test suite is a new +workspace member or lands in `zenpipe`; default background color for alpha-flatten; +and the C2PA drop-vs-preserve policy (legal implications). diff --git a/src/capabilities.rs b/src/capabilities.rs index cc8cef4..9db5985 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -102,6 +102,9 @@ pub struct EncodeCapabilities { exif: bool, xmp: bool, cicp: bool, + // CICP reliability (distinct from `cicp` = "has a CICP carrier") + cicp_is_format_authority: bool, + cicp_safe_sole_carrier: bool, // Operation support stop: bool, animation: bool, @@ -143,6 +146,8 @@ impl EncodeCapabilities { exif: false, xmp: false, cicp: false, + cicp_is_format_authority: false, + cicp_safe_sole_carrier: false, stop: false, animation: false, push_rows: false, @@ -181,6 +186,29 @@ impl EncodeCapabilities { pub const fn cicp(&self) -> bool { self.cicp } + /// Whether a conformant decoder of this format honors CICP as the + /// authoritative color signal (so CICP *can* carry the color description). + /// + /// True for formats whose native path is CICP-equivalent (JXL codestream + /// enum, AVIF/HEIC `nclx`). Distinct from [`cicp`](Self::cicp) (= "has a CICP + /// carrier slot") and from [`cicp_safe_sole_carrier`](Self::cicp_safe_sole_carrier) + /// (= "safe to ship CICP-only"). Gates whether CICP is emitted under + /// `ColorPolicy::Auto`/`Balanced`. + pub const fn cicp_is_format_authority(&self) -> bool { + self.cicp_is_format_authority + } + /// Whether it is safe in practice to ship CICP as the *sole* color carrier + /// and drop a redundant ICC profile for this format. + /// + /// Stricter than [`cicp_is_format_authority`](Self::cicp_is_format_authority): + /// a format can be CICP-authoritative yet still need an ICC kept for + /// real-world tool compatibility. As of 2026 this is true only for JXL + /// (matches libjxl's `want_icc=false` default); AVIF/HEIC/PNG keep the ICC + /// because non-browser pipelines mishandle CICP-only files. Gates whether a + /// redundant ICC is dropped under `ColorPolicy::Balanced`. + pub const fn cicp_safe_sole_carrier(&self) -> bool { + self.cicp_safe_sole_carrier + } /// Whether `with_stop` on encode jobs is respected (not a no-op). pub const fn stop(&self) -> bool { self.stop @@ -311,6 +339,18 @@ impl EncodeCapabilities { self.xmp = v; self } + /// Set whether a conformant decoder honors this format's CICP as authority. + /// See [`cicp_is_format_authority`](Self::cicp_is_format_authority). + pub const fn with_cicp_is_format_authority(mut self, v: bool) -> Self { + self.cicp_is_format_authority = v; + self + } + /// Set whether CICP is safe as the sole color carrier (drop redundant ICC). + /// See [`cicp_safe_sole_carrier`](Self::cicp_safe_sole_carrier). + pub const fn with_cicp_safe_sole_carrier(mut self, v: bool) -> Self { + self.cicp_safe_sole_carrier = v; + self + } /// Set whether the encoder embeds CICP color description. pub const fn with_cicp(mut self, v: bool) -> Self { self.cicp = v; diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..63d5180 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,364 @@ +//! Color-signaling production policy: how an image's color *description* +//! (ICC profile vs CICP code points) is emitted when encoding or transcoding. +//! +//! This is orthogonal to which *pixels* are written. Containers differ in which +//! color carriers they have and in how reliably real-world decoders honor each +//! one, so emitting "the right" color description is a per-target decision. +//! +//! # The obvious knob: [`ColorPolicy`] +//! +//! Pick an intent — the same meaning whether encoding from pixels or transcoding +//! from another file: +//! +//! - [`Compatibility`](ColorPolicy::Compatibility) — always embed an ICC; add CICP where reliable. +//! - [`Balanced`](ColorPolicy::Balanced) (**default**) — emit CICP where it's the format's authority, +//! drop a redundant ICC only where CICP is safe as the sole carrier (JXL today) or the ICC is plain sRGB. +//! - [`Compact`](ColorPolicy::Compact) — smallest: prefer CICP wherever the format carries it, drop the ICC. +//! - [`Verbatim`](ColorPolicy::Verbatim) — carry the source's signals unchanged. +//! - [`Custom`](ColorPolicy::Custom) — explicit [`ColorFields`] for power users. +//! +//! # The resolver: [`resolve_color_emit`] +//! +//! [`resolve_color_emit`] reconciles a [`SourceColor`] against a target's +//! [`EncodeCapabilities`] under a [`ColorPolicy`] and returns a [`ColorPlan`] — +//! a pure description of what to emit. This crate is `no_std` and carries no +//! CMS, so the plan only describes intent ([`IccDisposition::SynthesizeFrom`], +//! etc.); the bytes are materialized one layer up. + +use zenpixels::icc; +use zenpixels::{Cicp, ColorModel}; + +use crate::capabilities::EncodeCapabilities; +use crate::info::SourceColor; +use crate::metadata::IccRetention; + +/// How color description is emitted on encode — the obvious, intent-named knob. +/// +/// See the [module docs](self) for the per-format behavior table. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum ColorPolicy { + /// Widest compatibility: always embed an ICC profile (synthesizing one from + /// CICP when the source had none); add CICP where the format treats it as + /// authority. Largest color overhead. + Compatibility, + /// **Default.** Emit CICP where it is the format's authority and drop a + /// redundant ICC only where CICP is safe as the *sole* carrier + /// ([`cicp_safe_sole_carrier`](EncodeCapabilities::cicp_safe_sole_carrier) — + /// JXL today) or the ICC is a plain sRGB profile. Otherwise keep the ICC. + #[default] + Balanced, + /// Smallest color overhead: prefer CICP wherever the format can carry it at + /// all, and drop the ICC whenever CICP can describe the color. + Compact, + /// Carry the source's color signals through unchanged — derive and strip + /// nothing. For transcodes that must preserve exactly what was there. + Verbatim, + /// Explicit mechanism control. + Custom(ColorFields), +} + +/// Whether CICP is emitted, behind [`ColorPolicy::Custom`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum CicpEmission { + /// Emit CICP where the format treats it as the authoritative color signal + /// ([`cicp_is_format_authority`](EncodeCapabilities::cicp_is_format_authority)). + #[default] + WhereFormatAuthority, + /// Emit CICP wherever the format has a carrier, even if not authoritative. + WhereverSupported, + /// Never emit CICP (ICC-only output). + Never, +} + +/// Mechanism fields behind [`ColorPolicy::Custom`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct ColorFields { + /// When to drop the ICC profile. + pub icc: IccRetention, + /// Whether to emit CICP. + pub cicp: CicpEmission, +} + +impl Default for ColorFields { + fn default() -> Self { + Self { + icc: IccRetention::DropIfCicpSafeSoleCarrier, + cicp: CicpEmission::WhereFormatAuthority, + } + } +} + +impl ColorPolicy { + /// Resolve a preset to its mechanism fields. + pub const fn fields(&self) -> ColorFields { + match self { + Self::Compatibility => ColorFields { + icc: IccRetention::Keep, + cicp: CicpEmission::WhereFormatAuthority, + }, + Self::Balanced => ColorFields { + icc: IccRetention::DropIfCicpSafeSoleCarrier, + cicp: CicpEmission::WhereFormatAuthority, + }, + Self::Compact => ColorFields { + icc: IccRetention::DropIfCicpRepresentable, + cicp: CicpEmission::WhereverSupported, + }, + Self::Verbatim => ColorFields { + icc: IccRetention::Keep, + cicp: CicpEmission::WhereFormatAuthority, + }, + Self::Custom(f) => *f, + } + } +} + +/// What to do with the ICC profile channel for one encode. +/// +/// The bytes are materialized by the codec adapter / CMS layer, not here. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum IccDisposition { + /// Embed the source ICC bytes verbatim. + KeepSource, + /// Embed an ICC synthesized from this CICP (target has no CICP carrier, or + /// the policy wants an ICC alongside). The caller materializes the bytes. + SynthesizeFrom(Cicp), + /// Emit no ICC profile. + Drop, +} + +/// A resolved plan for emitting an image's color description on encode. +/// +/// Produced by [`resolve_color_emit`]. Deliberately minimal: it carries the +/// ICC/CICP decision, which is what current transcode needs. `#[non_exhaustive]` +/// so range/rendering-intent/HDR/gain-map dispositions and a warnings channel +/// can be added back additively when a consumer needs them. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct ColorPlan { + /// CICP to write to the target's native carrier, if any. + pub cicp: Option, + /// Disposition of the ICC profile channel. + pub icc: IccDisposition, +} + +/// The CICP that describes this source's color as code points, if any: +/// the explicit CICP, else derived from the ICC (`cicp` tag, then corpus). +fn representable_cicp(src: &SourceColor) -> Option { + if let Some(c) = src.cicp { + return Some(c); + } + let icc_bytes = src.icc_profile.as_ref()?; + icc::extract_cicp(icc_bytes) + .or_else(|| icc::identify_common(icc_bytes).and_then(|id| id.to_cicp())) +} + +/// Reconcile a source's color description against a target's capabilities under +/// a [`ColorPolicy`], returning what to emit. +/// +/// Pure and `no_std`. Decides ICC vs CICP emission, including the grayscale / +/// CMYK terminal states (where CICP is inapplicable and the ICC must be kept). +pub fn resolve_color_emit( + src: &SourceColor, + target: &EncodeCapabilities, + policy: ColorPolicy, +) -> ColorPlan { + let fields = policy.fields(); + let src_has_icc = src.icc_profile.is_some(); + + // Grayscale / CMYK: CICP is RGB-centric and cannot describe these. Keep the + // ICC (the only valid color description) and suppress CICP — emitting an RGB + // CICP over gray/CMYK pixels would recolor them. + let model = src + .icc_profile + .as_deref() + .and_then(icc::profile_color_space); + let is_gray = matches!(model, Some(ColorModel::Gray)) || src.channel_count == Some(1); + let is_cmyk = matches!(model, Some(ColorModel::Cmyk)); + if is_gray || is_cmyk { + return ColorPlan { + cicp: None, + icc: if src_has_icc { + IccDisposition::KeepSource + } else { + IccDisposition::Drop + }, + }; + } + + let repr_cicp = representable_cicp(src); + let cicp_represents = repr_cicp.is_some(); + let has_carrier = target.cicp(); + let is_authority = target.cicp_is_format_authority(); + let sole_safe = target.cicp_safe_sole_carrier(); + let icc_is_srgb = src.icc_profile.as_deref().is_some_and(icc::is_common_srgb); + + // Whether to emit CICP. + let emit_cicp = match policy { + ColorPolicy::Verbatim => has_carrier && src.cicp.is_some(), + _ => match fields.cicp { + CicpEmission::Never => false, + CicpEmission::WhereFormatAuthority => has_carrier && is_authority && cicp_represents, + CicpEmission::WhereverSupported => has_carrier && cicp_represents, + }, + }; + let cicp_out = if emit_cicp { + if policy == ColorPolicy::Verbatim { + src.cicp + } else { + repr_cicp + } + } else { + None + }; + + // Whether to drop the ICC. + let drop_by_rule = match fields.icc { + IccRetention::Drop => true, + IccRetention::Keep => false, + IccRetention::KeepNonSrgb => icc_is_srgb, + IccRetention::DropIfCicpRepresentable => emit_cicp && cicp_represents, + IccRetention::DropIfCicpSafeSoleCarrier => emit_cicp && sole_safe && cicp_represents, + }; + // Balanced additionally sheds a redundant sRGB ICC even where CICP isn't the + // sole carrier (the most common pure-weight case). + let drop_icc = match policy { + ColorPolicy::Balanced => drop_by_rule || (emit_cicp && icc_is_srgb), + _ => drop_by_rule, + }; + + let icc = if src_has_icc { + if drop_icc { + IccDisposition::Drop + } else { + IccDisposition::KeepSource + } + } else if !emit_cicp && cicp_represents && policy != ColorPolicy::Verbatim { + // No source ICC and CICP isn't carrying the color (target is ICC-only, + // or policy is Compatibility): synthesize an ICC so color isn't lost. + IccDisposition::SynthesizeFrom(repr_cicp.expect("cicp_represents")) + } else if matches!(policy, ColorPolicy::Compatibility) && cicp_represents { + // Compatibility always wants an ICC present. + IccDisposition::SynthesizeFrom(repr_cicp.expect("cicp_represents")) + } else { + IccDisposition::Drop + }; + + ColorPlan { + cicp: cicp_out, + icc, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zenpixels::ColorAuthority; + + // Capability fixtures matching the 2026 reliability findings. + fn caps_jxl() -> EncodeCapabilities { + EncodeCapabilities::new() + .with_icc(true) + .with_cicp(true) + .with_cicp_is_format_authority(true) + .with_cicp_safe_sole_carrier(true) + } + fn caps_avif() -> EncodeCapabilities { + EncodeCapabilities::new() + .with_icc(true) + .with_cicp(true) + .with_cicp_is_format_authority(true) + .with_cicp_safe_sole_carrier(false) + } + fn caps_jpeg() -> EncodeCapabilities { + // No CICP carrier at all. + EncodeCapabilities::new().with_icc(true) + } + + fn src_cicp(c: Cicp) -> SourceColor { + SourceColor::default() + .with_cicp(c) + .with_color_authority(ColorAuthority::Cicp) + .with_channel_count(3) + } + + #[test] + fn jxl_balanced_strips_representable_icc() { + // JXL (sole-safe): CICP present + an ICC whose color CICP represents → + // emit CICP, drop the ICC (matches libjxl's want_icc=false default). + let src = SourceColor::default() + .with_cicp(Cicp::SRGB) + .with_icc_profile(alloc::vec![0u8; 132]) + .with_channel_count(3); + let plan = resolve_color_emit(&src, &caps_jxl(), ColorPolicy::Balanced); + assert_eq!(plan.cicp, Some(Cicp::SRGB)); + assert_eq!(plan.icc, IccDisposition::Drop); + } + + #[test] + fn avif_balanced_keeps_nonsrgb_icc_alongside_cicp() { + // AVIF (not sole-safe): a non-sRGB ICC is kept alongside CICP. (The + // redundant-sRGB drop needs a corpus-recognized profile and is covered + // by the conformance suite, which has a real sRGB profile via `cms`.) + let p3 = src_cicp(Cicp::DISPLAY_P3).with_icc_profile(alloc::vec![0u8; 132]); + let plan = resolve_color_emit(&p3, &caps_avif(), ColorPolicy::Balanced); + assert_eq!(plan.cicp, Some(Cicp::DISPLAY_P3)); + assert_eq!(plan.icc, IccDisposition::KeepSource); + } + + #[test] + fn jpeg_synthesizes_icc_from_cicp() { + // CICP-only source → JPEG (no CICP carrier): synthesize an ICC. + let src = src_cicp(Cicp::DISPLAY_P3); + let plan = resolve_color_emit(&src, &caps_jpeg(), ColorPolicy::Balanced); + assert_eq!(plan.cicp, None); + assert_eq!(plan.icc, IccDisposition::SynthesizeFrom(Cicp::DISPLAY_P3)); + } + + #[test] + fn compact_strips_icc_on_avif() { + // Compact drops the ICC wherever CICP represents the color, even on AVIF. + let p3 = src_cicp(Cicp::DISPLAY_P3).with_icc_profile(alloc::vec![0u8; 132]); + let plan = resolve_color_emit(&p3, &caps_avif(), ColorPolicy::Compact); + assert_eq!(plan.cicp, Some(Cicp::DISPLAY_P3)); + assert_eq!(plan.icc, IccDisposition::Drop); + } + + #[test] + fn compatibility_always_keeps_or_synthesizes_icc() { + // CICP-only source, AVIF, Compatibility → CICP emitted AND an ICC synthesized. + let src = src_cicp(Cicp::DISPLAY_P3); + let plan = resolve_color_emit(&src, &caps_avif(), ColorPolicy::Compatibility); + assert_eq!(plan.cicp, Some(Cicp::DISPLAY_P3)); + assert_eq!(plan.icc, IccDisposition::SynthesizeFrom(Cicp::DISPLAY_P3)); + } + + #[test] + fn grayscale_keeps_icc_suppresses_cicp() { + // A 1-channel source: CICP is inapplicable; keep ICC, suppress CICP. + let src = SourceColor::default() + .with_icc_profile(alloc::vec![0u8; 132]) + .with_channel_count(1); + let plan = resolve_color_emit(&src, &caps_avif(), ColorPolicy::Balanced); + assert_eq!(plan.cicp, None); + assert_eq!(plan.icc, IccDisposition::KeepSource); + } + + #[test] + fn verbatim_passes_source_through() { + // Verbatim keeps both, derives nothing. + let src = src_cicp(Cicp::DISPLAY_P3).with_icc_profile(alloc::vec![0u8; 132]); + let plan = resolve_color_emit(&src, &caps_avif(), ColorPolicy::Verbatim); + assert_eq!(plan.cicp, Some(Cicp::DISPLAY_P3)); + assert_eq!(plan.icc, IccDisposition::KeepSource); + } + + #[test] + fn default_policy_is_balanced() { + assert_eq!(ColorPolicy::default(), ColorPolicy::Balanced); + } +} diff --git a/src/lib.rs b/src/lib.rs index 83b97f3..611ab42 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,8 @@ extern crate alloc; whereat::define_at_crate_info!(); mod capabilities; +/// Color-signaling production policy (how ICC vs CICP are emitted on encode). +pub mod color; mod cost; mod detect; mod error; @@ -65,6 +67,9 @@ mod traits; // Public root: shared types used by both encode and decode // ========================================================================= +pub use color::{ + CicpEmission, ColorFields, ColorPlan, ColorPolicy, IccDisposition, resolve_color_emit, +}; pub use exif::{ByteOrder, Exif, ExifPolicy, Retention}; pub use extensions::Extensions; pub use format::{ImageFormat, ImageFormatDefinition, ImageFormatRegistry}; diff --git a/src/metadata.rs b/src/metadata.rs index 1d5786f..ef43a2e 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -229,7 +229,12 @@ impl Metadata { // ICC — three-way; only KeepNonSrgb drops a redundant sRGB profile. out.icc_profile = match f.icc { IccRetention::Drop => None, - IccRetention::Keep => self.icc_profile.clone(), + // Target-blind retention keeps the profile; the CICP-conditional + // drop is resolved against a concrete target in + // `color::resolve_color_emit`, which `filtered` does not see. + IccRetention::Keep + | IccRetention::DropIfCicpRepresentable + | IccRetention::DropIfCicpSafeSoleCarrier => self.icc_profile.clone(), IccRetention::KeepNonSrgb => self .icc_profile .as_ref() @@ -285,6 +290,19 @@ pub enum IccRetention { KeepNonSrgb, /// Keep the profile as-is, even a redundant sRGB one (byte-faithful). Keep, + /// Drop the profile when it maps to a CICP expressible as code points + /// (sRGB / Display-P3 / BT.2020 / BT.2100…) — i.e. CICP fully describes the + /// color. This is a **target-aware** disposition: it only takes effect in + /// [`color::resolve_color_emit`](crate::color::resolve_color_emit), where the + /// target's CICP carrier is known. In the target-blind + /// [`Metadata::filtered`] retention path it conservatively keeps the profile. + DropIfCicpRepresentable, + /// Drop the profile only when the target format's CICP is safe as the sole + /// color carrier ([`EncodeCapabilities::cicp_safe_sole_carrier`](crate::encode::EncodeCapabilities::cicp_safe_sole_carrier) + /// — JXL today) and CICP represents the color. Like + /// [`DropIfCicpRepresentable`](Self::DropIfCicpRepresentable), this is + /// target-aware and keeps the profile in [`Metadata::filtered`]. + DropIfCicpSafeSoleCarrier, } /// Per-field metadata retention for [`MetadataPolicy::Custom`].