Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c09d7c6
feat(metadata): add the per-format capability query
justin13888 Sep 6, 2026
d9e2db3
feat(jpeg): wire the gamut-metadata facade behind a `metadata` feature
justin13888 Sep 6, 2026
637d47e
feat(jxl): read metadata boxes back and wire the gamut-metadata facade
justin13888 Sep 6, 2026
41fc5dc
style(jpeg): reformat the metadata wiring with the workspace rustfmt
justin13888 Sep 10, 2026
33322c3
style(jxl): reformat the metadata wiring with the workspace rustfmt
justin13888 Sep 10, 2026
3e7bd31
refactor(jxl): bound the container box walk's step inside the walk
justin13888 Sep 10, 2026
1470d1e
test(jxl): pin that an empty container box is walked over, not rejected
justin13888 Sep 10, 2026
f042320
feat(heic): wire the gamut-metadata facade behind a `metadata` feature
justin13888 Sep 10, 2026
327db14
feat(gamut): forward the format crates' `metadata` features from the …
justin13888 Sep 10, 2026
571c2fa
test(gamut): pin the umbrella's three metadata feature forwards
justin13888 Sep 10, 2026
d373c1e
test(gamut): scope the forward pin to the metadata feature's own entries
justin13888 Sep 10, 2026
ecad806
docs(metadata): correct two overclaims about the capability table
justin13888 Sep 10, 2026
64cca8e
refactor(metadata): make the capability ALL constants slices
justin13888 Sep 10, 2026
9875ac5
docs(jxl): document that a model's ICC replaces the encoder's colour …
justin13888 Sep 10, 2026
7937317
docs(metadata): record the embedding precedence a present carrier has
justin13888 Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions crates/gamut-heic/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ gamut-codec-abi.workspace = true
gamut-core.workspace = true
gamut-color.workspace = true
gamut-isobmff.workspace = true
# Typed metadata (issue #420): the unified `Metadata` model over the Exif / XMP items and `colr`
# ICC profile this crate locates. Optional and off by default, and a *normal* dependency (never
# dev-only) so release ordering follows it (`mise run check-release-deps`).
gamut-metadata = { workspace = true, optional = true }

[features]
default = []
# Typed metadata wiring: `HeifImage::blocks` / `HeifImage::metadata` over the `gamut-metadata`
# facade (decode-only, like the crate).
metadata = ["dep:gamut-metadata"]

[dev-dependencies]
# libheif (+ libde265 decode, kvazaar encode) as the differential-conformance oracle: FFI to a
Expand Down
5 changes: 3 additions & 2 deletions crates/gamut-heic/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ references (`dinf`/`dref`, `iloc` `construction_method` 2); mirroring the finali
| Meta-level accounting: `meta`/`iprp` children not consumed by the model surfaced as `UnknownBox` (e.g. `dinf`/`dref`, `uuid`) | 14496-12 | ✅ | S1 |
| C2PA manifest store located in a top-level `uuid` `ContentProvenanceBox`: opaque bytes + exact byte range, purposes `manifest`/`original`/`update` (`c2pa`, `c2pa_manifest_stores`) | C2PA 2.4 §A.5.1, §A.5.3, §8.4.2.3 (`references/c2pa` pending, #431) | ✅ | S7 |
| Store bounding is `LBox`-only and content-dependent (`LBox` validity alone cannot separate a store bound from a plausible interior length). Two routes close it: assert the `jumb` `TBox` — traceable to §A.3.9/§15.12.3.2 but only as a JPEG XL aside, so it is a maintainer call because it narrows what is reported — or confirm the store by §11.1.4.2's JUMBF type UUID, which needs 19566-5's Description Box layout. A `c2pa-rs` oracle fixture would settle either empirically | C2PA 2.4 §A.3.9, §11.1.4.2, §A.5.3; ISO/IEC 19566-5 (not vendored) | ☐ | #239 oracle |
| C2PA store surfaced through the `gamut-metadata` facade as a `MetadataBlock` | C2PA 2.4 §A.5 | ☐ | later |
| C2PA store surfaced through the `gamut-metadata` facade as a `MetadataBlock` (the store lives in a top-level `uuid` box outside the item model `HeifImage::blocks` reads; a caller appends `MetadataBlock::C2pa(HeifContainer::c2pa().bytes)` itself) | C2PA 2.4 §A.5 | ☐ | later |
| C2PA validation: JUMBF interior parse, `c2pa.hash.bmff.v3` hard binding, signature/trust verification | C2PA 2.4 §18.6, §A.5.6 | ☐ | user / #239 |
| `ftyp` brands + `is_hevc_still` (`heic`/`heix`/`heim`/`heis`, or `mif1`+`hvcC` primary) | 23008-12; `references/heif` §7 | ✅ | S1 |
| Sequence brands `msf1`/`hevc`/`hevx` (image sequences) | `references/heif` §7 | OOS | OOS |
Expand All @@ -175,7 +175,8 @@ references (`dinf`/`dref`, `iloc` `construction_method` 2); mirroring the finali
| Derived-image sources (`dimg`), `grid` payload + tile-count validation, `iovl` payload | 23008-12 §6.6.2; `references/heif` §4 | ✅ | S1 |
| `iden` identity derived item recognised (kind); source via `dimg` | 23008-12 §6.6.2.1 | ✅ | S1 |
| Entity groups + `altr` alternatives lens | 14496-12; MIAF | ✅ | S1 |
| Decoded Exif/XMP bytes → `gamut-exif`/`gamut-xmp` (payload exposed opaque here) | 23008-12 §A | ☐ | later |
| Exif `ExifDataBlock` lens: `HeifItem::exif_tiff_stream` applies the 4-byte `exif_tiff_header_offset` and yields the TIFF stream (`II`/`MM`); `HeifItem::icc_profile` yields the `rICC`/`prof` bytes regardless of `nclx` order | 23008-12 §A.2.1; `references/heif` §9 | ✅ | #420 |
| Decoded Exif/XMP/ICC bytes → the `gamut-metadata` facade: `HeifImage::blocks` (`MetadataBlock`s) and `HeifImage::metadata` (`Metadata`), behind the opt-in `metadata` feature (a normal, optional dependency). Pinned by typed extraction from an authored fixture at offsets 0 and 6. **Oracle cell not covered:** `tooling/exiv2-oracle` is block-level and in-memory (no HEIF reader), so "exiv2 reads the items out of the HEIC" is untested; the item bytes are pinned byte-exact against libheif (`tests/conformance.rs`) and the leaf crates pin the payloads against exiv2 (#510) | 23008-12 §A; issue #420 | ✅ | #420 |
| Protected / `uri ` items; external data references | 23008-12 | OOS | OOS |

## C. HEVC configuration & NAL layer (14496-15 · H.265)
Expand Down
223 changes: 223 additions & 0 deletions crates/gamut-heic/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,62 @@ impl HeifImage {
}
}

#[cfg(feature = "metadata")]
impl HeifImage {
/// The primary item's located metadata payloads as
/// [`MetadataBlock`](gamut_metadata::MetadataBlock)s, ready for
/// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) or a
/// [`MetadataExtractor`](gamut_metadata::MetadataExtractor) with a chosen
/// [`ConflictPolicy`](gamut_metadata::ConflictPolicy): the Exif item's TIFF stream
/// ([`HeifItem::exif_tiff_stream`]), the XMP `mime` item's packet ([`xmp`](Self::xmp)) and the
/// primary item's `colr` ICC profile ([`HeifItem::icc_profile`]), each present only when the
/// file carries it.
///
/// HEIF has no IPTC-IIM item type, so no `IptcIim` block is produced. A C2PA manifest store
/// lives in a top-level `uuid` box outside the item model, so it is not produced here either:
/// [`HeifContainer::c2pa`](crate::HeifContainer::c2pa) locates it, and a caller wanting it in
/// the same model appends a [`MetadataBlock::C2pa`](gamut_metadata::MetadataBlock::C2pa).
///
/// # Errors
///
/// Returns [`Error::InvalidInput`] if the Exif item's payload is malformed — shorter than its
/// 4-byte `exif_tiff_header_offset`, or with the offset past the payload's end.
pub fn blocks(&self) -> Result<Vec<gamut_metadata::MetadataBlock<'_>>> {
use gamut_metadata::MetadataBlock;
let mut blocks = Vec::new();
if let Some(exif) = self.exif() {
blocks.push(MetadataBlock::Exif(exif.exif_tiff_stream()?));
}
if let Some(xmp) = self.xmp() {
blocks.push(MetadataBlock::Xmp(&xmp.as_isobmff_item().payload));
}
if let Some(icc) = self.primary_item().icc_profile() {
blocks.push(MetadataBlock::Icc(icc));
}
Ok(blocks)
}

/// Parses the primary item's located metadata into the unified
/// [`Metadata`](gamut_metadata::Metadata) model —
/// [`Metadata::from_blocks`](gamut_metadata::Metadata::from_blocks) over
/// [`blocks`](Self::blocks).
///
/// # Errors
///
/// Returns [`Error::InvalidInput`] as [`blocks`](Self::blocks) does, or when a located payload
/// does not parse — the facade's [`MetadataError`](gamut_metadata::MetadataError) message,
/// naming the carrier, is carried as [`Error::detail`].
pub fn metadata(&self) -> Result<gamut_metadata::Metadata> {
gamut_metadata::Metadata::from_blocks(&self.blocks()?).map_err(|e| {
Error::invalid_input(
env!("CARGO_PKG_NAME"),
"HEIF: embedded metadata does not parse",
)
.with_detail(e.to_string())
})
}
}

/// A single HEIF item, viewed by role. A zero-cost borrow of the underlying [`gamut_isobmff::Item`];
/// [`as_isobmff_item`](Self::as_isobmff_item) exposes it. Per-item accessors read the item's type
/// and properties; cross-item relationships live on [`HeifImage`].
Expand All @@ -301,6 +357,55 @@ impl<'a> HeifItem<'a> {
self.inner
}

/// For an `Exif` item, the TIFF stream (starting `II`/`MM`) behind the payload's 4-byte
/// big-endian `exif_tiff_header_offset` — the `ExifDataBlock` of ISO/IEC 23008-12 §A.2.1,
/// whose offset counts bytes from the end of the field to the TIFF header (`references/heif`
/// §9). This is the form `gamut-exif` parses; the raw payload, offset included, stays
/// available through [`as_isobmff_item`](Self::as_isobmff_item).
///
/// # Errors
///
/// Returns [`Error::InvalidInput`] if the item is not an `Exif` item, if the payload is shorter
/// than the offset field, or if the offset points past the payload's end.
pub fn exif_tiff_stream(&self) -> Result<&'a [u8]> {
if !matches!(self.kind(), ItemKind::Exif) {
return Err(Error::invalid_input(
env!("CARGO_PKG_NAME"),
"HEIF: item is not an Exif item",
));
}
let [o0, o1, o2, o3, rest @ ..] = self.inner.payload.as_slice() else {
return Err(Error::invalid_input(
env!("CARGO_PKG_NAME"),
"HEIF: Exif item payload is shorter than its tiff-header offset field",
));
};
usize::try_from(u32::from_be_bytes([*o0, *o1, *o2, *o3]))
.ok()
.and_then(|offset| rest.get(offset..))
.ok_or_else(|| {
Error::invalid_input(
env!("CARGO_PKG_NAME"),
"HEIF: Exif item tiff-header offset out of range",
)
})
}

/// The ICC profile carried by the item's first `colr` property of ICC type (`rICC` or `prof`),
/// if any — the bytes `gamut-icc` parses. An item may carry both an `nclx` and an ICC `colr`
/// (MIAF allows the pair); [`colour`](Self::colour) returns whichever comes first, this lens
/// the profile regardless of order.
#[must_use]
pub fn icc_profile(&self) -> Option<&'a [u8]> {
self.inner.properties.iter().find_map(|p| match &p.kind {
PropertyKind::Colour(
ColourInformation::RestrictedIcc(profile)
| ColourInformation::UnrestrictedIcc(profile),
) => Some(profile.as_slice()),
_ => None,
})
}

/// The item's id.
#[must_use]
pub fn id(&self) -> u32 {
Expand Down Expand Up @@ -698,3 +803,121 @@ fn is_alpha_urn(aux_type: &str) -> bool {
fn is_depth_urn(aux_type: &str) -> bool {
DEPTH_AUX_URNS.contains(&aux_type)
}

/// Unit tests for the two metadata lenses on [`HeifItem`]: the `exif_tiff_header_offset`
/// arithmetic of `exif_tiff_stream` and the property search of `icc_profile`. They read
/// `HeifImage::new`, which is `pub(crate)`, so they live here.
#[cfg(test)]
mod tests {
use gamut_core::ErrorKind;
use gamut_isobmff::{IsoBmffImage, NclxColr, Property};

use super::*;

/// A one-item file whose primary is `item`.
fn image_of(item: Item) -> HeifImage {
HeifImage::new(IsoBmffImage {
major_brand: *b"heic",
minor_version: 0,
compatible_brands: vec![*b"heic", *b"mif1"],
primary_item_id: item.id,
items: vec![item],
groups: vec![],
})
.unwrap()
}

/// A bare item of the given type and payload.
fn item(item_type: [u8; 4], payload: Vec<u8>, properties: Vec<Property>) -> Item {
Item {
id: 1,
item_type,
name: String::new(),
content_type: None,
content_encoding: None,
hidden: false,
references: vec![],
properties,
payload,
}
}

fn colr(info: ColourInformation) -> Property {
Property {
essential: false,
kind: PropertyKind::Colour(info),
}
}

#[test]
fn exif_tiff_stream_skips_the_offset_field_and_the_offset() {
// Offset 0 (the usual case) and a non-zero offset skipping filler bytes.
for (payload, expected) in [
(b"\0\0\0\0II*\0".to_vec(), &b"II*\0"[..]),
(b"\0\0\0\x02\xEE\xEEMM\0*".to_vec(), &b"MM\0*"[..]),
// An offset landing exactly at the end is an empty stream, not an error.
(b"\0\0\0\x01\xEE".to_vec(), &b""[..]),
] {
let image = image_of(item(*b"Exif", payload.clone(), vec![]));
assert_eq!(
image.primary_item().exif_tiff_stream().unwrap(),
expected,
"{payload:?}"
);
}
}

#[test]
fn exif_tiff_stream_refuses_the_named_faults() {
let cases: [(Item, &str); 3] = [
(
item(*b"mime", b"\0\0\0\0<x/>".to_vec(), vec![]),
"HEIF: item is not an Exif item",
),
(
item(*b"Exif", b"\0\0\0".to_vec(), vec![]),
"HEIF: Exif item payload is shorter than its tiff-header offset field",
),
(
item(*b"Exif", b"\0\0\0\x05II*\0".to_vec(), vec![]),
"HEIF: Exif item tiff-header offset out of range",
),
];
for (item, message) in cases {
let image = image_of(item);
let err = image.primary_item().exif_tiff_stream().unwrap_err();
assert_eq!(err.kind(), ErrorKind::InvalidInput, "{message}");
assert_eq!(err.static_message(), Some(message));
}
}

#[test]
fn icc_profile_finds_the_icc_colr_behind_an_nclx_one() {
let nclx = ColourInformation::Nclx(NclxColr {
colour_primaries: 1,
transfer_characteristics: 13,
matrix_coefficients: 6,
full_range: true,
});
let hvc1 = |props: Vec<Property>| item(*b"hvc1", vec![0xAA], props);

// nclx first, then `prof`: `colour()` reports the nclx, the lens the profile.
let image = image_of(hvc1(vec![
colr(nclx.clone()),
colr(ColourInformation::UnrestrictedIcc(vec![1, 2, 3])),
]));
let primary = image.primary_item();
assert!(matches!(primary.colour(), Some(ColourInformation::Nclx(_))));
assert_eq!(primary.icc_profile(), Some(&[1u8, 2, 3][..]));

// `rICC` counts too.
let image = image_of(hvc1(vec![colr(ColourInformation::RestrictedIcc(vec![9]))]));
assert_eq!(image.primary_item().icc_profile(), Some(&[9u8][..]));

// nclx alone, or no colr at all: no profile.
let image = image_of(hvc1(vec![colr(nclx)]));
assert_eq!(image.primary_item().icc_profile(), None);
let image = image_of(hvc1(vec![]));
assert_eq!(image.primary_item().icc_profile(), None);
}
}
21 changes: 18 additions & 3 deletions crates/gamut-heic/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,22 @@
//! [`HevcDecoder`] seam — over gamut-authored fixtures generated at test time
//! (`tests/conformance.rs`, the dev-only `tooling/libheif-oracle`; see `references/heif` "Oracle").
//!
//! # Metadata
//!
//! [`HeifImage::exif`] / [`HeifImage::xmp`] locate the Exif and XMP items describing the primary
//! image, [`HeifItem::exif_tiff_stream`] applies the Exif item's `exif_tiff_header_offset` to
//! yield the TIFF stream `gamut-exif` parses, and [`HeifItem::icc_profile`] yields the `colr` ICC
//! bytes `gamut-icc` parses. With the optional **`metadata`** Cargo feature (off by default) the
//! same payloads are wired to the `gamut-metadata` facade's typed models: [`HeifImage::blocks`]
//! hands them over as `MetadataBlock`s and [`HeifImage::metadata`] parses them into a unified
//! `Metadata`. A C2PA manifest store lives outside the item model, in a top-level `uuid` box;
//! [`HeifContainer::c2pa`] locates it. The dependency direction stays
//! `gamut-heic → gamut-metadata`.
//!
//! # Deferred to later slices
//!
//! Wiring the decoded Exif/XMP bytes through `gamut-exif`/`gamut-xmp`. Image *sequences*
//! (`msf1`/`hevc`/`hevx` tracks) are permanently out of scope (gamut is image-first). See this
//! crate's `STATUS.md`.
//! Image *sequences* (`msf1`/`hevc`/`hevx` tracks) are permanently out of scope (gamut is
//! image-first). See this crate's `STATUS.md`.
//!
//! # Example
//!
Expand Down Expand Up @@ -160,6 +171,10 @@ pub use backend::{
pub use c2pa::{C2PA_UUID, C2paBoxPurpose, C2paManifestStore};
pub use container::{HeifContainer, Segment, SegmentKind, UnknownBox, UnknownBoxLocation};
pub use decode::{DecodedFrame, HevcDecoder};
// The facade types named in the `metadata`-feature signatures, so a caller can spell
// `HeifImage::metadata` / `HeifImage::blocks` without a direct dependency.
#[cfg(feature = "metadata")]
pub use gamut_metadata::{Metadata, MetadataBlock};
pub use hvcc::{ChromaFormat, HevcConfig, NalArray};
pub use image::{
CleanAperture, ContentLightLevel, HeifImage, HeifItem, ItemKind, PixelAspectRatio,
Expand Down
Loading
Loading