diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c427307..1e8eb63 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,6 +11,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # Pinned so fmt/clippy verdicts and build behaviour do not drift with the + # runner image's preinstalled stable; bump deliberately, together with the + # local toolchain. + RUST_TOOLCHAIN: 1.90.0 + jobs: lint: runs-on: ubuntu-latest @@ -21,6 +27,11 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Pin Rust toolchain + run: | + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component clippy,rustfmt + rustup default "$RUST_TOOLCHAIN" + - run: cargo fmt --check - run: cargo clippy --all-targets --all-features --locked @@ -45,12 +56,17 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Pin Rust toolchain + run: | + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + rustup default "$RUST_TOOLCHAIN" + - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y cmake pkg-config ffmpeg \ libde265-dev libx265-dev libaom-dev libdav1d-dev \ - libopenjp2-7-dev libjpeg-dev libbrotli-dev zlib1g-dev + libopenjp2-7-dev libjpeg-dev libpng-dev libbrotli-dev zlib1g-dev - name: Fetch pinned libheif (validator source and corpus) run: | diff --git a/API.md b/API.md index 63cea05..e4fbe13 100644 --- a/API.md +++ b/API.md @@ -242,6 +242,14 @@ fn orientation_to_apply(path: &Path) -> Result, heic_decoder::DecodeE Requires `image-integration` feature. +Hook decodes buffer the entire encoded input in memory before decoding; in +exchange, pixels decode straight into the buffer `image` hands over, without +an additional full-frame owned RGBA allocation. Codec-native planes and a +single grid-tile scratch buffer may still be allocated. +`register_image_decoder_hooks()` caps the encoded input buffer at +`DEFAULT_HOOK_MAX_INPUT_BYTES` (128 MiB); register with explicit guardrails +to choose a different `max_input_bytes` bound (or `None` for unbounded). + ### 1) Register hooks once at startup ```rust @@ -279,15 +287,15 @@ let img = if let Some(orientation) = hint.orientation_to_apply() { }; ``` -### 3) Direct adapter usage (optional) - -`HeifImageDecoder` constructors: - -- `from_bytes[_with_guardrails]` -- `from_read[_with_guardrails]` -- `from_bufread[_with_guardrails]` -- `from_seekable[_with_guardrails]` -- `from_path[_with_guardrails]` +The registered hook decoder also exposes the primary item's EXIF block through +the standard `ImageDecoder::exif_metadata`/`ImageDecoder::orientation` methods, +so generic `image` code (`decoder.orientation()` + +`DynamicImage::apply_orientation`) works without crate-specific helpers. EXIF +orientation stays unapplied in the pixels; applying it remains the caller's +choice. When the primary item carries `irot`/`imir` container transforms — +which decode does bake into the pixels — `orientation()` reports +`NoTransforms` (matching `ExifOrientationHint::should_apply_exif_orientation`) +so callers do not rotate twice; `exif_metadata` still returns the full block. ## Conversion Helpers (image module) diff --git a/TESTING.md b/TESTING.md index adee7f9..46808ae 100644 --- a/TESTING.md +++ b/TESTING.md @@ -29,11 +29,15 @@ into this repository. CI runs this on every pull request (`.github/workflows/tests.yml`): a `lint` job (`cargo fmt --check`, clippy, `cargo test`) and a `verify` job that performs the full default-corpus correctness pass, including stress-corpus -generation. The workflow pins the libheif and ente test-fixtures commits it -fetches; bump those pins in the workflow to move the CI validator or corpus -forward deliberately. +generation. The workflow pins the Rust toolchain plus the libheif and ente +test-fixtures commits it fetches; bump those pins in the workflow to move the +CI toolchain, validator, or corpus forward deliberately. - pixel-for-pixel PNG comparison against an external `heif-dec` validator +- pixel-for-pixel comparison of the `image` crate integration hook output + (`ImageReader`/`DynamicImage::from_decoder`) against the direct Rust decode + for every comparable verifier file, including exact ICC-profile equality + through the hook decoder's `ImageDecoder::icc_profile` - embedded ICC colour-profile comparison against the validator's PNG output: when `heif-dec` embeds a profile, the Rust PNG must carry byte-identical profile data; a Rust-only profile is allowed (the Rust decoder synthesizes diff --git a/scripts/heic_tests.sh b/scripts/heic_tests.sh index 0c5ee11..6a11c36 100755 --- a/scripts/heic_tests.sh +++ b/scripts/heic_tests.sh @@ -477,6 +477,74 @@ fn main() -> Result<(), Box> { println!("{value}"); Ok(()) } +RS + + cat > "$HELPER_DIR/src/bin/heif-image-hook-check.rs" <<'RS' +use heic_decoder::image_integration::register_image_decoder_hooks; +use heic_decoder::{DecodedRgbaPixels, decode_path_to_rgba}; +use image::{DynamicImage, ImageDecoder, ImageReader}; +use std::error::Error; +use std::path::Path; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + if args.len() != 2 { + return Err("Usage: heif-image-hook-check ".into()); + } + + let input = Path::new(&args[1]); + let direct = decode_path_to_rgba(input)?; + + let _ = register_image_decoder_hooks(); + let mut decoder = ImageReader::open(input)?.into_decoder()?; + let icc_profile = decoder.icc_profile()?; + if icc_profile != direct.icc_profile { + return Err("image hook ICC profile differs from direct decode".into()); + } + + let decoded = DynamicImage::from_decoder(decoder)?; + if decoded.width() != direct.width || decoded.height() != direct.height { + return Err(format!( + "image hook dimensions {}x{} differ from direct decode {}x{}", + decoded.width(), + decoded.height(), + direct.width, + direct.height + ) + .into()); + } + + match (&direct.pixels, decoded) { + (DecodedRgbaPixels::U8(expected), DynamicImage::ImageRgba8(actual)) + if expected == actual.as_raw() => {} + (DecodedRgbaPixels::U16(expected), DynamicImage::ImageRgba16(actual)) + if expected == actual.as_raw() => {} + (DecodedRgbaPixels::U8(expected), DynamicImage::ImageRgba8(actual)) => { + return Err(format!( + "image hook RGBA8 pixel mismatch: direct_samples={} hook_samples={}", + expected.len(), + actual.as_raw().len() + ) + .into()); + } + (DecodedRgbaPixels::U16(expected), DynamicImage::ImageRgba16(actual)) => { + return Err(format!( + "image hook RGBA16 pixel mismatch: direct_samples={} hook_samples={}", + expected.len(), + actual.as_raw().len() + ) + .into()); + } + (DecodedRgbaPixels::U8(_), other) => { + return Err(format!("image hook color mismatch: expected RGBA8, got {:?}", other.color()).into()); + } + (DecodedRgbaPixels::U16(_), other) => { + return Err(format!("image hook color mismatch: expected RGBA16, got {:?}", other.color()).into()); + } + } + + Ok(()) +} RS cat > "$HELPER_DIR/src/bin/heif-stream-concurrency-bench.rs" <<'RS' @@ -789,6 +857,10 @@ decode_with_helper() { "$HELPER_BIN_DIR/heif-decode" --orientation preserve "$1" "$2" } +check_with_image_hook_helper() { + "$HELPER_BIN_DIR/heif-image-hook-check" "$1" +} + failure_reason_from_log() { tr '\n' ' ' < "$1" | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//' } @@ -980,6 +1052,7 @@ png_to_rgba() { # Prints a failure description and returns 1 on mismatch. compare_png_icc() { local ref_png="$1" rust_png="$2" prefix="$3" + local ref_label="${4:-validator}" rust_label="${5:-rust}" local ref_icc="$prefix.ref.icc" rust_icc="$prefix.rust.icc" local ref_status=0 rust_status=0 "$HELPER_BIN_DIR/heif-png-icc" "$ref_png" "$ref_icc" 2>/dev/null || ref_status=$? @@ -997,14 +1070,14 @@ compare_png_icc() { return 0 fi if [[ "$rust_status" -eq 3 ]]; then - echo "icc profile missing: validator PNG embeds one, rust PNG has none" + echo "icc profile missing: $ref_label PNG embeds one, $rust_label PNG has none" return 1 fi if ! cmp -s "$ref_icc" "$rust_icc"; then local ref_hash rust_hash ref_hash="$(shasum -a 256 "$ref_icc" | awk '{print $1}')" rust_hash="$(shasum -a 256 "$rust_icc" | awk '{print $1}')" - echo "icc profile mismatch ref=$ref_hash rust=$rust_hash" + echo "icc profile mismatch $ref_label=$ref_hash $rust_label=$rust_hash" return 1 fi return 0 @@ -1140,6 +1213,7 @@ EOF echo "mode=$mode files=${#files[@]}" > "$report_file" local total=0 skipped=0 passed=0 failed=0 + local image_hook_passed=0 local expected_validator_failed=0 expected_validator_rust_decoded=0 expected_validator_rust_errors=0 local expected_rust_failed=0 local comparable_heif=0 comparable_heic=0 comparable_avif=0 @@ -1274,8 +1348,31 @@ EOF if cmp -s "$ref_raw" "$rust_raw"; then local icc_failure if icc_failure="$(compare_png_icc "$ref_actual" "$rust_actual" "$tmp_dir/$id")"; then - passed=$((passed + 1)) - echo "PASS $rel_path" >> "$report_file" + # Deliberately run for EVERY passing file even though it re-decodes + # the file twice more (direct + hook): the image-crate hook is a + # separate decode path (lazy adapter, caller-buffer slices) whose + # parity with the direct decode is a hard correctness requirement, + # so it gets the same per-file coverage as the validator comparison. + # Do not sample or gate this to save CI time. + local hook_log hook_status + hook_log="$tmp_dir/$id.image-hook.check.stderr.log" + if check_with_image_hook_helper "$input_file" >/dev/null 2>"$hook_log"; then + hook_status=0 + else + hook_status=$? + fi + + if [[ "$hook_status" -ne 0 ]]; then + local hook_reason + hook_reason="$(failure_reason_from_log "$hook_log")" + failed=$((failed + 1)) + failures+=("$rel_path :: image hook check failed status=$hook_status: ${hook_reason:-no stderr}") + echo "FAIL $rel_path (image hook check failed status=$hook_status)" >> "$report_file" + else + image_hook_passed=$((image_hook_passed + 1)) + passed=$((passed + 1)) + echo "PASS $rel_path (direct+image-hook)" >> "$report_file" + fi else failed=$((failed + 1)) failures+=("$rel_path :: $icc_failure") @@ -1296,7 +1393,7 @@ EOF done [[ "$keep_artifacts" -eq 1 ]] || rm -rf "$tmp_dir" - log verify "Summary: total=$total skipped=$skipped expected_validator_fail=$expected_validator_failed expected_validator_rust_decoded=$expected_validator_rust_decoded expected_validator_rust_errors=$expected_validator_rust_errors expected_rust_fail=$expected_rust_failed passed=$passed failed=$failed" + log verify "Summary: total=$total skipped=$skipped expected_validator_fail=$expected_validator_failed expected_validator_rust_decoded=$expected_validator_rust_decoded expected_validator_rust_errors=$expected_validator_rust_errors expected_rust_fail=$expected_rust_failed passed=$passed image_hook_passed=$image_hook_passed failed=$failed" log verify "Report: $report_file" if [[ "$failed" -gt 0 ]]; then diff --git a/src/image_integration.rs b/src/image_integration.rs index 71172a8..ca40e5f 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -8,22 +8,22 @@ //! See `API.md` in the crate root for end-to-end examples. use crate::{ - DecodeError, DecodeGuardrails, DecodedRgbaImage, DecodedRgbaPixels, HeifInputFamily, - decode_bufread_to_rgba_with_guardrails, decode_bytes_to_rgba_with_guardrails, - decode_path_to_rgba_with_guardrails, decode_read_to_rgba_with_guardrails, - decode_seekable_to_rgba_with_hint_and_guardrails, + DecodeError, DecodeGuardrails, DecodedRgbaImage, DecodedRgbaLayout, DecodedRgbaPixels, + HeifInputFamily, decode_bytes_to_rgba_layout_with_hint_and_guardrails, + decode_bytes_to_rgba8_slice_with_hint_and_guardrails, + decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails, }; use image::error::{ DecodingError, ImageFormatHint, ParameterError, ParameterErrorKind, UnsupportedError, UnsupportedErrorKind, }; use image::hooks; +use image::metadata::Orientation; use image::{ColorType, DynamicImage, ImageBuffer, ImageDecoder, ImageError, ImageResult, Rgba}; use std::error::Error; use std::ffi::OsString; use std::fmt::{Display, Formatter}; -use std::io::{BufRead, Read, Seek}; -use std::path::Path; +use std::io::{Read, Seek, SeekFrom}; use std::sync::Once; const HOOK_EXTENSION_HEIC: &str = "heic"; @@ -71,140 +71,62 @@ pub fn apply_exif_orientation_dynamic(image: DynamicImage, exif_orientation: u8) } } -/// Dedicated `image::ImageDecoder` adapter backed by decoded RGBA samples. -/// -/// This adapter decodes HEIF/HEIC/AVIF inputs directly into in-memory RGBA and -/// exposes the buffer via the `image` crate's decoder trait without any PNG -/// intermediate transcode. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct HeifImageDecoder { - decoded: DecodedRgbaImage, +struct LazyHeifImageDecoder { + input: Vec, + hint: Option, + guardrails: DecodeGuardrails, + layout: DecodedRgbaLayout, + // Primary-item payload extraction the layout probe already performed; + // `read_image` consumes it instead of copying every payload out of the + // container a second time. + preextracted_heic: Option, } -impl HeifImageDecoder { - /// Build an adapter from an already decoded RGBA image. - pub fn from_decoded(decoded: DecodedRgbaImage) -> ImageResult { - validate_decoded_rgba_image(&decoded)?; - Ok(Self { decoded }) - } - - /// Decode HEIF/HEIC/AVIF bytes into an `image::ImageDecoder` adapter. - pub fn from_bytes(input: &[u8]) -> ImageResult { - Self::from_bytes_with_guardrails(input, DecodeGuardrails::default()) - } - - /// Decode HEIF/HEIC/AVIF bytes into an `image::ImageDecoder` adapter with configurable guardrails. - pub fn from_bytes_with_guardrails( - input: &[u8], - guardrails: DecodeGuardrails, - ) -> ImageResult { - let decoded = decode_bytes_to_rgba_with_guardrails(input, guardrails) - .map_err(decode_error_to_image_error)?; - Self::from_decoded(decoded) - } - - /// Decode a `Read` source into an `image::ImageDecoder` adapter. - pub fn from_read(input_reader: R) -> ImageResult { - Self::from_read_with_guardrails(input_reader, DecodeGuardrails::default()) - } - - /// Decode a `Read` source into an `image::ImageDecoder` adapter with configurable guardrails. - pub fn from_read_with_guardrails( - input_reader: R, - guardrails: DecodeGuardrails, - ) -> ImageResult { - let decoded = decode_read_to_rgba_with_guardrails(input_reader, guardrails) - .map_err(decode_error_to_image_error)?; - Self::from_decoded(decoded) - } - - /// Decode a seekable `Read` source into an `image::ImageDecoder` adapter. - pub fn from_seekable(input_reader: R) -> ImageResult { - Self::from_seekable_with_guardrails(input_reader, DecodeGuardrails::default()) - } - - /// Decode a seekable `Read` source into an `image::ImageDecoder` adapter with configurable guardrails. - pub fn from_seekable_with_guardrails( - input_reader: R, - guardrails: DecodeGuardrails, - ) -> ImageResult { - Self::from_seekable_with_hint_and_guardrails(input_reader, None, guardrails) - } - - /// Decode a `BufRead` source into an `image::ImageDecoder` adapter. - pub fn from_bufread(input_reader: R) -> ImageResult { - Self::from_bufread_with_guardrails(input_reader, DecodeGuardrails::default()) - } - - /// Decode a `BufRead` source into an `image::ImageDecoder` adapter with configurable guardrails. - pub fn from_bufread_with_guardrails( - input_reader: R, - guardrails: DecodeGuardrails, - ) -> ImageResult { - let decoded = decode_bufread_to_rgba_with_guardrails(input_reader, guardrails) - .map_err(decode_error_to_image_error)?; - Self::from_decoded(decoded) - } - - /// Decode a file path into an `image::ImageDecoder` adapter. - pub fn from_path(input_path: &Path) -> ImageResult { - Self::from_path_with_guardrails(input_path, DecodeGuardrails::default()) - } - - /// Decode a file path into an `image::ImageDecoder` adapter with configurable guardrails. - pub fn from_path_with_guardrails( - input_path: &Path, - guardrails: DecodeGuardrails, - ) -> ImageResult { - let decoded = decode_path_to_rgba_with_guardrails(input_path, guardrails) - .map_err(decode_error_to_image_error)?; - Self::from_decoded(decoded) - } - - /// Consume the adapter and return the owned decoded RGBA buffer. - pub fn into_decoded_rgba(self) -> DecodedRgbaImage { - self.decoded +// Manual impl: a derived Debug would dump the entire encoded input. +impl std::fmt::Debug for LazyHeifImageDecoder { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LazyHeifImageDecoder") + .field("input_len", &self.input.len()) + .field("hint", &self.hint) + .field("guardrails", &self.guardrails) + .field("layout", &self.layout) + .finish() } +} - fn from_seekable_with_hint_and_guardrails( - input_reader: R, +impl LazyHeifImageDecoder { + fn from_encoded_input( + input: Vec, hint: Option, guardrails: DecodeGuardrails, - ) -> ImageResult { - let decoded = - decode_seekable_to_rgba_with_hint_and_guardrails(input_reader, hint, guardrails) - .map_err(decode_error_to_image_error)?; - Self::from_decoded(decoded) + probe: crate::RgbaLayoutProbe, + ) -> Self { + Self { + input, + hint, + guardrails, + layout: probe.layout, + preextracted_heic: probe.preextracted_heic, + } } fn storage_color_type(&self) -> ColorType { - match self.decoded.storage_bit_depth() { - 8 => ColorType::Rgba8, - 16 => ColorType::Rgba16, - other => { - unreachable!("validated storage bit depth must be 8 or 16, got {other}") - } - } + storage_color_type_from_bit_depth(self.layout.storage_bit_depth) } fn expected_total_bytes(&self) -> ImageResult { - expected_rgba_byte_count( - self.decoded.width, - self.decoded.height, - self.decoded.storage_bit_depth(), + expected_rgba_total_bytes( + self.layout.width, + self.layout.height, + self.layout.storage_bit_depth, ) - .ok_or_else(|| { - parameter_error(format!( - "decoded RGBA buffer size overflow for {}x{} image", - self.decoded.width, self.decoded.height - )) - }) } } -impl ImageDecoder for HeifImageDecoder { +impl ImageDecoder for LazyHeifImageDecoder { fn dimensions(&self) -> (u32, u32) { - (self.decoded.width, self.decoded.height) + (self.layout.width, self.layout.height) } fn color_type(&self) -> ColorType { @@ -212,7 +134,30 @@ impl ImageDecoder for HeifImageDecoder { } fn icc_profile(&mut self) -> ImageResult>> { - Ok(self.decoded.icc_profile.clone()) + Ok(self.layout.icc_profile.clone()) + } + + // Decode does not bake EXIF orientation into pixels (libheif parity; + // orientation is applied at the application layer), so expose the EXIF + // block through the trait: `ImageDecoder::orientation()` derives from it, + // and without this override image-crate callers would always see + // `Orientation::NoTransforms` for EXIF-only-rotated files. + fn exif_metadata(&mut self) -> ImageResult>> { + Ok(crate::primary_exif_tiff_payload(&self.input)) + } + + // Container transforms (`irot`/`imir`) are baked into the decoded pixels, + // so reporting the EXIF orientation on top would make generic + // `orientation()` + `apply_orientation` callers double-rotate. Mirror the + // gate in `ExifOrientationHint::should_apply_exif_orientation`. + fn orientation(&mut self) -> ImageResult { + if crate::primary_item_has_orientation_transform(&self.input) { + return Ok(Orientation::NoTransforms); + } + Ok(self + .exif_metadata()? + .and_then(|chunk| Orientation::from_exif_chunk(&chunk)) + .unwrap_or(Orientation::NoTransforms)) } fn read_image(self, buf: &mut [u8]) -> ImageResult<()> @@ -226,16 +171,27 @@ impl ImageDecoder for HeifImageDecoder { ))); } - match self.decoded.pixels { - DecodedRgbaPixels::U8(pixels) => { - buf.copy_from_slice(&pixels); - } - DecodedRgbaPixels::U16(pixels) => { - write_rgba16_native_endian_bytes(&pixels, buf); + match self.layout.storage_bit_depth { + 8 => decode_bytes_to_rgba8_slice_with_hint_and_guardrails( + &self.input, + self.hint, + self.guardrails, + self.preextracted_heic, + buf, + ) + .map_err(decode_error_to_image_error), + 16 => decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails( + &self.input, + self.hint, + self.guardrails, + self.preextracted_heic, + buf, + ) + .map_err(decode_error_to_image_error), + other => { + unreachable!("validated storage bit depth must be 8 or 16, got {other}") } } - - Ok(()) } fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { @@ -243,6 +199,98 @@ impl ImageDecoder for HeifImageDecoder { } } +/// Build a hook decoder that probes metadata up front and defers pixel decode +/// until `read_image` supplies the destination buffer. +/// +/// Memory invariant: every accepted format writes into the caller's buffer; +/// there is no eager owned-RGBA fallback. Codec-native planes and bounded grid +/// tile scratch buffers may still be allocated during decode. +fn decoder_from_seekable_with_hint_and_guardrails( + input_reader: R, + hint: Option, + guardrails: DecodeGuardrails, +) -> ImageResult> { + let input = read_seekable_input_to_vec(input_reader, &guardrails)?; + let probe = + decode_bytes_to_rgba_layout_with_hint_and_guardrails(&input, hint, guardrails.clone()) + .map_err(decode_error_to_image_error)?; + Ok(Box::new(LazyHeifImageDecoder::from_encoded_input( + input, hint, guardrails, probe, + ))) +} + +/// Pre-allocation ceiling for the seek-reported input length. The reported +/// length is untrusted until the bytes are actually read: a lying or corrupt +/// reader could otherwise trigger a multi-gigabyte allocation (or a +/// capacity-overflow panic) before a single byte arrives. `read_to_end` still +/// grows the buffer past this for genuinely larger, guardrail-permitted +/// inputs. +const MAX_INPUT_PREALLOCATION_BYTES: u64 = 128 * 1024 * 1024; + +/// Default `max_input_bytes` applied by [`register_image_decoder_hooks`]. +/// +/// Hook decodes buffer the entire encoded input, so an unbounded default +/// would let a single oversized file (e.g. a motion-photo HEIC with a +/// multi-gigabyte `mdat`) allocate its full size. Callers that need larger +/// inputs can register with explicit guardrails via +/// [`register_image_decoder_hooks_with_guardrails`]. +pub const DEFAULT_HOOK_MAX_INPUT_BYTES: u64 = 128 * 1024 * 1024; + +/// Read the whole encoded input into memory. +/// +/// This is a deliberate trade: the lazy decoder needs the full input as a +/// byte slice so `read_image` can decode directly into the caller's buffer +/// (without an additional full-frame owned RGBA allocation, which would +/// dominate peak memory). The cost is that the encoded input — usually far +/// smaller than the decoded RGBA — is held in memory for the decoder's +/// lifetime, bounded by `guardrails.max_input_bytes`. +fn read_seekable_input_to_vec( + mut input_reader: R, + guardrails: &DecodeGuardrails, +) -> ImageResult> { + let input_len = input_reader + .seek(SeekFrom::End(0)) + .map_err(ImageError::IoError)?; + guardrails + .enforce_input_bytes(input_len) + .map_err(decode_error_to_image_error)?; + + input_reader + .seek(SeekFrom::Start(0)) + .map_err(ImageError::IoError)?; + let prealloc_len = input_len.min(MAX_INPUT_PREALLOCATION_BYTES); + let capacity = usize::try_from(prealloc_len).map_err(|_| { + parameter_error(format!( + "input size {prealloc_len} bytes does not fit in memory on this platform" + )) + })?; + let mut input = Vec::with_capacity(capacity); + match guardrails + .max_input_bytes + .and_then(|max| max.checked_add(1)) + { + Some(read_limit) => input_reader + .by_ref() + .take(read_limit) + .read_to_end(&mut input) + .map_err(ImageError::IoError)?, + // `None` is intentionally unbounded. A `u64::MAX` limit also has no + // representable sentinel byte, so reading it without `take` preserves + // the configured semantics without overflowing `max + 1`. + None => input_reader + .read_to_end(&mut input) + .map_err(ImageError::IoError)?, + }; + + // Re-check the actual byte count: a reader may yield more bytes than its + // seek-reported length claimed. + guardrails + .enforce_input_bytes(input.len() as u64) + .map_err(decode_error_to_image_error)?; + + Ok(input) +} + /// Result of attempting to install `image` crate decoder hooks for this crate. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ImageHookRegistration { @@ -270,11 +318,27 @@ impl ImageHookRegistration { /// After registration, `image::ImageReader` can decode `.heic`, `.heif`, and /// `.avif` inputs through this crate's pure-Rust decode path, including direct /// extension-based dispatch and content-based `ftyp` guesses for common brands. +/// +/// Memory: hook decodes buffer the entire encoded input in memory before +/// decoding (in exchange, pixels decode straight into the caller's buffer +/// without an additional full-frame RGBA allocation). Codec-native planes +/// and a single grid-tile scratch buffer may still be allocated. The encoded +/// input buffer is capped at [`DEFAULT_HOOK_MAX_INPUT_BYTES`]; callers that +/// need a different bound (or none) should use +/// [`register_image_decoder_hooks_with_guardrails`] with +/// [`DecodeGuardrails::max_input_bytes`] set accordingly. pub fn register_image_decoder_hooks() -> ImageHookRegistration { - register_image_decoder_hooks_with_guardrails(DecodeGuardrails::default()) + register_image_decoder_hooks_with_guardrails(DecodeGuardrails { + max_input_bytes: Some(DEFAULT_HOOK_MAX_INPUT_BYTES), + ..DecodeGuardrails::default() + }) } /// Register HEIF/HEIC/AVIF decoder hooks with `image::hooks`, applying the provided guardrails to all hook decodes. +/// +/// Hook decodes buffer the entire encoded input in memory (see +/// [`register_image_decoder_hooks`]); `guardrails.max_input_bytes` bounds +/// that buffer. pub fn register_image_decoder_hooks_with_guardrails( guardrails: DecodeGuardrails, ) -> ImageHookRegistration { @@ -282,35 +346,32 @@ pub fn register_image_decoder_hooks_with_guardrails( let heic_decoder_hook_registered = hooks::register_decoding_hook( OsString::from(HOOK_EXTENSION_HEIC), Box::new(move |reader| { - let decoder = HeifImageDecoder::from_seekable_with_hint_and_guardrails( + decoder_from_seekable_with_hint_and_guardrails( reader, Some(HeifInputFamily::Heif), heif_guardrails.clone(), - )?; - Ok(Box::new(decoder)) + ) }), ); let heif_guardrails = guardrails.clone(); let heif_decoder_hook_registered = hooks::register_decoding_hook( OsString::from(HOOK_EXTENSION_HEIF), Box::new(move |reader| { - let decoder = HeifImageDecoder::from_seekable_with_hint_and_guardrails( + decoder_from_seekable_with_hint_and_guardrails( reader, Some(HeifInputFamily::Heif), heif_guardrails.clone(), - )?; - Ok(Box::new(decoder)) + ) }), ); let avif_decoder_hook_registered = hooks::register_decoding_hook( OsString::from(HOOK_EXTENSION_AVIF), Box::new(move |reader| { - let decoder = HeifImageDecoder::from_seekable_with_hint_and_guardrails( + decoder_from_seekable_with_hint_and_guardrails( reader, Some(HeifInputFamily::Avif), guardrails.clone(), - )?; - Ok(Box::new(decoder)) + ) }), ); @@ -555,43 +616,23 @@ fn expected_rgba_byte_count(width: u32, height: u32, storage_bit_depth: u8) -> O expected_rgba_sample_count(width, height)?.checked_mul(bytes_per_sample) } -fn validate_decoded_rgba_image(decoded: &DecodedRgbaImage) -> ImageResult<()> { - if decoded.storage_bit_depth() != 8 && decoded.storage_bit_depth() != 16 { - return Err(ImageError::Unsupported( - UnsupportedError::from_format_and_kind( - heif_image_format_hint(), - UnsupportedErrorKind::GenericFeature(format!( - "unsupported decoded RGBA storage bit depth {}", - decoded.storage_bit_depth() - )), - ), - )); - } - - let expected_samples = - expected_rgba_sample_count(decoded.width, decoded.height).ok_or_else(|| { - parameter_error(format!( - "decoded RGBA sample count overflow for {}x{} image", - decoded.width, decoded.height - )) - })?; - let actual_samples = match &decoded.pixels { - DecodedRgbaPixels::U8(pixels) => pixels.len(), - DecodedRgbaPixels::U16(pixels) => pixels.len(), - }; - if actual_samples != expected_samples { - return Err(parameter_error(format!( - "decoded RGBA sample count mismatch for {}x{} image: expected {expected_samples}, got {actual_samples}", - decoded.width, decoded.height - ))); - } - - Ok(()) +/// `expected_rgba_byte_count` with the byte-count overflow mapped to the +/// decoder adapters' shared parameter error. +fn expected_rgba_total_bytes(width: u32, height: u32, storage_bit_depth: u8) -> ImageResult { + expected_rgba_byte_count(width, height, storage_bit_depth).ok_or_else(|| { + parameter_error(format!( + "decoded RGBA buffer size overflow for {width}x{height} image" + )) + }) } -fn write_rgba16_native_endian_bytes(samples: &[u16], out: &mut [u8]) { - for (sample, chunk) in samples.iter().zip(out.chunks_exact_mut(2)) { - chunk.copy_from_slice(&sample.to_ne_bytes()); +fn storage_color_type_from_bit_depth(storage_bit_depth: u8) -> ColorType { + match storage_bit_depth { + 8 => ColorType::Rgba8, + 16 => ColorType::Rgba16, + other => { + unreachable!("validated storage bit depth must be 8 or 16, got {other}") + } } } @@ -617,3 +658,225 @@ fn decode_error_to_image_error(err: DecodeError) -> ImageError { other => ImageError::Decoding(DecodingError::new(heif_image_format_hint(), other)), } } + +#[cfg(test)] +mod tests { + use super::{ + ColorType, DecodeGuardrails, HeifInputFamily, ImageDecoder, + decoder_from_seekable_with_hint_and_guardrails, read_seekable_input_to_vec, + }; + use std::cell::Cell; + use std::io::Cursor; + use std::rc::Rc; + + /// Locks the uncompressed half of the lazy-adapter contract: layout + /// probing and caller-buffer decoding must stay pixel-identical to the + /// direct owned API. + #[test] + fn hook_decoder_decodes_uncompressed_heif_lazily() { + let (file, expected_rgba) = crate::isobmff::test_support::minimal_uncompressed_rgb3_heif(); + + let decoder = decoder_from_seekable_with_hint_and_guardrails( + Cursor::new(file), + Some(HeifInputFamily::Heif), + DecodeGuardrails::default(), + ) + .expect("hook construction must accept the lazy uncompressed decoder"); + assert_eq!(decoder.dimensions(), (2, 1)); + assert_eq!(decoder.color_type(), ColorType::Rgba8); + + let mut pixels = vec![0_u8; expected_rgba.len()]; + decoder + .read_image_boxed(&mut pixels) + .expect("lazy uncompressed decode should succeed"); + assert_eq!(pixels, expected_rgba); + } + + /// A reader whose seek-reported length lies wildly. The hook must not + /// size an allocation from the untrusted length (that would panic or + /// abort before a single byte is read) and must still decode the bytes + /// the reader actually yields. + struct LyingLengthReader(Cursor>); + + impl std::io::Read for LyingLengthReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } + } + + impl std::io::Seek for LyingLengthReader { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + match pos { + std::io::SeekFrom::End(0) => { + self.0.seek(std::io::SeekFrom::End(0))?; + Ok(u64::MAX) + } + other => self.0.seek(other), + } + } + } + + struct UnderreportedLengthReader { + inner: Cursor>, + bytes_read: Rc>, + } + + impl std::io::Read for UnderreportedLengthReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let read = self.inner.read(buf)?; + self.bytes_read.set(self.bytes_read.get() + read); + Ok(read) + } + } + + impl std::io::Seek for UnderreportedLengthReader { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + match pos { + std::io::SeekFrom::End(0) => { + self.inner.seek(std::io::SeekFrom::End(0))?; + Ok(0) + } + other => self.inner.seek(other), + } + } + } + + #[test] + fn hook_input_read_enforces_limit_while_buffering() { + let guardrails = DecodeGuardrails { + max_input_bytes: Some(8), + ..DecodeGuardrails::default() + }; + + let exact_reads = Rc::new(Cell::new(0)); + let exact_input = vec![1_u8; 8]; + let input = read_seekable_input_to_vec( + UnderreportedLengthReader { + inner: Cursor::new(exact_input.clone()), + bytes_read: Rc::clone(&exact_reads), + }, + &guardrails, + ) + .expect("an input exactly at max_input_bytes should be accepted"); + assert_eq!(input, exact_input); + assert_eq!(exact_reads.get(), 8); + + let oversized_reads = Rc::new(Cell::new(0)); + let error = read_seekable_input_to_vec( + UnderreportedLengthReader { + inner: Cursor::new(vec![2_u8; 64]), + bytes_read: Rc::clone(&oversized_reads), + }, + &guardrails, + ) + .expect_err("an input larger than max_input_bytes should be rejected"); + assert_eq!(oversized_reads.get(), 9); + assert!( + error + .to_string() + .contains("input exceeds configured max_input_bytes") + ); + } + + /// Decode does not bake EXIF orientation into pixels, so hook callers + /// rely on `exif_metadata`/`orientation` to rotate correctly; without + /// the override they would always see `Orientation::NoTransforms`. + #[test] + fn hook_decoder_exposes_exif_orientation() { + let (file, expected_rgba, expected_tiff) = + crate::isobmff::test_support::minimal_uncompressed_rgb3_heif_with_exif_orientation(6); + + let mut decoder = decoder_from_seekable_with_hint_and_guardrails( + Cursor::new(file), + Some(HeifInputFamily::Heif), + DecodeGuardrails::default(), + ) + .expect("hook construction should succeed"); + assert_eq!( + decoder + .exif_metadata() + .expect("exif metadata read should succeed"), + Some(expected_tiff) + ); + assert_eq!( + decoder + .orientation() + .expect("orientation read should succeed"), + image::metadata::Orientation::Rotate90 + ); + + // Pixels stay unrotated; orientation is applied by the caller. + let mut pixels = vec![0_u8; expected_rgba.len()]; + decoder + .read_image_boxed(&mut pixels) + .expect("lazy uncompressed decode should succeed"); + assert_eq!(pixels, expected_rgba); + } + + /// When the primary item carries `irot`/`imir`, decode bakes that + /// transform into the pixels, so `orientation()` must report + /// `NoTransforms` — otherwise generic `orientation()` + + /// `apply_orientation` callers double-rotate. Mirrors + /// `ExifOrientationHint::should_apply_exif_orientation`. + #[test] + fn hook_decoder_suppresses_exif_orientation_when_transforms_bake_rotation() { + let (file, unrotated_rgba, expected_tiff) = crate::isobmff::test_support:: + minimal_uncompressed_rgb3_heif_with_exif_orientation_and_transforms( + 6, + &[crate::isobmff::test_support::irot_box(1)], + ); + + let mut decoder = decoder_from_seekable_with_hint_and_guardrails( + Cursor::new(file), + Some(HeifInputFamily::Heif), + DecodeGuardrails::default(), + ) + .expect("hook construction should succeed"); + + // The EXIF block itself stays exposed (camera metadata and friends); + // only the derived orientation is suppressed. + assert_eq!( + decoder + .exif_metadata() + .expect("exif metadata read should succeed"), + Some(expected_tiff) + ); + assert_eq!( + decoder + .orientation() + .expect("orientation read should succeed"), + image::metadata::Orientation::NoTransforms + ); + + // The irot is baked into the pixels: 2x1 rotated 90 degrees CCW. + assert_eq!(decoder.dimensions(), (1, 2)); + let mut pixels = vec![0_u8; unrotated_rgba.len()]; + decoder + .read_image_boxed(&mut pixels) + .expect("lazy uncompressed decode should succeed"); + let expected_rotated: Vec = unrotated_rgba[4..8] + .iter() + .chain(&unrotated_rgba[0..4]) + .copied() + .collect(); + assert_eq!(pixels, expected_rotated); + } + + #[test] + fn hook_decoder_survives_lying_seek_length() { + let (file, expected_rgba) = crate::isobmff::test_support::minimal_uncompressed_rgb3_heif(); + + let decoder = decoder_from_seekable_with_hint_and_guardrails( + LyingLengthReader(Cursor::new(file)), + Some(HeifInputFamily::Heif), + DecodeGuardrails::default(), + ) + .expect("a lying seek length must not fail hook construction"); + + let mut pixels = vec![0_u8; expected_rgba.len()]; + decoder + .read_image_boxed(&mut pixels) + .expect("decode should use the bytes the reader actually yields"); + assert_eq!(pixels, expected_rgba); + } +} diff --git a/src/isobmff.rs b/src/isobmff.rs index 8a622bd..b113a41 100644 --- a/src/isobmff.rs +++ b/src/isobmff.rs @@ -7077,20 +7077,15 @@ fn read_u64_be(input: &[u8]) -> u64 { ]) } +/// Minimal ISOBMFF builders shared by container-level tests across modules. +/// +/// These synthesize just enough structure for the paths under test: `meta` +/// graphs (pitm/iloc/iinf/iprp/iref) for colour-property resolution, and a +/// complete decodable uncompressed `rgb3` file for the lazy-image-adapter +/// contract tests in `lib.rs` and `image_integration.rs`. #[cfg(test)] -mod tests { - use super::{ - FourCc, IccColorProfile, NclxColorProfile, PrimaryItemColorProperties, - merge_primary_and_grid_tile_color_properties, primary_heic_color_properties, - }; - - // -- Minimal ISOBMFF builders ------------------------------------------- - // - // These synthesize just enough `meta` structure for the primary-item - // colour-property graph (pitm/iloc/iinf/iprp/iref); no image payload is - // needed because colour resolution never touches item data. - - fn plain_box(box_type: &[u8; 4], payload: &[u8]) -> Vec { +pub(crate) mod test_support { + pub(crate) fn plain_box(box_type: &[u8; 4], payload: &[u8]) -> Vec { let mut out = Vec::with_capacity(8 + payload.len()); out.extend_from_slice(&u32::try_from(8 + payload.len()).unwrap().to_be_bytes()); out.extend_from_slice(box_type); @@ -7098,20 +7093,20 @@ mod tests { out } - fn full_box(box_type: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec { + pub(crate) fn full_box(box_type: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec { let mut body = Vec::with_capacity(4 + payload.len()); body.extend_from_slice(&((u32::from(version) << 24) | flags).to_be_bytes()); body.extend_from_slice(payload); plain_box(box_type, &body) } - fn pitm_box(item_id: u16) -> Vec { + pub(crate) fn pitm_box(item_id: u16) -> Vec { full_box(b"pitm", 0, 0, &item_id.to_be_bytes()) } /// iloc v0 with zero-size offset/length/base_offset fields and no extents: /// enough for graph resolution, which only requires the entry to exist. - fn iloc_box(item_ids: &[u16]) -> Vec { + pub(crate) fn iloc_box(item_ids: &[u16]) -> Vec { let mut payload = Vec::new(); payload.extend_from_slice(&0_u16.to_be_bytes()); // all size fields zero payload.extend_from_slice(&u16::try_from(item_ids.len()).unwrap().to_be_bytes()); @@ -7123,7 +7118,23 @@ mod tests { full_box(b"iloc", 0, 0, &payload) } - fn infe_box(item_id: u16, item_type: &[u8; 4]) -> Vec { + /// iloc v0 with one file-absolute extent (construction method 0) per item. + pub(crate) fn iloc_with_extent_box(items: &[(u16, u32, u32)]) -> Vec { + let mut payload = Vec::new(); + // offset_size 4, length_size 4, base_offset_size 0. + payload.extend_from_slice(&0x4400_u16.to_be_bytes()); + payload.extend_from_slice(&u16::try_from(items.len()).unwrap().to_be_bytes()); + for (item_id, extent_offset, extent_length) in items { + payload.extend_from_slice(&item_id.to_be_bytes()); + payload.extend_from_slice(&0_u16.to_be_bytes()); // data_reference_index + payload.extend_from_slice(&1_u16.to_be_bytes()); // extent_count + payload.extend_from_slice(&extent_offset.to_be_bytes()); + payload.extend_from_slice(&extent_length.to_be_bytes()); + } + full_box(b"iloc", 0, 0, &payload) + } + + pub(crate) fn infe_box(item_id: u16, item_type: &[u8; 4]) -> Vec { let mut payload = Vec::new(); payload.extend_from_slice(&item_id.to_be_bytes()); payload.extend_from_slice(&0_u16.to_be_bytes()); // item_protection_index @@ -7132,7 +7143,7 @@ mod tests { full_box(b"infe", 2, 0, &payload) } - fn iinf_box(entries: &[Vec]) -> Vec { + pub(crate) fn iinf_box(entries: &[Vec]) -> Vec { let mut payload = Vec::new(); payload.extend_from_slice(&u16::try_from(entries.len()).unwrap().to_be_bytes()); for entry in entries { @@ -7141,7 +7152,7 @@ mod tests { full_box(b"iinf", 0, 0, &payload) } - fn colr_nclx_box( + pub(crate) fn colr_nclx_box( colour_primaries: u16, transfer_characteristics: u16, matrix_coefficients: u16, @@ -7156,7 +7167,7 @@ mod tests { plain_box(b"colr", &payload) } - fn colr_icc_box(profile: &[u8]) -> Vec { + pub(crate) fn colr_icc_box(profile: &[u8]) -> Vec { let mut payload = Vec::new(); payload.extend_from_slice(b"prof"); payload.extend_from_slice(profile); @@ -7165,14 +7176,14 @@ mod tests { /// Minimal parseable hvcC: configurationVersion 1, everything else zero, /// zero NAL arrays. - fn hvcc_box() -> Vec { + pub(crate) fn hvcc_box() -> Vec { let mut payload = vec![0_u8; 23]; payload[0] = 1; plain_box(b"hvcC", &payload) } /// ipma v0/flags0: each entry is (item_id, 1-based ipco property indices). - fn ipma_box(entries: &[(u16, &[u8])]) -> Vec { + pub(crate) fn ipma_box(entries: &[(u16, &[u8])]) -> Vec { let mut payload = Vec::new(); payload.extend_from_slice(&u32::try_from(entries.len()).unwrap().to_be_bytes()); for (item_id, property_indices) in entries { @@ -7183,7 +7194,7 @@ mod tests { full_box(b"ipma", 0, 0, &payload) } - fn iprp_box(properties: &[Vec], ipma_entries: &[(u16, &[u8])]) -> Vec { + pub(crate) fn iprp_box(properties: &[Vec], ipma_entries: &[(u16, &[u8])]) -> Vec { let mut ipco_payload = Vec::new(); for property in properties { ipco_payload.extend_from_slice(property); @@ -7193,17 +7204,25 @@ mod tests { plain_box(b"iprp", &payload) } - fn iref_dimg_box(from_item_id: u16, to_item_ids: &[u16]) -> Vec { + pub(crate) fn iref_reference_box( + reference_type: &[u8; 4], + from_item_id: u16, + to_item_ids: &[u16], + ) -> Vec { let mut child_payload = Vec::new(); child_payload.extend_from_slice(&from_item_id.to_be_bytes()); child_payload.extend_from_slice(&u16::try_from(to_item_ids.len()).unwrap().to_be_bytes()); for to_item_id in to_item_ids { child_payload.extend_from_slice(&to_item_id.to_be_bytes()); } - full_box(b"iref", 0, 0, &plain_box(b"dimg", &child_payload)) + full_box(b"iref", 0, 0, &plain_box(reference_type, &child_payload)) + } + + pub(crate) fn iref_dimg_box(from_item_id: u16, to_item_ids: &[u16]) -> Vec { + iref_reference_box(b"dimg", from_item_id, to_item_ids) } - fn meta_file(children: &[Vec]) -> Vec { + pub(crate) fn meta_file(children: &[Vec]) -> Vec { let mut payload = Vec::new(); for child in children { payload.extend_from_slice(child); @@ -7211,6 +7230,160 @@ mod tests { full_box(b"meta", 0, 0, &payload) } + pub(crate) fn ftyp_box(major_brand: &[u8; 4]) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(major_brand); + payload.extend_from_slice(&0_u32.to_be_bytes()); // minor_version + payload.extend_from_slice(major_brand); // compatible brand + plain_box(b"ftyp", &payload) + } + + pub(crate) fn ispe_box(width: u32, height: u32) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(&width.to_be_bytes()); + payload.extend_from_slice(&height.to_be_bytes()); + full_box(b"ispe", 0, 0, &payload) + } + + /// uncC version 1: the whole payload is the profile FourCC. + pub(crate) fn uncc_v1_box(profile: &[u8; 4]) -> Vec { + full_box(b"uncC", 1, 0, profile) + } + + /// A complete, decodable uncompressed HEIF file: `mif1` brand, a 2x1 + /// `unci` primary item with the version-1 `rgb3` (8-bit interleaved RGB) + /// profile, and the pixel payload in a trailing `mdat`. + /// + /// Returns the file bytes and the RGBA8 pixels a decode must produce. + pub(crate) fn minimal_uncompressed_rgb3_heif() -> (Vec, Vec) { + let pixels_rgb: [u8; 6] = [10, 20, 30, 200, 150, 100]; + let expected_rgba: Vec = vec![10, 20, 30, 255, 200, 150, 100, 255]; + + let ftyp = ftyp_box(b"mif1"); + let meta_with_pixel_offset = |pixel_offset: u32| { + meta_file(&[ + pitm_box(1), + iloc_with_extent_box(&[( + 1, + pixel_offset, + u32::try_from(pixels_rgb.len()).unwrap(), + )]), + iinf_box(&[infe_box(1, b"unci")]), + iprp_box(&[ispe_box(2, 1), uncc_v1_box(b"rgb3")], &[(1, &[1, 2])]), + ]) + }; + // The iloc extent offset is file-absolute and all iloc fields are + // fixed-size, so a first pass with a placeholder offset yields the + // final layout to compute the real offset from. + let meta_len = meta_with_pixel_offset(0).len(); + let mdat_header_len = 8; + let pixel_offset = u32::try_from(ftyp.len() + meta_len + mdat_header_len).unwrap(); + let meta = meta_with_pixel_offset(pixel_offset); + + let mut file = Vec::new(); + file.extend_from_slice(&ftyp); + file.extend_from_slice(&meta); + file.extend_from_slice(&plain_box(b"mdat", &pixels_rgb)); + (file, expected_rgba) + } + + /// [`minimal_uncompressed_rgb3_heif`] plus a cdsc-linked `Exif` item + /// whose big-endian TIFF block carries the given orientation. + /// + /// Returns the file bytes, the RGBA8 pixels a decode must produce, and + /// the raw TIFF block `ImageDecoder::exif_metadata` must hand back. + #[cfg(feature = "image-integration")] + pub(crate) fn minimal_uncompressed_rgb3_heif_with_exif_orientation( + orientation: u16, + ) -> (Vec, Vec, Vec) { + minimal_uncompressed_rgb3_heif_with_exif_orientation_and_transforms(orientation, &[]) + } + + #[cfg(feature = "image-integration")] + pub(crate) fn irot_box(rotation_ccw_quarter_turns: u8) -> Vec { + plain_box(b"irot", &[rotation_ccw_quarter_turns & 0x03]) + } + + /// [`minimal_uncompressed_rgb3_heif_with_exif_orientation`] with extra + /// transform property boxes (`irot`/`imir`/`clap`) associated with the + /// primary item after `ispe` and `uncC`. + /// + /// The returned RGBA pixels are the untransformed samples; callers that + /// pass transforms derive their own expected output. + #[cfg(feature = "image-integration")] + pub(crate) fn minimal_uncompressed_rgb3_heif_with_exif_orientation_and_transforms( + orientation: u16, + transform_properties: &[Vec], + ) -> (Vec, Vec, Vec) { + let pixels_rgb: [u8; 6] = [10, 20, 30, 200, 150, 100]; + let expected_rgba: Vec = vec![10, 20, 30, 255, 200, 150, 100, 255]; + + // Big-endian TIFF: header, one IFD with a single SHORT orientation + // entry (value left-justified in the 4-byte field), no next IFD. + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"MM\x00\x2A"); + tiff.extend_from_slice(&8_u32.to_be_bytes()); + tiff.extend_from_slice(&1_u16.to_be_bytes()); + tiff.extend_from_slice(&0x0112_u16.to_be_bytes()); + tiff.extend_from_slice(&3_u16.to_be_bytes()); + tiff.extend_from_slice(&1_u32.to_be_bytes()); + tiff.extend_from_slice(&orientation.to_be_bytes()); + tiff.extend_from_slice(&[0, 0]); + tiff.extend_from_slice(&0_u32.to_be_bytes()); + + // HEIF EXIF item payload: 4-byte exif_tiff_header_offset, then TIFF. + let mut exif_payload = Vec::new(); + exif_payload.extend_from_slice(&0_u32.to_be_bytes()); + exif_payload.extend_from_slice(&tiff); + + let mut properties = vec![ispe_box(2, 1), uncc_v1_box(b"rgb3")]; + properties.extend_from_slice(transform_properties); + let association_indices: Vec = (1..=u8::try_from(properties.len()).unwrap()).collect(); + + let ftyp = ftyp_box(b"mif1"); + let meta_with_data_offsets = |pixel_offset: u32, exif_offset: u32| { + meta_file(&[ + pitm_box(1), + iloc_with_extent_box(&[ + (1, pixel_offset, u32::try_from(pixels_rgb.len()).unwrap()), + (2, exif_offset, u32::try_from(exif_payload.len()).unwrap()), + ]), + iinf_box(&[infe_box(1, b"unci"), infe_box(2, b"Exif")]), + iref_reference_box(b"cdsc", 2, &[1]), + iprp_box(&properties, &[(1, &association_indices)]), + ]) + }; + // The iloc extent offsets are file-absolute and all iloc fields are + // fixed-size, so a first pass with placeholder offsets yields the + // final layout to compute the real offsets from. + let meta_len = meta_with_data_offsets(0, 0).len(); + let mdat_header_len = 8; + let pixel_offset = u32::try_from(ftyp.len() + meta_len + mdat_header_len).unwrap(); + let exif_offset = pixel_offset + u32::try_from(pixels_rgb.len()).unwrap(); + let meta = meta_with_data_offsets(pixel_offset, exif_offset); + + let mut mdat_payload = pixels_rgb.to_vec(); + mdat_payload.extend_from_slice(&exif_payload); + + let mut file = Vec::new(); + file.extend_from_slice(&ftyp); + file.extend_from_slice(&meta); + file.extend_from_slice(&plain_box(b"mdat", &mdat_payload)); + (file, expected_rgba, tiff) + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{ + colr_icc_box, colr_nclx_box, hvcc_box, iinf_box, iloc_box, infe_box, iprp_box, + iref_dimg_box, meta_file, pitm_box, + }; + use super::{ + FourCc, IccColorProfile, NclxColorProfile, PrimaryItemColorProperties, + merge_primary_and_grid_tile_color_properties, primary_heic_color_properties, + }; + // -- Container-level colour resolution tests ---------------------------- #[test] diff --git a/src/lib.rs b/src/lib.rs index 505ce73..776764f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,8 +15,12 @@ use moxcms::{ CicpColorPrimaries, CicpProfile, ColorProfile, LocalizableString, MatrixCoefficients, ProfileText, TransferCharacteristics, Xyzd, }; +#[cfg(feature = "image-integration")] +use rav1d::dav1d_parse_sequence_header; use rav1d::include::dav1d::data::Dav1dData; use rav1d::include::dav1d::dav1d::{Dav1dContext, Dav1dSettings}; +#[cfg(feature = "image-integration")] +use rav1d::include::dav1d::headers::Dav1dSequenceHeader; use rav1d::include::dav1d::headers::{ DAV1D_PIXEL_LAYOUT_I400, DAV1D_PIXEL_LAYOUT_I420, DAV1D_PIXEL_LAYOUT_I422, DAV1D_PIXEL_LAYOUT_I444, @@ -35,8 +39,6 @@ use std::error::Error; use std::ffi::c_void; use std::fmt::{Display, Formatter}; use std::fs::File; -#[cfg(feature = "image-integration")] -use std::io::Seek; use std::io::{BufRead, BufWriter, Read}; use std::mem::MaybeUninit; use std::path::{Path, PathBuf}; @@ -608,6 +610,16 @@ pub struct DecodedRgbaImage { pub icc_profile: Option>, } +#[cfg(feature = "image-integration")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DecodedRgbaLayout { + pub width: u32, + pub height: u32, + pub source_bit_depth: u8, + pub storage_bit_depth: u8, + pub icc_profile: Option>, +} + /// HEIF EXIF-orientation inspection result for caller-controlled display transforms. /// /// `exif_orientation` is the raw EXIF orientation value (`1..=8`) when present. @@ -832,6 +844,7 @@ pub enum DecodeAvifError { UnsupportedMatrixCoefficients { matrix_coefficients: u16, }, + MissingSequenceHeader, } impl DecodeAvifError { @@ -857,9 +870,8 @@ impl DecodeAvifError { | DecodeAvifError::DecodedGeometryMismatch { .. } | DecodeAvifError::PlaneSampleTypeMismatch { .. } | DecodeAvifError::PlaneDimensionsMismatch { .. } - | DecodeAvifError::PlaneSampleCountMismatch { .. } => { - DecodeErrorCategory::MalformedInput - } + | DecodeAvifError::PlaneSampleCountMismatch { .. } + | DecodeAvifError::MissingSequenceHeader => DecodeErrorCategory::MalformedInput, } } } @@ -887,6 +899,12 @@ impl Display for DecodeAvifError { DecodeAvifError::UnsupportedBitDepth { bit_depth } => { write!(f, "decoded AV1 frame has unsupported bit depth {bit_depth}") } + DecodeAvifError::MissingSequenceHeader => { + write!( + f, + "AV1 sequence header not found in av1C configOBUs or primary item payload" + ) + } DecodeAvifError::UnsupportedPixelLayout { layout } => { write!( f, @@ -1390,17 +1408,7 @@ fn decode_primary_heic_to_image_internal( Ok(decoded) } isobmff::HeicPrimaryItemDataWithGrid::Coded(item_data) => { - let (stream, metadata, ycbcr_range_override, ycbcr_matrix_override) = - decode_primary_heic_stream_and_metadata_from_coded_item_data(input, &item_data)?; - let mut decoded = decode_hevc_stream_to_image(&stream)?; - if let Some(ycbcr_range) = ycbcr_range_override { - decoded.ycbcr_range = ycbcr_range; - } - if let Some(ycbcr_matrix) = ycbcr_matrix_override { - decoded.ycbcr_matrix = ycbcr_matrix; - } - validate_decoded_heic_image_against_metadata(&decoded, &metadata)?; - Ok(decoded) + decode_primary_heic_coded_item_to_image(input, &item_data) } } } @@ -1690,14 +1698,173 @@ fn decode_primary_uncompressed_to_image_internal( input: &[u8], source: &mut Option<&mut dyn RandomAccessSource>, ) -> Result { - // Provenance: baseline decode flow mirrors libheif uncompressed handling in - // libheif/libheif/codecs/uncompressed/unc_codec.cc: - // UncompressedImageCodec::{check_header_validity,decode_uncompressed_image} - // and decoder dispatch constraints from - // libheif/libheif/codecs/uncompressed/unc_decoder.cc: - // unc_decoder_factory::{check_common_requirements,get_unc_decoder}. - let properties = isobmff::parse_primary_uncompressed_item_properties(input)?; + let decoded = decode_primary_uncompressed_to_channels_internal(input, source)?; + let mut rgba = Vec::with_capacity(decoded.rgba_sample_count()?); + for pixel_index in 0..decoded.pixel_count()? { + rgba.extend_from_slice(&decoded.rgba_at(pixel_index)?); + } + + Ok(DecodedUncompressedImage { + width: decoded.width, + height: decoded.height, + bit_depth: decoded.output_bit_depth, + rgba, + icc_profile: decoded.icc_profile, + }) +} + +struct DecodedUncompressedChannels { + width: u32, + height: u32, + output_bit_depth: u8, + has_channel: [bool; UNCOMPRESSED_CHANNEL_COUNT], + channel_bit_depths: [u8; UNCOMPRESSED_CHANNEL_COUNT], + channel_samples: [Option>; UNCOMPRESSED_CHANNEL_COUNT], + has_monochrome: bool, + has_full_ycbcr: bool, + ycbcr_converter: Option, + alpha_default: u16, + icc_profile: Option>, +} + +impl DecodedUncompressedChannels { + fn pixel_count(&self) -> Result { + let width = + usize::try_from(self.width).map_err(|_| DecodeUncompressedError::InvalidInput { + detail: format!( + "uncompressed image width {} cannot be represented", + self.width + ), + })?; + let height = + usize::try_from(self.height).map_err(|_| DecodeUncompressedError::InvalidInput { + detail: format!( + "uncompressed image height {} cannot be represented", + self.height + ), + })?; + width + .checked_mul(height) + .ok_or_else(|| DecodeUncompressedError::InvalidInput { + detail: format!( + "uncompressed image sample-count overflow for dimensions {}x{}", + self.width, self.height + ), + }) + } + + fn rgba_sample_count(&self) -> Result { + self.pixel_count()? + .checked_mul(4) + .ok_or_else(|| DecodeUncompressedError::InvalidInput { + detail: "uncompressed RGBA output length overflow".to_string(), + }) + } + + fn channel( + &self, + channel_index: usize, + name: &'static str, + ) -> Result<&[u16], DecodeUncompressedError> { + self.channel_samples[channel_index] + .as_deref() + .ok_or_else(|| DecodeUncompressedError::InvalidInput { + detail: format!("missing decoded {name} channel samples"), + }) + } + + fn rgba_at(&self, pixel_index: usize) -> Result<[u16; 4], DecodeUncompressedError> { + let (red, green, blue) = if self.has_monochrome { + let mono = self.channel(UNCOMPRESSED_CHANNEL_MONO, "monochrome")?; + let scaled = scale_uncompressed_sample_bit_depth( + mono[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_MONO], + self.output_bit_depth, + "monochrome", + )?; + (scaled, scaled, scaled) + } else if self.has_full_ycbcr { + let y = self.channel(UNCOMPRESSED_CHANNEL_LUMA, "luma")?; + let cb = self.channel(UNCOMPRESSED_CHANNEL_CB, "Cb")?; + let cr = self.channel(UNCOMPRESSED_CHANNEL_CR, "Cr")?; + let y_sample = scale_uncompressed_sample_bit_depth( + y[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_LUMA], + self.output_bit_depth, + "luma", + )?; + let cb_sample = scale_uncompressed_sample_bit_depth( + cb[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_CB], + self.output_bit_depth, + "Cb", + )?; + let cr_sample = scale_uncompressed_sample_bit_depth( + cr[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_CR], + self.output_bit_depth, + "Cr", + )?; + self.ycbcr_converter + .ok_or_else(|| DecodeUncompressedError::InvalidInput { + detail: "missing YCbCr transform for decoded YCbCr channel set".to_string(), + })? + .convert( + i32::from(y_sample), + i32::from(cb_sample), + i32::from(cr_sample), + ) + } else { + let red = self.channel(UNCOMPRESSED_CHANNEL_RED, "red")?; + let green = self.channel(UNCOMPRESSED_CHANNEL_GREEN, "green")?; + let blue = self.channel(UNCOMPRESSED_CHANNEL_BLUE, "blue")?; + ( + scale_uncompressed_sample_bit_depth( + red[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_RED], + self.output_bit_depth, + "red", + )?, + scale_uncompressed_sample_bit_depth( + green[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_GREEN], + self.output_bit_depth, + "green", + )?, + scale_uncompressed_sample_bit_depth( + blue[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_BLUE], + self.output_bit_depth, + "blue", + )?, + ) + }; + + let alpha = if self.has_channel[UNCOMPRESSED_CHANNEL_ALPHA] { + let alpha = self.channel(UNCOMPRESSED_CHANNEL_ALPHA, "alpha")?; + scale_uncompressed_sample_bit_depth( + alpha[pixel_index], + self.channel_bit_depths[UNCOMPRESSED_CHANNEL_ALPHA], + self.output_bit_depth, + "alpha", + )? + } else { + self.alpha_default + }; + + Ok([red, green, blue, alpha]) + } +} +/// Expand the `uncC` component layout (v1 profile shorthand or v0/v2 `cmpd` +/// mapping) into per-component decode specs plus the effective sampling and +/// interleave types. +/// +/// Shared by the decode path and the image-hook layout probe so component +/// validation cannot drift between them. +fn uncompressed_component_layout_from_properties( + properties: &isobmff::UncompressedPrimaryItemProperties, +) -> Result<(Vec, u8, u8), DecodeUncompressedError> { let mut interleave_type = properties.unc_c.interleave_type; let mut sampling_type = properties.unc_c.sampling_type; let mut component_specs = Vec::new(); @@ -1837,6 +2004,87 @@ fn decode_primary_uncompressed_to_image_internal( } } + Ok((component_specs, sampling_type, interleave_type)) +} + +/// Per-channel presence, bit depth, and component count resolved from the +/// `uncC` component list. +struct UncompressedChannelMap { + has_channel: [bool; UNCOMPRESSED_CHANNEL_COUNT], + channel_bit_depths: [u8; UNCOMPRESSED_CHANNEL_COUNT], + channel_component_counts: [u8; UNCOMPRESSED_CHANNEL_COUNT], +} + +/// Fold component decode specs into the per-channel map, enforcing the +/// duplicate-component rules (only multi-Y luma may repeat, and only at a +/// single bit depth). +/// +/// Shared by the decode path and the image-hook layout probe so the two +/// cannot drift. +fn resolve_uncompressed_channel_map( + component_specs: &[UncompressedComponentDecodeSpec], + interleave_type: u8, +) -> Result { + let mut has_channel = [false; UNCOMPRESSED_CHANNEL_COUNT]; + let mut channel_bit_depths = [0_u8; UNCOMPRESSED_CHANNEL_COUNT]; + let mut channel_component_counts = [0_u8; UNCOMPRESSED_CHANNEL_COUNT]; + for spec in component_specs { + let Some(channel_index) = spec.role.channel_index() else { + continue; + }; + if has_channel[channel_index] { + let allow_duplicate = interleave_type == UNCOMPRESSED_INTERLEAVE_MULTI_Y + && channel_index == UNCOMPRESSED_CHANNEL_LUMA; + if allow_duplicate { + if channel_bit_depths[channel_index] != spec.bit_depth { + return Err(DecodeUncompressedError::UnsupportedFeature { + detail: format!( + "uncC multi-y interleave requires duplicate luma components to use one bit depth (saw {} and {})", + channel_bit_depths[channel_index], spec.bit_depth + ), + }); + } + channel_component_counts[channel_index] = channel_component_counts[channel_index] + .checked_add(1) + .ok_or_else(|| DecodeUncompressedError::InvalidInput { + detail: "uncompressed multi-y luma component-count overflow".to_string(), + })?; + continue; + } + return Err(DecodeUncompressedError::InvalidInput { + detail: format!( + "duplicate component mapping for {} is not supported in this baseline decoder", + uncompressed_channel_name(channel_index) + ), + }); + } + has_channel[channel_index] = true; + channel_bit_depths[channel_index] = spec.bit_depth; + channel_component_counts[channel_index] = 1; + } + + Ok(UncompressedChannelMap { + has_channel, + channel_bit_depths, + channel_component_counts, + }) +} + +fn decode_primary_uncompressed_to_channels_internal( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, +) -> Result { + // Provenance: baseline decode flow mirrors libheif uncompressed handling in + // libheif/libheif/codecs/uncompressed/unc_codec.cc: + // UncompressedImageCodec::{check_header_validity,decode_uncompressed_image} + // and decoder dispatch constraints from + // libheif/libheif/codecs/uncompressed/unc_decoder.cc: + // unc_decoder_factory::{check_common_requirements,get_unc_decoder}. + let properties = isobmff::parse_primary_uncompressed_item_properties(input)?; + + let (component_specs, sampling_type, interleave_type) = + uncompressed_component_layout_from_properties(&properties)?; + let (ycbcr_subsample_x, ycbcr_subsample_y) = match sampling_type { UNCOMPRESSED_SAMPLING_NO_SUBSAMPLING => (1_usize, 1_usize), UNCOMPRESSED_SAMPLING_422 => (2_usize, 1_usize), @@ -1953,43 +2201,11 @@ fn decode_primary_uncompressed_to_image_internal( } })?; - let mut has_channel = [false; UNCOMPRESSED_CHANNEL_COUNT]; - let mut channel_bit_depths = [0_u8; UNCOMPRESSED_CHANNEL_COUNT]; - let mut channel_component_counts = [0_u8; UNCOMPRESSED_CHANNEL_COUNT]; - for spec in &component_specs { - let Some(channel_index) = spec.role.channel_index() else { - continue; - }; - if has_channel[channel_index] { - let allow_duplicate = interleave_type == UNCOMPRESSED_INTERLEAVE_MULTI_Y - && channel_index == UNCOMPRESSED_CHANNEL_LUMA; - if allow_duplicate { - if channel_bit_depths[channel_index] != spec.bit_depth { - return Err(DecodeUncompressedError::UnsupportedFeature { - detail: format!( - "uncC multi-y interleave requires duplicate luma components to use one bit depth (saw {} and {})", - channel_bit_depths[channel_index], spec.bit_depth - ), - }); - } - channel_component_counts[channel_index] = channel_component_counts[channel_index] - .checked_add(1) - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "uncompressed multi-y luma component-count overflow".to_string(), - })?; - continue; - } - return Err(DecodeUncompressedError::InvalidInput { - detail: format!( - "duplicate component mapping for {} is not supported in this baseline decoder", - uncompressed_channel_name(channel_index) - ), - }); - } - has_channel[channel_index] = true; - channel_bit_depths[channel_index] = spec.bit_depth; - channel_component_counts[channel_index] = 1; - } + let UncompressedChannelMap { + has_channel, + channel_bit_depths, + channel_component_counts, + } = resolve_uncompressed_channel_map(&component_specs, interleave_type)?; let has_monochrome = has_channel[UNCOMPRESSED_CHANNEL_MONO]; let has_ycbcr = has_channel[UNCOMPRESSED_CHANNEL_LUMA] @@ -2279,167 +2495,40 @@ fn decode_primary_uncompressed_to_image_internal( ) }); - let mut rgba = Vec::with_capacity(pixel_count.checked_mul(4).ok_or_else(|| { - DecodeUncompressedError::InvalidInput { - detail: "uncompressed RGBA output length overflow".to_string(), - } - })?); - for pixel_index in 0..pixel_count { - let (r_sample, g_sample, b_sample) = if has_monochrome { - let mono = channel_samples[UNCOMPRESSED_CHANNEL_MONO] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded monochrome channel samples".to_string(), - })?; - let scaled = scale_uncompressed_sample_bit_depth( - mono[pixel_index], - channel_bit_depths[UNCOMPRESSED_CHANNEL_MONO], - output_bit_depth, - "monochrome", - )?; - (scaled, scaled, scaled) - } else if has_full_ycbcr { - let y = channel_samples[UNCOMPRESSED_CHANNEL_LUMA] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded luma channel samples".to_string(), - })?; - let cb = channel_samples[UNCOMPRESSED_CHANNEL_CB] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded Cb channel samples".to_string(), - })?; - let cr = channel_samples[UNCOMPRESSED_CHANNEL_CR] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded Cr channel samples".to_string(), - })?; - // Provenance: uncompressed YCbCr conversion (including 4:2:2/4:2:0 - // component/tile-component decode after nearest-neighbor chroma - // expansion) reuses libheif-aligned nclx/range-aware - // YCbCr->RGB conversion semantics from - // libheif/libheif/color-conversion/yuv2rgb.cc: - // Op_YCbCr_to_RGB::convert_colorspace. - let y_sample = scale_uncompressed_sample_bit_depth( - y[pixel_index], - channel_bit_depths[UNCOMPRESSED_CHANNEL_LUMA], - output_bit_depth, - "luma", - )?; - let cb_sample = scale_uncompressed_sample_bit_depth( - cb[pixel_index], - channel_bit_depths[UNCOMPRESSED_CHANNEL_CB], - output_bit_depth, - "Cb", - )?; - let cr_sample = scale_uncompressed_sample_bit_depth( - cr[pixel_index], - channel_bit_depths[UNCOMPRESSED_CHANNEL_CR], - output_bit_depth, - "Cr", - )?; - let converter = - ycbcr_converter.ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing YCbCr transform for decoded YCbCr channel set".to_string(), - })?; - converter.convert( - i32::from(y_sample), - i32::from(cb_sample), - i32::from(cr_sample), - ) - } else { - let red = channel_samples[UNCOMPRESSED_CHANNEL_RED] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded red channel samples".to_string(), - })?; - let green = channel_samples[UNCOMPRESSED_CHANNEL_GREEN] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded green channel samples".to_string(), - })?; - let blue = channel_samples[UNCOMPRESSED_CHANNEL_BLUE] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded blue channel samples".to_string(), - })?; - ( - scale_uncompressed_sample_bit_depth( - red[pixel_index], - channel_bit_depths[UNCOMPRESSED_CHANNEL_RED], - output_bit_depth, - "red", - )?, - scale_uncompressed_sample_bit_depth( - green[pixel_index], - channel_bit_depths[UNCOMPRESSED_CHANNEL_GREEN], - output_bit_depth, - "green", - )?, - scale_uncompressed_sample_bit_depth( - blue[pixel_index], - channel_bit_depths[UNCOMPRESSED_CHANNEL_BLUE], - output_bit_depth, - "blue", - )?, - ) - }; - rgba.push(r_sample); - rgba.push(g_sample); - rgba.push(b_sample); - - let alpha_output = if has_channel[UNCOMPRESSED_CHANNEL_ALPHA] { - channel_samples[UNCOMPRESSED_CHANNEL_ALPHA] - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: "missing decoded alpha channel samples".to_string(), - })?[pixel_index] - } else { - alpha_default - }; - - rgba.push(if has_channel[UNCOMPRESSED_CHANNEL_ALPHA] { - scale_uncompressed_sample_bit_depth( - alpha_output, - channel_bit_depths[UNCOMPRESSED_CHANNEL_ALPHA], - output_bit_depth, - "alpha", - )? - } else { - alpha_default - }); - } - - let icc_profile = icc_profile_from_color_properties(&properties.colr); - - Ok(DecodedUncompressedImage { - width, - height, - bit_depth: output_bit_depth, - rgba, - icc_profile, - }) -} - -fn uncompressed_component_subsampling( - role: UncompressedChannelRole, - sampling_type: u8, -) -> Result<(usize, usize), DecodeUncompressedError> { - match role { - UncompressedChannelRole::ChromaBlue | UncompressedChannelRole::ChromaRed => { - // Provenance: mirrors chroma plane sizing in - // libheif/libheif/codecs/uncompressed/unc_decoder_legacybase.cc: - // unc_decoder_legacybase::buildChannelListEntry. - match sampling_type { - UNCOMPRESSED_SAMPLING_NO_SUBSAMPLING => Ok((1, 1)), - UNCOMPRESSED_SAMPLING_422 => Ok((2, 1)), - UNCOMPRESSED_SAMPLING_420 => Ok((2, 2)), - _ => Err(DecodeUncompressedError::UnsupportedFeature { - detail: format!( - "unsupported uncC sampling_type {sampling_type} for Cb/Cr component decoding" - ), - }), - } + Ok(DecodedUncompressedChannels { + width, + height, + output_bit_depth, + has_channel, + channel_bit_depths, + channel_samples, + has_monochrome, + has_full_ycbcr, + ycbcr_converter, + alpha_default, + icc_profile: icc_profile_from_color_properties(&properties.colr), + }) +} + +fn uncompressed_component_subsampling( + role: UncompressedChannelRole, + sampling_type: u8, +) -> Result<(usize, usize), DecodeUncompressedError> { + match role { + UncompressedChannelRole::ChromaBlue | UncompressedChannelRole::ChromaRed => { + // Provenance: mirrors chroma plane sizing in + // libheif/libheif/codecs/uncompressed/unc_decoder_legacybase.cc: + // unc_decoder_legacybase::buildChannelListEntry. + match sampling_type { + UNCOMPRESSED_SAMPLING_NO_SUBSAMPLING => Ok((1, 1)), + UNCOMPRESSED_SAMPLING_422 => Ok((2, 1)), + UNCOMPRESSED_SAMPLING_420 => Ok((2, 2)), + _ => Err(DecodeUncompressedError::UnsupportedFeature { + detail: format!( + "unsupported uncC sampling_type {sampling_type} for Cb/Cr component decoding" + ), + }), + } } _ => Ok((1, 1)), } @@ -3400,6 +3489,16 @@ fn select_uncompressed_output_bit_depth( if max_bit_depth <= 8 { Ok(8) } else { Ok(16) } } +#[cfg(feature = "image-integration")] +fn uncompressed_output_bit_depth_from_properties( + properties: &isobmff::UncompressedPrimaryItemProperties, +) -> Result { + let (component_specs, _sampling_type, interleave_type) = + uncompressed_component_layout_from_properties(properties)?; + let channel_map = resolve_uncompressed_channel_map(&component_specs, interleave_type)?; + select_uncompressed_output_bit_depth(&channel_map.has_channel, &channel_map.channel_bit_depths) +} + fn max_sample_for_bit_depth(bit_depth: u8) -> Result { if bit_depth == 0 || bit_depth > 16 { return Err(DecodeUncompressedError::InvalidInput { @@ -3907,10 +4006,13 @@ type PrimaryHeicStreamDecodeContext = ( Option, ); -fn decode_primary_heic_stream_and_metadata_from_coded_item_data( +/// Parse the primary coded item's preflight properties and cross-check the +/// item id against the extracted payload. Shared by the stream-assembling +/// decode path and the image-hook layout probe. +fn parse_and_validate_heic_coded_item_preflight( input: &[u8], item_data: &isobmff::HeicPrimaryItemData, -) -> Result { +) -> Result { let properties = isobmff::parse_primary_heic_item_preflight_properties(input)?; if properties.item_id != item_data.item_id { return Err(DecodeHeicError::InvalidDecodedFrame { @@ -3920,6 +4022,14 @@ fn decode_primary_heic_stream_and_metadata_from_coded_item_data( ), }); } + Ok(properties) +} + +fn decode_primary_heic_stream_and_metadata_from_coded_item_data( + input: &[u8], + item_data: &isobmff::HeicPrimaryItemData, +) -> Result { + let properties = parse_and_validate_heic_coded_item_preflight(input, item_data)?; let ycbcr_range_override = ycbcr_range_override_from_primary_colr(&properties.colr); let ycbcr_matrix_override = ycbcr_matrix_override_from_primary_colr(&properties.colr); let stream = assemble_heic_hevc_stream_from_components(&properties.hvcc, &item_data.payload)?; @@ -3932,6 +4042,65 @@ fn decode_primary_heic_stream_and_metadata_from_coded_item_data( Ok((stream, decoded, ycbcr_range_override, ycbcr_matrix_override)) } +/// Decode the primary coded (non-grid) HEIC item: assemble and decode its +/// HEVC stream, apply the container's nclx range/matrix overrides, and +/// validate the decoded frame against the container metadata. +fn decode_primary_heic_coded_item_to_image( + input: &[u8], + item_data: &isobmff::HeicPrimaryItemData, +) -> Result { + let (stream, metadata, ycbcr_range_override, ycbcr_matrix_override) = + decode_primary_heic_stream_and_metadata_from_coded_item_data(input, item_data)?; + let mut decoded = decode_hevc_stream_to_image(&stream)?; + if let Some(ycbcr_range) = ycbcr_range_override { + decoded.ycbcr_range = ycbcr_range; + } + if let Some(ycbcr_matrix) = ycbcr_matrix_override { + decoded.ycbcr_matrix = ycbcr_matrix; + } + validate_decoded_heic_image_against_metadata(&decoded, &metadata)?; + Ok(decoded) +} + +/// Decode the primary coded HEIC item, enforce the pixel-count guardrail, +/// and resolve its auxiliary alpha plane. +fn decode_primary_heic_coded_item_with_alpha( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + item_data: &isobmff::HeicPrimaryItemData, + guardrails: &DecodeGuardrails, +) -> Result<(DecodedHeicImage, Option), DecodeError> { + let decoded = decode_primary_heic_coded_item_to_image(input, item_data)?; + guardrails.enforce_pixel_count(decoded.width, decoded.height)?; + let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + input, + source, + decoded.width, + decoded.height, + ); + Ok((decoded, auxiliary_alpha)) +} + +/// Enforce the pixel-count guardrail on the grid descriptor and resolve the +/// grid's auxiliary alpha plane before any tile is decoded. +fn decode_primary_heic_grid_auxiliary_alpha( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + grid_data: &isobmff::HeicGridPrimaryItemData, + guardrails: &DecodeGuardrails, +) -> Result, DecodeError> { + guardrails.enforce_pixel_count( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )?; + Ok(decode_primary_heic_auxiliary_alpha_plane_internal( + input, + source, + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )) +} + fn decoded_heic_image_to_metadata(decoded: &DecodedHeicImage) -> DecodedHeicImageMetadata { DecodedHeicImageMetadata { width: decoded.width, @@ -3969,29 +4138,7 @@ fn decode_primary_heic_grid_to_image( }); } - let mut decoded_tiles = Vec::with_capacity(grid_data.tiles.len()); - for tile in &grid_data.tiles { - let stream = assemble_heic_hevc_stream_from_components(&tile.hvcc, &tile.payload)?; - let metadata = decode_hevc_stream_metadata_from_sps(&stream)?; - let mut decoded = decode_hevc_stream_to_image(&stream)?; - if let Some(ycbcr_range) = ycbcr_range_override_from_primary_colr(&tile.colr) { - decoded.ycbcr_range = ycbcr_range; - } - if let Some(ycbcr_matrix) = ycbcr_matrix_override_from_primary_colr(&tile.colr) { - decoded.ycbcr_matrix = ycbcr_matrix; - } - validate_decoded_heic_image_against_metadata(&decoded, &metadata)?; - decoded = apply_heic_grid_tile_transforms(decoded, &tile.transforms)?; - decoded_tiles.push(decoded); - } - - stitch_decoded_heic_grid_tiles(&grid_data.descriptor, &decoded_tiles) -} - -fn stitch_decoded_heic_grid_tiles( - descriptor: &isobmff::HeicGridDescriptor, - tiles: &[DecodedHeicImage], -) -> Result { + let descriptor = &grid_data.descriptor; let rows = usize::from(descriptor.rows); let columns = usize::from(descriptor.columns); let expected_tiles = @@ -4002,22 +4149,78 @@ fn stitch_decoded_heic_grid_tiles( descriptor.columns, descriptor.rows ), })?; - if tiles.len() != expected_tiles { + if grid_data.tiles.len() != expected_tiles { return Err(DecodeHeicError::InvalidDecodedFrame { detail: format!( "grid descriptor {}x{} expects {expected_tiles} tiles, got {}", descriptor.columns, descriptor.rows, - tiles.len() + grid_data.tiles.len() ), }); } - let first_tile = tiles - .first() - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: "grid tile list cannot be empty".to_string(), - })?; + let mut output = None; + let mut tile_width = 0; + let mut tile_height = 0; + + for row in 0..rows { + for column in 0..columns { + let tile_index = row + .checked_mul(columns) + .and_then(|idx| idx.checked_add(column)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("grid tile index overflow at row={row}, column={column}"), + })?; + let tile = decode_heic_grid_tile_to_image(&grid_data.tiles[tile_index])?; + + if tile_index == 0 { + tile_width = tile.width; + tile_height = tile.height; + output = Some(create_decoded_heic_grid_output(descriptor, &tile)?); + } + + paste_decoded_heic_grid_tile( + &tile, + output + .as_mut() + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "grid output was not initialized".to_string(), + })?, + tile_width, + tile_height, + row, + column, + tile_index, + )?; + } + } + + output.ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile list cannot be empty".to_string(), + }) +} + +fn decode_heic_grid_tile_to_image( + tile: &isobmff::HeicGridTileItemData, +) -> Result { + let stream = assemble_heic_hevc_stream_from_components(&tile.hvcc, &tile.payload)?; + let metadata = decode_hevc_stream_metadata_from_sps(&stream)?; + let mut decoded = decode_hevc_stream_to_image(&stream)?; + if let Some(ycbcr_range) = ycbcr_range_override_from_primary_colr(&tile.colr) { + decoded.ycbcr_range = ycbcr_range; + } + if let Some(ycbcr_matrix) = ycbcr_matrix_override_from_primary_colr(&tile.colr) { + decoded.ycbcr_matrix = ycbcr_matrix; + } + validate_decoded_heic_image_against_metadata(&decoded, &metadata)?; + apply_heic_grid_tile_transforms(decoded, &tile.transforms) +} + +fn create_decoded_heic_grid_output( + descriptor: &isobmff::HeicGridDescriptor, + first_tile: &DecodedHeicImage, +) -> Result { let tile_width = first_tile.width; let tile_height = first_tile.height; if tile_width == 0 || tile_height == 0 { @@ -4077,1026 +4280,2897 @@ fn stitch_decoded_heic_grid_tiles( }); } - for row in 0..rows { - for column in 0..columns { - let tile_index = row - .checked_mul(columns) - .and_then(|idx| idx.checked_add(column)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!("grid tile index overflow at row={row}, column={column}"), - })?; - let tile = &tiles[tile_index]; - if tile.width != tile_width || tile.height != tile_height { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tiles have mixed dimensions: expected {tile_width}x{tile_height}, got {}x{} at index {tile_index}", - tile.width, tile.height - ), - }); - } - if tile.layout != output.layout { - return Err(DecodeHeicError::DecodedLayoutMismatch { - expected: output.layout, - actual: tile.layout, - }); - } - if tile.bit_depth_luma != output.bit_depth_luma - || tile.bit_depth_chroma != output.bit_depth_chroma - { - return Err(DecodeHeicError::DecodedBitDepthMismatch { - expected_luma: output.bit_depth_luma, - expected_chroma: output.bit_depth_chroma, - actual_luma: tile.bit_depth_luma, - actual_chroma: tile.bit_depth_chroma, - }); - } - if tile.ycbcr_range != output.ycbcr_range || tile.ycbcr_matrix != output.ycbcr_matrix { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tiles have inconsistent YCbCr metadata at index {tile_index}" - ), - }); - } + Ok(output) +} - validate_heic_plane_dimensions(&tile.y_plane, tile.width, tile.height, "grid tile Y")?; - let column_u64 = - u64::try_from(column).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("grid tile column index {column} cannot be represented"), - })?; - let row_u64 = u64::try_from(row).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("grid tile row index {row} cannot be represented"), - })?; - let x_origin = u32::try_from( - column_u64 - .checked_mul(u64::from(tile_width)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile x-origin overflow for column {column} with tile width {tile_width}" - ), - })?, - ) - .map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile x-origin overflow for column {column} with tile width {tile_width}" - ), - })?; - let y_origin = u32::try_from(row_u64.checked_mul(u64::from(tile_height)).ok_or_else( - || DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile y-origin overflow for row {row} with tile height {tile_height}" - ), - }, - )?) - .map_err(|_| DecodeHeicError::InvalidDecodedFrame { +fn decode_primary_heic_grid_to_rgba_image( + grid_data: &isobmff::HeicGridPrimaryItemData, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + icc_profile: Option>, +) -> Result { + let descriptor = &grid_data.descriptor; + validate_heic_grid_descriptor_and_tile_count(grid_data)?; + + let (first_tile, source_bit_depth) = decode_and_validate_heic_grid_first_tile(grid_data)?; + let reference = HeicGridTileReference::from_first_tile(&first_tile, &grid_data.colr); + // Direct orientation is safe for opaque grids. Alpha and clean aperture + // stay on the existing source-coordinate transform path. + let (direct_orientation_transform, output_width, output_height) = if auxiliary_alpha.is_none() { + heic_grid_rgba_orientation_and_output_dims(descriptor, transforms)? + } else { + (None, descriptor.output_width, descriptor.output_height) + }; + + if source_bit_depth <= 8 { + finish_heic_grid_rgba_decode( + grid_data, + transforms, + auxiliary_alpha, + icc_profile, + first_tile, + &reference, + source_bit_depth, + direct_orientation_transform, + output_width, + output_height, + convert_heic_to_rgba8_into, + apply_auxiliary_alpha_to_rgba8, + DecodedRgbaPixels::U8, + ) + } else { + finish_heic_grid_rgba_decode( + grid_data, + transforms, + auxiliary_alpha, + icc_profile, + first_tile, + &reference, + source_bit_depth, + direct_orientation_transform, + output_width, + output_height, + convert_heic_to_rgba16_into, + apply_auxiliary_alpha_to_rgba16, + DecodedRgbaPixels::U16, + ) + } +} + +fn validate_heic_grid_descriptor_and_tile_count( + grid_data: &isobmff::HeicGridPrimaryItemData, +) -> Result<(), DecodeHeicError> { + let descriptor = &grid_data.descriptor; + if descriptor.output_width == 0 || descriptor.output_height == 0 { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid descriptor output dimensions must be non-zero, got {}x{}", + descriptor.output_width, descriptor.output_height + ), + }); + } + + let rows = usize::from(descriptor.rows); + let columns = usize::from(descriptor.columns); + let expected_tiles = + rows.checked_mul(columns) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { detail: format!( - "grid tile y-origin overflow for row {row} with tile height {tile_height}" + "grid tile count overflow for {}x{} descriptor", + descriptor.columns, descriptor.rows ), })?; + if grid_data.tiles.len() != expected_tiles { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid descriptor {}x{} expects {expected_tiles} tiles, got {}", + descriptor.columns, + descriptor.rows, + grid_data.tiles.len() + ), + }); + } - paste_heic_plane_with_clip( - &tile.y_plane, - &mut output.y_plane, - x_origin, - y_origin, - "grid tile Y", - )?; + Ok(()) +} - if output.layout == HeicPixelLayout::Yuv400 { - continue; - } +/// Decode and validate the grid's first tile, returning it together with its +/// PNG-conversion source bit depth. +fn decode_and_validate_heic_grid_first_tile( + grid_data: &isobmff::HeicGridPrimaryItemData, +) -> Result<(DecodedHeicImage, u8), DecodeHeicError> { + let first_tile_data = + grid_data + .tiles + .first() + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile list cannot be empty".to_string(), + })?; + let first_tile = decode_heic_grid_tile_to_image(first_tile_data)?; + validate_decoded_heic_grid_first_tile(&grid_data.descriptor, &first_tile)?; + let source_bit_depth = heic_bit_depth_for_png_conversion(&first_tile)?; + Ok((first_tile, source_bit_depth)) +} - let (subsample_x, subsample_y) = heic_chroma_subsampling(output.layout); - if !x_origin.is_multiple_of(subsample_x) || !y_origin.is_multiple_of(subsample_y) { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile origin ({x_origin},{y_origin}) is not aligned for {:?} chroma subsampling", - output.layout - ), - }); - } +/// Resolve the direct orientation transform for a grid RGBA decode together +/// with the output dimensions it implies (descriptor dimensions when no +/// direct transform applies). +fn heic_grid_rgba_orientation_and_output_dims( + descriptor: &isobmff::HeicGridDescriptor, + transforms: &[isobmff::PrimaryItemTransformProperty], +) -> Result<(Option, u32, u32), DecodeError> { + let orientation_transform = rgba_orientation_transform_from_primary_transforms( + descriptor.output_width, + descriptor.output_height, + transforms, + )?; + let output_width = orientation_transform + .as_ref() + .map_or(descriptor.output_width, |transform| { + transform.destination_width + }); + let output_height = orientation_transform + .as_ref() + .map_or(descriptor.output_height, |transform| { + transform.destination_height + }); + Ok((orientation_transform, output_width, output_height)) +} - let (tile_u_plane, tile_v_plane, expected_chroma_width, expected_chroma_height) = - require_heic_chroma_planes(tile)?; - validate_heic_plane_dimensions( - tile_u_plane, - expected_chroma_width, - expected_chroma_height, - "grid tile U", - )?; - validate_heic_plane_dimensions( - tile_v_plane, - expected_chroma_width, - expected_chroma_height, - "grid tile V", - )?; +/// Shared 8/16-bit tail of the owned grid RGBA decode: paste every tile, +/// then either return the directly oriented canvas or run the RGBA-domain +/// alpha and transform path. +#[allow(clippy::too_many_arguments)] +fn finish_heic_grid_rgba_decode( + grid_data: &isobmff::HeicGridPrimaryItemData, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + icc_profile: Option>, + first_tile: DecodedHeicImage, + reference: &HeicGridTileReference, + source_bit_depth: u8, + direct_orientation_transform: Option, + output_width: u32, + output_height: u32, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, + apply_alpha: fn(&mut [T], u32, u32, &HeicAuxiliaryAlphaPlane) -> Result<(), DecodeHeicError>, + wrap_pixels: fn(Vec) -> DecodedRgbaPixels, +) -> Result { + let descriptor = &grid_data.descriptor; + let mut output = vec![T::default(); checked_rgba_sample_count(output_width, output_height)?]; + if !heic_grid_tiles_cover_descriptor(descriptor, reference) { + // Match libheif (and the plane-canvas path): pixels no tile covers + // are the converted zero-YUV color, not transparent black. The gap + // color is uniform, so filling the (possibly oriented) canvas before + // pasting is exact. + let gap_pixel = heic_grid_gap_rgba_pixel(reference, convert_tile)?; + for pixel in output.chunks_exact_mut(4) { + pixel.copy_from_slice(&gap_pixel); + } + } + paste_heic_grid_tiles_to_rgba( + grid_data, + first_tile, + &mut output, + reference, + direct_orientation_transform.as_ref(), + convert_tile, + )?; + if direct_orientation_transform.is_some() { + return Ok(DecodedRgbaImage { + width: output_width, + height: output_height, + source_bit_depth, + pixels: wrap_pixels(output), + icc_profile, + }); + } + if let Some(alpha) = auxiliary_alpha { + apply_alpha( + &mut output, + descriptor.output_width, + descriptor.output_height, + alpha, + )?; + } + let (width, height, transformed) = apply_primary_item_transforms_rgba( + descriptor.output_width, + descriptor.output_height, + output, + transforms, + )?; + Ok(DecodedRgbaImage { + width, + height, + source_bit_depth, + pixels: wrap_pixels(transformed), + icc_profile, + }) +} - let chroma_x_origin = x_origin / subsample_x; - let chroma_y_origin = y_origin / subsample_y; - paste_heic_plane_with_clip( - tile_u_plane, - output - .u_plane - .as_mut() - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: "missing output U plane for non-monochrome grid".to_string(), - })?, - chroma_x_origin, - chroma_y_origin, - "grid tile U", - )?; - paste_heic_plane_with_clip( - tile_v_plane, - output - .v_plane - .as_mut() - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: "missing output V plane for non-monochrome grid".to_string(), - })?, - chroma_x_origin, - chroma_y_origin, - "grid tile V", - )?; - } +fn validate_decoded_heic_grid_first_tile( + descriptor: &isobmff::HeicGridDescriptor, + first_tile: &DecodedHeicImage, +) -> Result<(), DecodeHeicError> { + let tile_width = first_tile.width; + let tile_height = first_tile.height; + if tile_width == 0 || tile_height == 0 { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!("grid tile geometry must be non-zero, got {tile_width}x{tile_height}"), + }); } - Ok(output) + if tile_width < descriptor.output_width / u32::from(descriptor.columns) + || tile_height < descriptor.output_height / u32::from(descriptor.rows) + { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: "grid tiles do not cover the whole output image".to_string(), + }); + } + + Ok(()) } -fn apply_heic_grid_tile_transforms( - mut decoded: DecodedHeicImage, - transforms: &[isobmff::PrimaryItemTransformProperty], -) -> Result { - // Provenance: mirrors libheif grid tile decode behavior where each tile - // item is decoded with its own item transforms before pasting into the - // grid canvas (libheif/libheif/image-items/grid.cc: - // ImageItem_Grid::decode_and_paste_tile_image, which calls tile image-item - // decode flow applying clap/irot/imir transforms). - for transform in transforms { - match transform { - isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture) => { - decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; - } - // A 0-degree rotation is a no-op; some muxers write the property - // redundantly. - isobmff::PrimaryItemTransformProperty::Rotation(rotation) - if rotation.rotation_ccw_degrees == 0 => {} - // Per-tile rotation/mirror would require plane-level transforms - // before pasting (libheif applies them); silently skipping them - // scrambles tile content, so reject loudly until implemented. - isobmff::PrimaryItemTransformProperty::Rotation(_) - | isobmff::PrimaryItemTransformProperty::Mirror(_) => { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: "grid tile irot/imir transforms are not supported".to_string(), - }); - } +/// Per-grid reference metadata every tile must match, plus the YCbCr +/// interpretation used for RGBA conversion (the grid-level colr override, +/// falling back to the first tile's bitstream metadata). +#[derive(Clone, Copy, Debug)] +struct HeicGridTileReference { + tile_width: u32, + tile_height: u32, + layout: HeicPixelLayout, + bit_depth_luma: u8, + bit_depth_chroma: u8, + ycbcr_range: YCbCrRange, + ycbcr_matrix: YCbCrMatrixCoefficients, + conversion_ycbcr_range: YCbCrRange, + conversion_ycbcr_matrix: YCbCrMatrixCoefficients, +} + +impl HeicGridTileReference { + fn from_first_tile( + first_tile: &DecodedHeicImage, + grid_colr: &isobmff::PrimaryItemColorProperties, + ) -> Self { + Self { + tile_width: first_tile.width, + tile_height: first_tile.height, + layout: first_tile.layout, + bit_depth_luma: first_tile.bit_depth_luma, + bit_depth_chroma: first_tile.bit_depth_chroma, + ycbcr_range: first_tile.ycbcr_range, + ycbcr_matrix: first_tile.ycbcr_matrix, + conversion_ycbcr_range: ycbcr_range_override_from_primary_colr(grid_colr) + .unwrap_or(first_tile.ycbcr_range), + conversion_ycbcr_matrix: ycbcr_matrix_override_from_primary_colr(grid_colr) + .unwrap_or(first_tile.ycbcr_matrix), + } + } + + /// Reference assembled from the plane-canvas grid output: tiles must + /// match the canvas metadata exactly, and no conversion-time colr + /// override applies at paste time. + fn from_output_canvas(output: &DecodedHeicImage, tile_width: u32, tile_height: u32) -> Self { + Self { + tile_width, + tile_height, + layout: output.layout, + bit_depth_luma: output.bit_depth_luma, + bit_depth_chroma: output.bit_depth_chroma, + ycbcr_range: output.ycbcr_range, + ycbcr_matrix: output.ycbcr_matrix, + conversion_ycbcr_range: output.ycbcr_range, + conversion_ycbcr_matrix: output.ycbcr_matrix, } } - Ok(decoded) } -fn crop_heic_by_clean_aperture( - decoded: DecodedHeicImage, - clean_aperture: isobmff::ImageCleanApertureProperty, -) -> Result { - if decoded.width == 0 || decoded.height == 0 { +/// True when the uniform tile lattice covers every descriptor pixel, so the +/// paste loops leave no gap pixels behind. +fn heic_grid_tiles_cover_descriptor( + descriptor: &isobmff::HeicGridDescriptor, + reference: &HeicGridTileReference, +) -> bool { + u64::from(descriptor.columns) * u64::from(reference.tile_width) + >= u64::from(descriptor.output_width) + && u64::from(descriptor.rows) * u64::from(reference.tile_height) + >= u64::from(descriptor.output_height) +} + +/// RGBA pixel for descriptor pixels no tile covers. libheif composes grids on +/// a zero-filled YUV canvas and converts the whole canvas afterwards, so gap +/// pixels come out as the converted all-zero YUV sample (opaque, green-tinted +/// for limited-range color) rather than transparent black. Convert a single +/// zero sample through the same tile conversion to match that exactly. +fn heic_grid_gap_rgba_pixel( + reference: &HeicGridTileReference, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, +) -> Result<[T; 4], DecodeHeicError> { + let zero_plane = HeicPlane { + width: 1, + height: 1, + samples: vec![0], + }; + let chroma_plane = if reference.layout == HeicPixelLayout::Yuv400 { + None + } else { + Some(zero_plane.clone()) + }; + let zero_image = DecodedHeicImage { + width: 1, + height: 1, + bit_depth_luma: reference.bit_depth_luma, + bit_depth_chroma: reference.bit_depth_chroma, + layout: reference.layout, + ycbcr_range: reference.conversion_ycbcr_range, + ycbcr_matrix: reference.conversion_ycbcr_matrix, + y_plane: zero_plane.clone(), + u_plane: chroma_plane.clone(), + v_plane: chroma_plane, + }; + let mut pixel = Vec::new(); + convert_tile(&zero_image, &mut pixel)?; + <[T; 4]>::try_from(pixel.as_slice()).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid gap pixel conversion produced {} samples, expected 4", + pixel.len() + ), + }) +} + +fn validate_decoded_heic_grid_tile_reference( + tile: &DecodedHeicImage, + reference: &HeicGridTileReference, + tile_index: usize, +) -> Result<(), DecodeHeicError> { + if tile.width != reference.tile_width || tile.height != reference.tile_height { return Err(DecodeHeicError::InvalidDecodedFrame { detail: format!( - "grid tile clean-aperture input geometry must be non-zero, got {}x{}", - decoded.width, decoded.height + "grid tiles have mixed dimensions: expected {}x{}, got {}x{} at index {tile_index}", + reference.tile_width, reference.tile_height, tile.width, tile.height ), }); } - - let mut left = clap_left_rounded(clean_aperture, decoded.width); - let mut right = clap_right_rounded(clean_aperture, decoded.width); - let mut top = clap_top_rounded(clean_aperture, decoded.height); - let mut bottom = clap_bottom_rounded(clean_aperture, decoded.height); - - left = left.max(0); - top = top.max(0); - let max_x = i128::from(decoded.width) - 1; - let max_y = i128::from(decoded.height) - 1; - right = right.min(max_x); - bottom = bottom.min(max_y); - - if left > right || top > bottom { + if tile.layout != reference.layout { + return Err(DecodeHeicError::DecodedLayoutMismatch { + expected: reference.layout, + actual: tile.layout, + }); + } + if tile.bit_depth_luma != reference.bit_depth_luma + || tile.bit_depth_chroma != reference.bit_depth_chroma + { + return Err(DecodeHeicError::DecodedBitDepthMismatch { + expected_luma: reference.bit_depth_luma, + expected_chroma: reference.bit_depth_chroma, + actual_luma: tile.bit_depth_luma, + actual_chroma: tile.bit_depth_chroma, + }); + } + if tile.ycbcr_range != reference.ycbcr_range || tile.ycbcr_matrix != reference.ycbcr_matrix { return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile clean-aperture crop is empty after clamp (left={left}, right={right}, top={top}, bottom={bottom}, tile={}x{})", - decoded.width, decoded.height - ), + detail: format!("grid tiles have inconsistent YCbCr metadata at index {tile_index}"), }); } - let crop_width = - u32::try_from(right - left + 1).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile clean-aperture width is out of range: {}", - right - left + 1 - ), - })?; - let crop_height = - u32::try_from(bottom - top + 1).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile clean-aperture height is out of range: {}", - bottom - top + 1 - ), - })?; - let crop_left = u32::try_from(left).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("grid tile clean-aperture left bound is out of range: {left}"), - })?; - let crop_top = u32::try_from(top).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("grid tile clean-aperture top bound is out of range: {top}"), - })?; - - validate_heic_plane_dimensions( - &decoded.y_plane, - decoded.width, - decoded.height, - "grid tile Y", - )?; - let y_stride = usize::try_from(decoded.y_plane.width).map_err(|_| { - DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile Y width {} cannot be represented for clap crop", - decoded.y_plane.width - ), - } - })?; - let y_plane = extract_cropped_heic_plane( - &decoded.y_plane.samples, - y_stride, - crop_left, - crop_top, - crop_width, - crop_height, - "grid tile Y", - )?; + Ok(()) +} - if decoded.layout == HeicPixelLayout::Yuv400 { - return Ok(DecodedHeicImage { - width: crop_width, - height: crop_height, - y_plane, - ..decoded - }); +/// Drive the shared per-tile grid loop: decode each tile (reusing the +/// pre-decoded first tile), validate it against the reference, apply the +/// grid-level colr conversion overrides, convert it to RGBA, and compute the +/// aligned tile origin — then hand it to `paste`. Shared by the owned and +/// caller-buffer grid paths so their per-tile semantics cannot drift. +fn for_each_heic_grid_tile_rgba( + grid_data: &isobmff::HeicGridPrimaryItemData, + first_tile: DecodedHeicImage, + reference: &HeicGridTileReference, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, + mut paste: impl FnMut(&DecodedHeicImage, &[T], u32, u32) -> Result<(), DecodeError>, +) -> Result<(), DecodeError> { + let columns = usize::from(grid_data.descriptor.columns); + let mut first_tile = Some(first_tile); + let mut tile_pixels = Vec::new(); + for tile_index in 0..grid_data.tiles.len() { + let mut tile = if tile_index == 0 { + first_tile + .take() + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "first grid tile was already consumed".to_string(), + })? + } else { + decode_heic_grid_tile_to_image(&grid_data.tiles[tile_index])? + }; + validate_decoded_heic_grid_tile_reference(&tile, reference, tile_index)?; + tile.ycbcr_range = reference.conversion_ycbcr_range; + tile.ycbcr_matrix = reference.conversion_ycbcr_matrix; + convert_tile(&tile, &mut tile_pixels)?; + let row = tile_index / columns; + let column = tile_index % columns; + let (x_origin, y_origin) = + heic_grid_tile_origin(reference.tile_width, reference.tile_height, row, column)?; + validate_heic_grid_tile_origin_alignment(reference.layout, x_origin, y_origin)?; + paste(&tile, &tile_pixels, x_origin, y_origin)?; } - let (u_plane, v_plane, expected_chroma_width, expected_chroma_height) = - require_heic_chroma_planes(&decoded)?; - validate_heic_plane_dimensions( - u_plane, - expected_chroma_width, - expected_chroma_height, - "grid tile U", - )?; - validate_heic_plane_dimensions( - v_plane, - expected_chroma_width, - expected_chroma_height, - "grid tile V", - )?; + Ok(()) +} - let (subsample_x, subsample_y) = heic_chroma_subsampling(decoded.layout); - let chroma_crop_left = crop_left / subsample_x; - let chroma_crop_top = crop_top / subsample_y; - let chroma_crop_width = crop_width.div_ceil(subsample_x); - let chroma_crop_height = crop_height.div_ceil(subsample_y); - let u_stride = - usize::try_from(u_plane.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { +fn paste_heic_grid_tiles_to_rgba( + grid_data: &isobmff::HeicGridPrimaryItemData, + first_tile: DecodedHeicImage, + output: &mut [T], + reference: &HeicGridTileReference, + orientation_transform: Option<&RgbaOrientationTransform>, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, +) -> Result<(), DecodeError> { + let descriptor = &grid_data.descriptor; + for_each_heic_grid_tile_rgba( + grid_data, + first_tile, + reference, + convert_tile, + |tile, tile_pixels, x_origin, y_origin| { + if let Some(orientation_transform) = orientation_transform { + paste_transformed_rgba_tile_with_clip( + tile_pixels, + tile.width, + tile.height, + output, + orientation_transform, + x_origin, + y_origin, + "grid tile RGBA", + ) + } else { + paste_rgba_tile_with_clip( + tile_pixels, + tile.width, + tile.height, + output, + descriptor.output_width, + descriptor.output_height, + x_origin, + y_origin, + "grid tile RGBA", + ) + .map_err(DecodeError::from) + } + }, + ) +} + +fn heic_grid_tile_origin( + tile_width: u32, + tile_height: u32, + row: usize, + column: usize, +) -> Result<(u32, u32), DecodeHeicError> { + let column_u64 = u64::try_from(column).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("grid tile column index {column} cannot be represented"), + })?; + let row_u64 = u64::try_from(row).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("grid tile row index {row} cannot be represented"), + })?; + let x_origin = u32::try_from(column_u64.checked_mul(u64::from(tile_width)).ok_or_else( + || DecodeHeicError::InvalidDecodedFrame { detail: format!( - "grid tile U width {} cannot be represented for clap crop", - u_plane.width + "grid tile x-origin overflow for column {column} with tile width {tile_width}" ), - })?; - let v_stride = - usize::try_from(v_plane.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + }, + )?) + .map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile x-origin overflow for column {column} with tile width {tile_width}" + ), + })?; + let y_origin = u32::try_from(row_u64.checked_mul(u64::from(tile_height)).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { detail: format!( - "grid tile V width {} cannot be represented for clap crop", - v_plane.width + "grid tile y-origin overflow for row {row} with tile height {tile_height}" ), - })?; - let cropped_u = extract_cropped_heic_plane( - &u_plane.samples, - u_stride, - chroma_crop_left, - chroma_crop_top, - chroma_crop_width, - chroma_crop_height, - "grid tile U", - )?; - let cropped_v = extract_cropped_heic_plane( - &v_plane.samples, - v_stride, - chroma_crop_left, - chroma_crop_top, - chroma_crop_width, - chroma_crop_height, - "grid tile V", - )?; + } + })?) + .map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("grid tile y-origin overflow for row {row} with tile height {tile_height}"), + })?; - Ok(DecodedHeicImage { - width: crop_width, - height: crop_height, - y_plane, - u_plane: Some(cropped_u), - v_plane: Some(cropped_v), - ..decoded - }) + Ok((x_origin, y_origin)) } -fn paste_heic_plane_with_clip( - source: &HeicPlane, - destination: &mut HeicPlane, +/// Reject grid tile origins that are not aligned to the layout's chroma +/// subsampling. Pasting such tiles would sample chroma with a different +/// phase than libheif at tile seams, so fail loudly instead — matching the +/// plane-canvas grid path. +fn validate_heic_grid_tile_origin_alignment( + layout: HeicPixelLayout, x_origin: u32, y_origin: u32, - plane: &'static str, ) -> Result<(), DecodeHeicError> { - if x_origin >= destination.width || y_origin >= destination.height { - return Ok(()); + let (subsample_x, subsample_y) = heic_chroma_subsampling(layout); + if !x_origin.is_multiple_of(subsample_x) || !y_origin.is_multiple_of(subsample_y) { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile origin ({x_origin},{y_origin}) is not aligned for {layout:?} chroma subsampling" + ), + }); } - let source_width = - usize::try_from(source.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} plane width {} cannot be represented", source.width), - })?; - let source_height = - usize::try_from(source.height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} plane height {} cannot be represented", - source.height - ), - })?; - let destination_width = - usize::try_from(destination.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination width {} cannot be represented", - destination.width - ), - })?; - let destination_height = - usize::try_from(destination.height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination height {} cannot be represented", - destination.height - ), - })?; - let x_origin_usize = - usize::try_from(x_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} x-origin {x_origin} cannot be represented"), - })?; - let y_origin_usize = - usize::try_from(y_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} y-origin {y_origin} cannot be represented"), - })?; + Ok(()) +} - let source_sample_count = source_width.checked_mul(source_height).ok_or_else(|| { - DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} source sample count overflow for {}x{}", - source.width, source.height - ), +#[derive(Clone, Debug)] +struct RgbaOrientationTransform { + source_width: u32, + source_height: u32, + source_width_usize: usize, + source_height_usize: usize, + destination_width: u32, + destination_height: u32, + destination_width_usize: usize, + destination_height_usize: usize, + destination_x_from_source_x: i64, + destination_x_from_source_y: i64, + destination_x_offset: i64, + destination_y_from_source_x: i64, + destination_y_from_source_y: i64, + destination_y_offset: i64, +} + +/// A resolved primary-item transform sequence that can map pixels in either +/// direction without materializing intermediate RGBA images. +/// +/// Keeping every step (rather than collapsing the sequence into one affine +/// transform) makes clean-aperture clipping explicit and preserves the exact +/// transform order used by `apply_primary_item_transforms_rgba`. +#[derive(Clone, Debug)] +#[cfg(feature = "image-integration")] +struct RgbaTransformPlan { + source_width: u32, + source_height: u32, + destination_width: u32, + destination_height: u32, + steps: Vec, +} + +#[derive(Clone, Copy, Debug)] +#[cfg(feature = "image-integration")] +enum ResolvedRgbaTransformStep { + CleanAperture { + left: u32, + top: u32, + right: u32, + bottom: u32, + }, + Rotation { + rotation_ccw_degrees: u16, + input_width: u32, + input_height: u32, + }, + Mirror { + direction: isobmff::ImageMirrorDirection, + width: u32, + height: u32, + }, +} + +#[cfg(feature = "image-integration")] +impl RgbaTransformPlan { + fn from_primary_transforms( + width: u32, + height: u32, + transforms: &[isobmff::PrimaryItemTransformProperty], + ) -> Result { + if width == 0 || height == 0 { + return Err(DecodeError::TransformGuard( + TransformGuardError::EmptyImageGeometry { width, height }, + )); } - })?; - if source.samples.len() != source_sample_count { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} source plane has {} samples, expected {source_sample_count}", - source.samples.len() - ), - }); + + let mut current_width = width; + let mut current_height = height; + let mut steps = Vec::with_capacity(transforms.len()); + for transform in transforms { + match *transform { + isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture) => { + let crop = + clean_aperture_crop_bounds(current_width, current_height, clean_aperture)?; + steps.push(ResolvedRgbaTransformStep::CleanAperture { + left: u32::try_from(crop.left).map_err(|_| { + DecodeError::TransformGuard( + TransformGuardError::CleanApertureBoundOutOfRange { + bound: "left", + value: crop.left, + }, + ) + })?, + top: u32::try_from(crop.top).map_err(|_| { + DecodeError::TransformGuard( + TransformGuardError::CleanApertureBoundOutOfRange { + bound: "top", + value: crop.top, + }, + ) + })?, + right: u32::try_from(crop.right).map_err(|_| { + DecodeError::TransformGuard( + TransformGuardError::CleanApertureBoundOutOfRange { + bound: "right", + value: crop.right, + }, + ) + })?, + bottom: u32::try_from(crop.bottom).map_err(|_| { + DecodeError::TransformGuard( + TransformGuardError::CleanApertureBoundOutOfRange { + bound: "bottom", + value: crop.bottom, + }, + ) + })?, + }); + current_width = crop.width; + current_height = crop.height; + } + isobmff::PrimaryItemTransformProperty::Rotation(rotation) => { + let rotation_ccw_degrees = rotation.rotation_ccw_degrees % 360; + match rotation_ccw_degrees { + 0 => continue, + 90 | 180 | 270 => {} + _ => { + return Err(DecodeError::TransformGuard( + TransformGuardError::UnsupportedRotation { + rotation_ccw_degrees: rotation.rotation_ccw_degrees, + }, + )); + } + } + steps.push(ResolvedRgbaTransformStep::Rotation { + rotation_ccw_degrees, + input_width: current_width, + input_height: current_height, + }); + if matches!(rotation_ccw_degrees, 90 | 270) { + std::mem::swap(&mut current_width, &mut current_height); + } + } + isobmff::PrimaryItemTransformProperty::Mirror(mirror) => { + steps.push(ResolvedRgbaTransformStep::Mirror { + direction: mirror.direction, + width: current_width, + height: current_height, + }); + } + } + } + + Ok(Self { + source_width: width, + source_height: height, + destination_width: current_width, + destination_height: current_height, + steps, + }) } - let destination_sample_count = destination_width - .checked_mul(destination_height) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination sample count overflow for {}x{}", - destination.width, destination.height - ), + /// True when the plan maps every pixel to itself: no steps were + /// recorded, so destination dimensions equal source dimensions by + /// construction. + fn is_identity(&self) -> bool { + self.steps.is_empty() + } + + fn map_destination_pixel( + &self, + destination_x: usize, + destination_y: usize, + ) -> Result<(usize, usize), DecodeError> { + // Identity fast path: no irot/imir/clap is the common case, and the + // hook decode paths call this once per pixel. Callers iterate within + // the plan's dimensions, so the coordinate conversions and range + // checks below only matter when a step actually remaps coordinates. + if self.is_identity() { + return Ok((destination_x, destination_y)); + } + + let mut x = u32::try_from(destination_x).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct transform destination", + x: destination_x, + y: destination_y, + width: self.destination_width, + height: self.destination_height, + }) })?; - if destination.samples.len() != destination_sample_count { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination plane has {} samples, expected {destination_sample_count}", - destination.samples.len() - ), - }); - } + let mut y = u32::try_from(destination_y).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct transform destination", + x: destination_x, + y: destination_y, + width: self.destination_width, + height: self.destination_height, + }) + })?; + if x >= self.destination_width || y >= self.destination_height { + return Err(DecodeError::TransformGuard( + TransformGuardError::PixelIndexOverflow { + stage: "direct transform destination", + x: destination_x, + y: destination_y, + width: self.destination_width, + height: self.destination_height, + }, + )); + } - let remaining_width = destination_width - x_origin_usize; - let copy_width = source_width.min(remaining_width); - if copy_width == 0 { - return Ok(()); - } - let max_rows = source_height.min(destination_height - y_origin_usize); - for row in 0..max_rows { - let source_start = - row.checked_mul(source_width) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} source row index overflow at row {row}"), - })?; - let source_end = source_start.checked_add(copy_width).ok_or_else(|| { - DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} source row end overflow at row {row}"), + for step in self.steps.iter().rev() { + match *step { + ResolvedRgbaTransformStep::CleanAperture { left, top, .. } => { + x = x.checked_add(left).ok_or({ + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct clean-aperture inverse", + x: destination_x, + y: destination_y, + width: self.source_width, + height: self.source_height, + }) + })?; + y = y.checked_add(top).ok_or({ + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct clean-aperture inverse", + x: destination_x, + y: destination_y, + width: self.source_width, + height: self.source_height, + }) + })?; + } + ResolvedRgbaTransformStep::Rotation { + rotation_ccw_degrees, + input_width, + input_height, + } => { + (x, y) = match rotation_ccw_degrees { + 90 => (input_width - 1 - y, x), + 180 => (input_width - 1 - x, input_height - 1 - y), + 270 => (y, input_height - 1 - x), + _ => unreachable!("rotation angle was validated while building the plan"), + }; + } + ResolvedRgbaTransformStep::Mirror { + direction, + width, + height, + } => match direction { + isobmff::ImageMirrorDirection::Horizontal => x = width - 1 - x, + isobmff::ImageMirrorDirection::Vertical => y = height - 1 - y, + }, } + } + + Ok((x as usize, y as usize)) + } + + fn map_source_pixel( + &self, + source_x: usize, + source_y: usize, + ) -> Result, DecodeError> { + // Identity fast path, mirroring `map_destination_pixel`: the grid + // paste and alpha-seeding loops call this once per pixel and stay + // within the plan's source dimensions. + if self.is_identity() { + return Ok(Some((source_x, source_y))); + } + + let mut x = u32::try_from(source_x).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct transform source", + x: source_x, + y: source_y, + width: self.source_width, + height: self.source_height, + }) + })?; + let mut y = u32::try_from(source_y).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct transform source", + x: source_x, + y: source_y, + width: self.source_width, + height: self.source_height, + }) })?; + if x >= self.source_width || y >= self.source_height { + return Err(DecodeError::TransformGuard( + TransformGuardError::PixelIndexOverflow { + stage: "direct transform source", + x: source_x, + y: source_y, + width: self.source_width, + height: self.source_height, + }, + )); + } - let destination_row = y_origin_usize.checked_add(row).ok_or_else(|| { - DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} destination row overflow at row {row}"), + for step in &self.steps { + match *step { + ResolvedRgbaTransformStep::CleanAperture { + left, + top, + right, + bottom, + } => { + if x < left || x > right || y < top || y > bottom { + return Ok(None); + } + x -= left; + y -= top; + } + ResolvedRgbaTransformStep::Rotation { + rotation_ccw_degrees, + input_width, + input_height, + } => { + (x, y) = match rotation_ccw_degrees { + 90 => (y, input_width - 1 - x), + 180 => (input_width - 1 - x, input_height - 1 - y), + 270 => (input_height - 1 - y, x), + _ => unreachable!("rotation angle was validated while building the plan"), + }; + } + ResolvedRgbaTransformStep::Mirror { + direction, + width, + height, + } => match direction { + isobmff::ImageMirrorDirection::Horizontal => x = width - 1 - x, + isobmff::ImageMirrorDirection::Vertical => y = height - 1 - y, + }, } + } + + Ok(Some((x as usize, y as usize))) + } +} + +impl RgbaOrientationTransform { + fn map_source_pixel(&self, x: usize, y: usize) -> Result<(usize, usize), DecodeError> { + let x_i64 = i64::try_from(x).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct grid source", + x, + y, + width: self.source_width, + height: self.source_height, + }) })?; - let destination_start = destination_row - .checked_mul(destination_width) - .and_then(|offset| offset.checked_add(x_origin_usize)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} destination row start overflow at row {row}"), - })?; - let destination_end = destination_start.checked_add(copy_width).ok_or_else(|| { - DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} destination row end overflow at row {row}"), - } + let y_i64 = i64::try_from(y).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct grid source", + x, + y, + width: self.source_width, + height: self.source_height, + }) })?; + let destination_x = self.destination_x_from_source_x * x_i64 + + self.destination_x_from_source_y * y_i64 + + self.destination_x_offset; + let destination_y = self.destination_y_from_source_x * x_i64 + + self.destination_y_from_source_y * y_i64 + + self.destination_y_offset; + + Ok(( + mapped_orientation_coordinate_to_usize( + destination_x, + self.destination_width_usize, + "direct grid destination x", + x, + y, + self.destination_width, + self.destination_height, + )?, + mapped_orientation_coordinate_to_usize( + destination_y, + self.destination_height_usize, + "direct grid destination y", + x, + y, + self.destination_width, + self.destination_height, + )?, + )) + } +} - destination.samples[destination_start..destination_end] - .copy_from_slice(&source.samples[source_start..source_end]); +fn mapped_orientation_coordinate_to_usize( + value: i64, + limit: usize, + stage: &'static str, + x: usize, + y: usize, + width: u32, + height: u32, +) -> Result { + if value < 0 { + return Err(DecodeError::TransformGuard( + TransformGuardError::PixelIndexOverflow { + stage, + x, + y, + width, + height, + }, + )); + } + let coordinate = usize::try_from(value).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage, + x, + y, + width, + height, + }) + })?; + if coordinate >= limit { + return Err(DecodeError::TransformGuard( + TransformGuardError::PixelIndexOverflow { + stage, + x, + y, + width, + height, + }, + )); } + Ok(coordinate) +} - Ok(()) +#[derive(Clone, Copy, Debug)] +struct RgbaOrientationAxis { + from_source_x: i64, + from_source_y: i64, + offset: i64, } -fn validate_decoded_heic_geometry_against_ispe( - metadata: &DecodedHeicImageMetadata, - expected_width: u32, - expected_height: u32, -) -> Result<(), DecodeHeicError> { - if metadata.width != expected_width || metadata.height != expected_height { - return Err(DecodeHeicError::DecodedGeometryMismatch { - expected_width, - expected_height, - actual_width: metadata.width, - actual_height: metadata.height, - }); +fn flipped_orientation_axis(axis: RgbaOrientationAxis, dimension: u32) -> RgbaOrientationAxis { + RgbaOrientationAxis { + from_source_x: -axis.from_source_x, + from_source_y: -axis.from_source_y, + offset: i64::from(dimension) - 1 - axis.offset, } +} - Ok(()) +fn rotate_orientation_axes_90_ccw( + x_axis: RgbaOrientationAxis, + y_axis: RgbaOrientationAxis, + width: u32, +) -> (RgbaOrientationAxis, RgbaOrientationAxis) { + (y_axis, flipped_orientation_axis(x_axis, width)) } -fn validate_decoded_heic_image_against_metadata( - decoded: &DecodedHeicImage, - metadata: &DecodedHeicImageMetadata, -) -> Result<(), DecodeHeicError> { - // Provenance: mirrors libheif's decoder metadata expectations where HEVC - // coded-image chroma/bit-depth metadata is exposed by - // Decoder_HEVC::{get_coded_image_colorspace,get_luma_bits_per_pixel,get_chroma_bits_per_pixel} - // and backend output planes are materialized in - // plugins/decoder_libde265.cc:convert_libde265_image_to_heif_image. - if decoded.width != metadata.width || decoded.height != metadata.height { - return Err(DecodeHeicError::DecodedGeometryMismatch { - expected_width: metadata.width, - expected_height: metadata.height, - actual_width: decoded.width, - actual_height: decoded.height, - }); - } +fn rotate_orientation_axes_270_ccw( + x_axis: RgbaOrientationAxis, + y_axis: RgbaOrientationAxis, + height: u32, +) -> (RgbaOrientationAxis, RgbaOrientationAxis) { + (flipped_orientation_axis(y_axis, height), x_axis) +} - if decoded.layout != metadata.layout { - return Err(DecodeHeicError::DecodedLayoutMismatch { - expected: metadata.layout, - actual: decoded.layout, - }); - } +fn rotate_orientation_axes_180( + x_axis: RgbaOrientationAxis, + y_axis: RgbaOrientationAxis, + width: u32, + height: u32, +) -> (RgbaOrientationAxis, RgbaOrientationAxis) { + ( + flipped_orientation_axis(x_axis, width), + flipped_orientation_axis(y_axis, height), + ) +} - if decoded.bit_depth_luma != metadata.bit_depth_luma - || decoded.bit_depth_chroma != metadata.bit_depth_chroma - { - return Err(DecodeHeicError::DecodedBitDepthMismatch { - expected_luma: metadata.bit_depth_luma, - expected_chroma: metadata.bit_depth_chroma, - actual_luma: decoded.bit_depth_luma, - actual_chroma: decoded.bit_depth_chroma, - }); +fn transform_dimension_to_usize( + stage: &'static str, + dimension: &'static str, + value: u32, +) -> Result { + usize::try_from(value).map_err(|_| { + DecodeError::TransformGuard(TransformGuardError::DimensionTooLargeForPlatform { + stage, + dimension, + value: u64::from(value), + }) + }) +} + +fn rgba_orientation_transform_from_primary_transforms( + width: u32, + height: u32, + transforms: &[isobmff::PrimaryItemTransformProperty], +) -> Result, DecodeError> { + if width == 0 || height == 0 { + return Err(DecodeError::TransformGuard( + TransformGuardError::EmptyImageGeometry { width, height }, + )); } - Ok(()) -} + let source_width_usize = + transform_dimension_to_usize("direct grid orientation", "source width", width)?; + let source_height_usize = + transform_dimension_to_usize("direct grid orientation", "source height", height)?; + let mut current_width = width; + let mut current_height = height; + let mut effective = false; + let mut x_axis = RgbaOrientationAxis { + from_source_x: 1, + from_source_y: 0, + offset: 0, + }; + let mut y_axis = RgbaOrientationAxis { + from_source_x: 0, + from_source_y: 1, + offset: 0, + }; -fn decode_hevc_stream_to_image(stream: &[u8]) -> Result { - let parsed_nals = parse_length_prefixed_hevc_nal_units(stream)?; - if !parsed_nals - .iter() - .any(|nal| nal.class() == HevcNalClass::Vcl) - { - return Err(DecodeHeicError::MissingVclNalUnit); + for transform in transforms { + match *transform { + isobmff::PrimaryItemTransformProperty::CleanAperture(_) => return Ok(None), + isobmff::PrimaryItemTransformProperty::Rotation(rotation) => { + let rotation_ccw_degrees = rotation.rotation_ccw_degrees % 360; + if rotation_ccw_degrees == 0 { + continue; + } + match rotation_ccw_degrees { + 90 | 180 | 270 => {} + _ => { + return Err(DecodeError::TransformGuard( + TransformGuardError::UnsupportedRotation { + rotation_ccw_degrees: rotation.rotation_ccw_degrees, + }, + )); + } + } + effective = true; + (x_axis, y_axis) = match rotation_ccw_degrees { + 90 => { + let axes = rotate_orientation_axes_90_ccw(x_axis, y_axis, current_width); + std::mem::swap(&mut current_width, &mut current_height); + axes + } + 180 => { + rotate_orientation_axes_180(x_axis, y_axis, current_width, current_height) + } + 270 => { + let axes = rotate_orientation_axes_270_ccw(x_axis, y_axis, current_height); + std::mem::swap(&mut current_width, &mut current_height); + axes + } + _ => unreachable!("rotation angle was validated above"), + }; + } + isobmff::PrimaryItemTransformProperty::Mirror(mirror) => { + effective = true; + match mirror.direction { + isobmff::ImageMirrorDirection::Horizontal => { + x_axis = flipped_orientation_axis(x_axis, current_width); + } + isobmff::ImageMirrorDirection::Vertical => { + y_axis = flipped_orientation_axis(y_axis, current_height); + } + } + } + } } - let mut backend_stream = Vec::with_capacity(stream.len()); - for nal_unit in parsed_nals { - append_nal_with_u32_length_prefix(nal_unit.bytes, &mut backend_stream)?; + if !effective { + return Ok(None); } - let decoded = heic_decoder::hevc::decode(&backend_stream).map_err(|err| { - DecodeHeicError::BackendDecodeFailed { - detail: err.to_string(), - } - })?; - heic_frame_to_internal_image(&decoded) + Ok(Some(RgbaOrientationTransform { + source_width: width, + source_height: height, + source_width_usize, + source_height_usize, + destination_width: current_width, + destination_height: current_height, + destination_width_usize: transform_dimension_to_usize( + "direct grid orientation", + "destination width", + current_width, + )?, + destination_height_usize: transform_dimension_to_usize( + "direct grid orientation", + "destination height", + current_height, + )?, + destination_x_from_source_x: x_axis.from_source_x, + destination_x_from_source_y: x_axis.from_source_y, + destination_x_offset: x_axis.offset, + destination_y_from_source_x: y_axis.from_source_x, + destination_y_from_source_y: y_axis.from_source_y, + destination_y_offset: y_axis.offset, + })) } -fn heic_frame_to_internal_image(frame: &HeicFrame) -> Result { - let width = frame.cropped_width(); - let height = frame.cropped_height(); - if width == 0 || height == 0 { +/// Validate that an RGBA paste buffer holds exactly `width x height` pixels +/// (4 samples per pixel). `role` is "source" or "destination". +#[allow(clippy::too_many_arguments)] +fn validate_rgba_paste_buffer_len( + actual_len: usize, + width_usize: usize, + height_usize: usize, + width: u32, + height: u32, + plane: &'static str, + role: &'static str, +) -> Result<(), DecodeHeicError> { + let expected_samples = width_usize + .checked_mul(height_usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} {role} sample count overflow for {width}x{height}"), + })?; + if actual_len != expected_samples { return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!("cropped geometry must be non-zero, got {width}x{height}"), + detail: format!("{plane} {role} has {actual_len} samples, expected {expected_samples}"), }); } - - let layout = heic_layout_from_sps_chroma_array_type(frame.chroma_format)?; - let y_plane = extract_cropped_heic_plane( - &frame.y_plane, - frame.y_stride(), - frame.crop_left, - frame.crop_top, - width, - height, - "Y", - )?; - - let (u_plane, v_plane) = match layout { - HeicPixelLayout::Yuv400 => (None, None), - HeicPixelLayout::Yuv420 | HeicPixelLayout::Yuv422 | HeicPixelLayout::Yuv444 => { - let (subsample_x, subsample_y) = heic_chroma_subsampling(layout); - if !frame.crop_left.is_multiple_of(subsample_x) - || !frame.crop_right.is_multiple_of(subsample_x) - || !frame.crop_top.is_multiple_of(subsample_y) - || !frame.crop_bottom.is_multiple_of(subsample_y) - { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "chroma crop alignment mismatch for layout {layout:?}: crop=({}, {}, {}, {})", - frame.crop_left, frame.crop_right, frame.crop_top, frame.crop_bottom - ), - }); - } - - let chroma_width = width.div_ceil(subsample_x); - let chroma_height = height.div_ceil(subsample_y); - let chroma_crop_left = frame.crop_left / subsample_x; - let chroma_crop_top = frame.crop_top / subsample_y; - - let cb_plane = extract_cropped_heic_plane( - &frame.cb_plane, - frame.c_stride(), - chroma_crop_left, - chroma_crop_top, - chroma_width, - chroma_height, - "U", - )?; - let cr_plane = extract_cropped_heic_plane( - &frame.cr_plane, - frame.c_stride(), - chroma_crop_left, - chroma_crop_top, - chroma_width, - chroma_height, - "V", - )?; - (Some(cb_plane), Some(cr_plane)) - } - }; - - Ok(DecodedHeicImage { - width, - height, - bit_depth_luma: frame.bit_depth, - bit_depth_chroma: frame.bit_depth, - layout, - // Provenance: mirror libheif decoder-plugin color handoff where - // bitstream-derived range/matrix metadata is attached when available - // (libheif/libheif/plugins/decoder_libde265.cc: - // de265_get_image_{full_range_flag,matrix_coefficients}). - ycbcr_range: if frame.full_range { - YCbCrRange::Full - } else { - YCbCrRange::Limited - }, - ycbcr_matrix: YCbCrMatrixCoefficients { - matrix_coefficients: u16::from(frame.matrix_coeffs), - colour_primaries: u16::from(frame.colour_primaries), - }, - y_plane, - u_plane, - v_plane, - }) + Ok(()) } -fn heic_chroma_subsampling(layout: HeicPixelLayout) -> (u32, u32) { - match layout { - HeicPixelLayout::Yuv400 | HeicPixelLayout::Yuv444 => (1, 1), - HeicPixelLayout::Yuv420 => (2, 2), - HeicPixelLayout::Yuv422 => (2, 1), +#[allow(clippy::too_many_arguments)] +fn paste_rgba_tile_with_clip( + source: &[T], + source_width: u32, + source_height: u32, + destination: &mut [T], + destination_width: u32, + destination_height: u32, + x_origin: u32, + y_origin: u32, + plane: &'static str, +) -> Result<(), DecodeHeicError> { + if x_origin >= destination_width || y_origin >= destination_height { + return Ok(()); } -} -fn extract_cropped_heic_plane( - source: &[u16], - stride: usize, - crop_left: u32, - crop_top: u32, - width: u32, - height: u32, - plane: &'static str, -) -> Result { - let width_usize = usize::try_from(width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} plane width does not fit in usize ({width})"), - })?; - let height_usize = - usize::try_from(height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} plane height does not fit in usize ({height})"), + let source_width_usize = + usize::try_from(source_width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} width {source_width} cannot be represented"), })?; - let crop_left_usize = - usize::try_from(crop_left).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} plane crop_left does not fit in usize ({crop_left})"), + let source_height_usize = + usize::try_from(source_height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} height {source_height} cannot be represented"), })?; - let crop_top_usize = - usize::try_from(crop_top).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} plane crop_top does not fit in usize ({crop_top})"), + let destination_width_usize = + usize::try_from(destination_width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination width {destination_width} cannot be represented"), })?; - - let row_end = crop_left_usize - .checked_add(width_usize) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + let destination_height_usize = + usize::try_from(destination_height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { detail: format!( - "{plane} plane row bound overflows: crop_left={crop_left_usize}, width={width_usize}" + "{plane} destination height {destination_height} cannot be represented" ), })?; - if row_end > stride { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} plane stride {stride} smaller than crop+width bound {row_end}" - ), - }); - } + let x_origin_usize = + usize::try_from(x_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} x-origin {x_origin} cannot be represented"), + })?; + let y_origin_usize = + usize::try_from(y_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} y-origin {y_origin} cannot be represented"), + })?; - let expected_samples = width_usize.checked_mul(height_usize).ok_or_else(|| { - DecodeHeicError::InvalidDecodedFrame { - detail: format!("{plane} plane sample count overflow for {width_usize}x{height_usize}"), - } - })?; - let mut samples = Vec::with_capacity(expected_samples); + validate_rgba_paste_buffer_len( + source.len(), + source_width_usize, + source_height_usize, + source_width, + source_height, + plane, + "source", + )?; + validate_rgba_paste_buffer_len( + destination.len(), + destination_width_usize, + destination_height_usize, + destination_width, + destination_height, + plane, + "destination", + )?; - for row in 0..height_usize { - let src_row = crop_top_usize.checked_add(row).ok_or_else(|| { + let remaining_width = destination_width_usize - x_origin_usize; + let copy_width = source_width_usize.min(remaining_width); + if copy_width == 0 { + return Ok(()); + } + let max_rows = source_height_usize.min(destination_height_usize - y_origin_usize); + let copy_samples = + copy_width + .checked_mul(4) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} row sample count overflow for copy width {copy_width}"), + })?; + for row in 0..max_rows { + let source_start = row + .checked_mul(source_width_usize) + .and_then(|offset| offset.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source row index overflow at row {row}"), + })?; + let source_end = source_start.checked_add(copy_samples).ok_or_else(|| { DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} plane row index overflow: crop_top={crop_top_usize}, row={row}" - ), + detail: format!("{plane} source row end overflow at row {row}"), } })?; - let src_start = src_row - .checked_mul(stride) - .and_then(|offset| offset.checked_add(crop_left_usize)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} plane source index overflow at row {row} (stride={stride}, crop_left={crop_left_usize})" - ), - })?; - let src_end = src_start - .checked_add(width_usize) + let destination_row = y_origin_usize.checked_add(row).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination row overflow at row {row}"), + } + })?; + let destination_start = destination_row + .checked_mul(destination_width_usize) + .and_then(|offset| offset.checked_add(x_origin_usize)) + .and_then(|offset| offset.checked_mul(4)) .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} plane source row end overflow at row {row} (start={src_start}, width={width_usize})" - ), + detail: format!("{plane} destination row start overflow at row {row}"), })?; - if src_end > source.len() { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} plane row {row} exceeds decoded buffer: end={src_end}, available={}", - source.len() - ), - }); - } - samples.extend_from_slice(&source[src_start..src_end]); + let destination_end = destination_start.checked_add(copy_samples).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination row end overflow at row {row}"), + } + })?; + + destination[destination_start..destination_end] + .copy_from_slice(&source[source_start..source_end]); } - Ok(HeicPlane { - width, - height, - samples, - }) + Ok(()) } -fn assemble_heic_hevc_stream_from_components( - hvcc: &isobmff::HevcDecoderConfigurationBox, - payload: &[u8], -) -> Result, DecodeHeicError> { - let nal_length_size = hvcc.nal_length_size; - if !(1..=4).contains(&nal_length_size) { - return Err(DecodeHeicError::InvalidNalLengthSize { nal_length_size }); - } - - let mut stream = Vec::new(); - append_hvcc_header_nals(&hvcc.nal_arrays, &mut stream)?; +#[allow(clippy::too_many_arguments)] +fn paste_transformed_rgba_tile_with_clip( + source: &[T], + source_width: u32, + source_height: u32, + destination: &mut [T], + orientation_transform: &RgbaOrientationTransform, + x_origin: u32, + y_origin: u32, + plane: &'static str, +) -> Result<(), DecodeError> { + if x_origin >= orientation_transform.source_width + || y_origin >= orientation_transform.source_height + { + return Ok(()); + } + + let source_width_usize = + usize::try_from(source_width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} width {source_width} cannot be represented"), + })?; + let source_height_usize = + usize::try_from(source_height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} height {source_height} cannot be represented"), + })?; + let x_origin_usize = + usize::try_from(x_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} x-origin {x_origin} cannot be represented"), + })?; + let y_origin_usize = + usize::try_from(y_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} y-origin {y_origin} cannot be represented"), + })?; + + validate_rgba_paste_buffer_len( + source.len(), + source_width_usize, + source_height_usize, + source_width, + source_height, + plane, + "source", + )?; + validate_rgba_paste_buffer_len( + destination.len(), + orientation_transform.destination_width_usize, + orientation_transform.destination_height_usize, + orientation_transform.destination_width, + orientation_transform.destination_height, + plane, + "destination", + )?; + + let remaining_width = orientation_transform.source_width_usize - x_origin_usize; + let copy_width = source_width_usize.min(remaining_width); + if copy_width == 0 { + return Ok(()); + } + let max_rows = + source_height_usize.min(orientation_transform.source_height_usize - y_origin_usize); + + for row in 0..max_rows { + let source_row_start = row + .checked_mul(source_width_usize) + .and_then(|offset| offset.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source row index overflow at row {row}"), + })?; + let source_y = y_origin_usize.checked_add(row).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source y-coordinate overflow at row {row}"), + } + })?; + for column in 0..copy_width { + let source_x = x_origin_usize.checked_add(column).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source x-coordinate overflow at column {column}"), + } + })?; + let source_start = source_row_start + .checked_add(column.checked_mul(4).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source column sample overflow at {column}"), + } + })?) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source sample index overflow at column {column}"), + })?; + let source_end = source_start.checked_add(4).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source sample end overflow at column {column}"), + } + })?; + let (destination_x, destination_y) = + orientation_transform.map_source_pixel(source_x, source_y)?; + let destination_start = destination_y + .checked_mul(orientation_transform.destination_width_usize) + .and_then(|offset| offset.checked_add(destination_x)) + .and_then(|offset| offset.checked_mul(4)) + .ok_or({ + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct grid destination", + x: destination_x, + y: destination_y, + width: orientation_transform.destination_width, + height: orientation_transform.destination_height, + }) + })?; + let destination_end = destination_start.checked_add(4).ok_or({ + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "direct grid destination", + x: destination_x, + y: destination_y, + width: orientation_transform.destination_width, + height: orientation_transform.destination_height, + }) + })?; + + destination[destination_start..destination_end] + .copy_from_slice(&source[source_start..source_end]); + } + } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn paste_decoded_heic_grid_tile( + tile: &DecodedHeicImage, + output: &mut DecodedHeicImage, + tile_width: u32, + tile_height: u32, + row: usize, + column: usize, + tile_index: usize, +) -> Result<(), DecodeHeicError> { + let reference = HeicGridTileReference::from_output_canvas(output, tile_width, tile_height); + validate_decoded_heic_grid_tile_reference(tile, &reference, tile_index)?; + + validate_heic_plane_dimensions(&tile.y_plane, tile.width, tile.height, "grid tile Y")?; + let (x_origin, y_origin) = heic_grid_tile_origin(tile_width, tile_height, row, column)?; + + paste_heic_plane_with_clip( + &tile.y_plane, + &mut output.y_plane, + x_origin, + y_origin, + "grid tile Y", + )?; + + if output.layout == HeicPixelLayout::Yuv400 { + return Ok(()); + } + + let (subsample_x, subsample_y) = heic_chroma_subsampling(output.layout); + validate_heic_grid_tile_origin_alignment(output.layout, x_origin, y_origin)?; + + let (tile_u_plane, tile_v_plane, expected_chroma_width, expected_chroma_height) = + require_heic_chroma_planes(tile)?; + validate_heic_plane_dimensions( + tile_u_plane, + expected_chroma_width, + expected_chroma_height, + "grid tile U", + )?; + validate_heic_plane_dimensions( + tile_v_plane, + expected_chroma_width, + expected_chroma_height, + "grid tile V", + )?; + + let chroma_x_origin = x_origin / subsample_x; + let chroma_y_origin = y_origin / subsample_y; + paste_heic_plane_with_clip( + tile_u_plane, + output + .u_plane + .as_mut() + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "missing output U plane for non-monochrome grid".to_string(), + })?, + chroma_x_origin, + chroma_y_origin, + "grid tile U", + )?; + paste_heic_plane_with_clip( + tile_v_plane, + output + .v_plane + .as_mut() + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "missing output V plane for non-monochrome grid".to_string(), + })?, + chroma_x_origin, + chroma_y_origin, + "grid tile V", + )?; + + Ok(()) +} + +fn apply_heic_grid_tile_transforms( + mut decoded: DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], +) -> Result { + // Provenance: mirrors libheif grid tile decode behavior where each tile + // item is decoded with its own item transforms before pasting into the + // grid canvas (libheif/libheif/image-items/grid.cc: + // ImageItem_Grid::decode_and_paste_tile_image, which calls tile image-item + // decode flow applying clap/irot/imir transforms). + for transform in transforms { + match transform { + isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture) => { + // KNOWN DIVERGENCE: libheif converts a 4:2:0/4:2:2 tile to + // 4:4:4 before a chroma-unaligned clap crop + // (libheif/libheif/pixelimage.cc:HeifPixelImage::crop); + // cropping the subsampled planes here floors the chroma + // origin instead, shifting chroma phase for odd left/top + // offsets. Tiles have no post-conversion RGBA fallback + // before pasting, so this stays until tile decode grows one. + decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; + } + // A 0-degree rotation is a no-op; some muxers write the property + // redundantly. + isobmff::PrimaryItemTransformProperty::Rotation(rotation) + if rotation.rotation_ccw_degrees == 0 => {} + // Per-tile rotation/mirror would require plane-level transforms + // before pasting (libheif applies them); silently skipping them + // scrambles tile content, so reject loudly until implemented. + isobmff::PrimaryItemTransformProperty::Rotation(_) + | isobmff::PrimaryItemTransformProperty::Mirror(_) => { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile irot/imir transforms are not supported".to_string(), + }); + } + } + } + Ok(decoded) +} + +/// Whether a clean-aperture crop can be applied to the subsampled Y/U/V +/// planes without shifting the chroma phase of the converted RGBA output. +/// +/// Provenance: mirrors libheif's crop guard in +/// libheif/libheif/pixelimage.cc:HeifPixelImage::crop, which converts to +/// 4:4:4 before cropping when a 4:2:2 crop has an odd left offset or a 4:2:0 +/// crop has an odd left/top offset. Cropping the subsampled planes in those +/// cases floors the chroma origin, so the later RGBA conversion samples +/// chroma half a luma sample off from heif-dec output. +fn heic_clean_aperture_crop_preserves_chroma_phase( + decoded: &DecodedHeicImage, + clean_aperture: isobmff::ImageCleanApertureProperty, +) -> bool { + let (subsample_x, subsample_y) = heic_chroma_subsampling(decoded.layout); + if subsample_x == 1 && subsample_y == 1 { + return true; + } + let Ok(crop) = clean_aperture_crop_bounds(decoded.width, decoded.height, clean_aperture) else { + // Invalid crops also fall back to the RGBA transform path, which + // recomputes the bounds and reports the error. + return false; + }; + crop.left % i128::from(subsample_x) == 0 && crop.top % i128::from(subsample_y) == 0 +} + +/// Apply leading clean-aperture transforms to the subsampled planes when the +/// crop is chroma-phase-preserving, returning the cropped image and the +/// transforms that remain for the RGBA path. Cropping before RGBA conversion +/// keeps peak memory proportional to the cropped geometry; unaligned crops +/// stay on the RGBA path for pixel parity with libheif (see +/// [`heic_clean_aperture_crop_preserves_chroma_phase`]). +fn crop_heic_by_leading_chroma_aligned_clean_apertures( + mut decoded: DecodedHeicImage, + mut transforms: &[isobmff::PrimaryItemTransformProperty], +) -> Result<(DecodedHeicImage, &[isobmff::PrimaryItemTransformProperty]), DecodeError> { + while let Some(isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture)) = + transforms.first() + { + if !heic_clean_aperture_crop_preserves_chroma_phase(&decoded, *clean_aperture) { + break; + } + decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; + transforms = &transforms[1..]; + } + Ok((decoded, transforms)) +} + +fn crop_heic_by_clean_aperture( + decoded: DecodedHeicImage, + clean_aperture: isobmff::ImageCleanApertureProperty, +) -> Result { + // Bounds come from the same helper as the RGBA transform path so the two + // crop semantics cannot drift; only the error type differs here. + let crop = clean_aperture_crop_bounds(decoded.width, decoded.height, clean_aperture).map_err( + |source| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile clean-aperture crop is invalid for {}x{}: {source}", + decoded.width, decoded.height + ), + }, + )?; + let crop_width = crop.width; + let crop_height = crop.height; + let crop_left = u32::try_from(crop.left).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile clean-aperture left bound is out of range: {}", + crop.left + ), + })?; + let crop_top = u32::try_from(crop.top).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile clean-aperture top bound is out of range: {}", + crop.top + ), + })?; + + if crop_left == 0 + && crop_top == 0 + && crop_width == decoded.width + && crop_height == decoded.height + { + return Ok(decoded); + } + + validate_heic_plane_dimensions( + &decoded.y_plane, + decoded.width, + decoded.height, + "grid tile Y", + )?; + let y_stride = usize::try_from(decoded.y_plane.width).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile Y width {} cannot be represented for clap crop", + decoded.y_plane.width + ), + } + })?; + let y_plane = extract_cropped_heic_plane( + &decoded.y_plane.samples, + y_stride, + crop_left, + crop_top, + crop_width, + crop_height, + "grid tile Y", + )?; + + if decoded.layout == HeicPixelLayout::Yuv400 { + return Ok(DecodedHeicImage { + width: crop_width, + height: crop_height, + y_plane, + ..decoded + }); + } + + let (u_plane, v_plane, expected_chroma_width, expected_chroma_height) = + require_heic_chroma_planes(&decoded)?; + validate_heic_plane_dimensions( + u_plane, + expected_chroma_width, + expected_chroma_height, + "grid tile U", + )?; + validate_heic_plane_dimensions( + v_plane, + expected_chroma_width, + expected_chroma_height, + "grid tile V", + )?; + + let (subsample_x, subsample_y) = heic_chroma_subsampling(decoded.layout); + let chroma_crop_left = crop_left / subsample_x; + let chroma_crop_top = crop_top / subsample_y; + let chroma_crop_width = crop_width.div_ceil(subsample_x); + let chroma_crop_height = crop_height.div_ceil(subsample_y); + let u_stride = + usize::try_from(u_plane.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile U width {} cannot be represented for clap crop", + u_plane.width + ), + })?; + let v_stride = + usize::try_from(v_plane.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid tile V width {} cannot be represented for clap crop", + v_plane.width + ), + })?; + let cropped_u = extract_cropped_heic_plane( + &u_plane.samples, + u_stride, + chroma_crop_left, + chroma_crop_top, + chroma_crop_width, + chroma_crop_height, + "grid tile U", + )?; + let cropped_v = extract_cropped_heic_plane( + &v_plane.samples, + v_stride, + chroma_crop_left, + chroma_crop_top, + chroma_crop_width, + chroma_crop_height, + "grid tile V", + )?; + + Ok(DecodedHeicImage { + width: crop_width, + height: crop_height, + y_plane, + u_plane: Some(cropped_u), + v_plane: Some(cropped_v), + ..decoded + }) +} + +fn paste_heic_plane_with_clip( + source: &HeicPlane, + destination: &mut HeicPlane, + x_origin: u32, + y_origin: u32, + plane: &'static str, +) -> Result<(), DecodeHeicError> { + if x_origin >= destination.width || y_origin >= destination.height { + return Ok(()); + } + + let source_width = + usize::try_from(source.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} plane width {} cannot be represented", source.width), + })?; + let source_height = + usize::try_from(source.height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} plane height {} cannot be represented", + source.height + ), + })?; + let destination_width = + usize::try_from(destination.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination width {} cannot be represented", + destination.width + ), + })?; + let destination_height = + usize::try_from(destination.height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination height {} cannot be represented", + destination.height + ), + })?; + let x_origin_usize = + usize::try_from(x_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} x-origin {x_origin} cannot be represented"), + })?; + let y_origin_usize = + usize::try_from(y_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} y-origin {y_origin} cannot be represented"), + })?; + + let source_sample_count = source_width.checked_mul(source_height).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} source sample count overflow for {}x{}", + source.width, source.height + ), + } + })?; + if source.samples.len() != source_sample_count { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} source plane has {} samples, expected {source_sample_count}", + source.samples.len() + ), + }); + } + + let destination_sample_count = destination_width + .checked_mul(destination_height) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination sample count overflow for {}x{}", + destination.width, destination.height + ), + })?; + if destination.samples.len() != destination_sample_count { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination plane has {} samples, expected {destination_sample_count}", + destination.samples.len() + ), + }); + } + + let remaining_width = destination_width - x_origin_usize; + let copy_width = source_width.min(remaining_width); + if copy_width == 0 { + return Ok(()); + } + let max_rows = source_height.min(destination_height - y_origin_usize); + for row in 0..max_rows { + let source_start = + row.checked_mul(source_width) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source row index overflow at row {row}"), + })?; + let source_end = source_start.checked_add(copy_width).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} source row end overflow at row {row}"), + } + })?; + + let destination_row = y_origin_usize.checked_add(row).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination row overflow at row {row}"), + } + })?; + let destination_start = destination_row + .checked_mul(destination_width) + .and_then(|offset| offset.checked_add(x_origin_usize)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination row start overflow at row {row}"), + })?; + let destination_end = destination_start.checked_add(copy_width).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination row end overflow at row {row}"), + } + })?; + + destination.samples[destination_start..destination_end] + .copy_from_slice(&source.samples[source_start..source_end]); + } + + Ok(()) +} + +fn validate_decoded_heic_geometry_against_ispe( + metadata: &DecodedHeicImageMetadata, + expected_width: u32, + expected_height: u32, +) -> Result<(), DecodeHeicError> { + if metadata.width != expected_width || metadata.height != expected_height { + return Err(DecodeHeicError::DecodedGeometryMismatch { + expected_width, + expected_height, + actual_width: metadata.width, + actual_height: metadata.height, + }); + } + + Ok(()) +} + +fn validate_decoded_heic_image_against_metadata( + decoded: &DecodedHeicImage, + metadata: &DecodedHeicImageMetadata, +) -> Result<(), DecodeHeicError> { + // Provenance: mirrors libheif's decoder metadata expectations where HEVC + // coded-image chroma/bit-depth metadata is exposed by + // Decoder_HEVC::{get_coded_image_colorspace,get_luma_bits_per_pixel,get_chroma_bits_per_pixel} + // and backend output planes are materialized in + // plugins/decoder_libde265.cc:convert_libde265_image_to_heif_image. + if decoded.width != metadata.width || decoded.height != metadata.height { + return Err(DecodeHeicError::DecodedGeometryMismatch { + expected_width: metadata.width, + expected_height: metadata.height, + actual_width: decoded.width, + actual_height: decoded.height, + }); + } + + if decoded.layout != metadata.layout { + return Err(DecodeHeicError::DecodedLayoutMismatch { + expected: metadata.layout, + actual: decoded.layout, + }); + } + + if decoded.bit_depth_luma != metadata.bit_depth_luma + || decoded.bit_depth_chroma != metadata.bit_depth_chroma + { + return Err(DecodeHeicError::DecodedBitDepthMismatch { + expected_luma: metadata.bit_depth_luma, + expected_chroma: metadata.bit_depth_chroma, + actual_luma: decoded.bit_depth_luma, + actual_chroma: decoded.bit_depth_chroma, + }); + } + + Ok(()) +} + +fn decode_hevc_stream_to_image(stream: &[u8]) -> Result { + let parsed_nals = parse_length_prefixed_hevc_nal_units(stream)?; + if !parsed_nals + .iter() + .any(|nal| nal.class() == HevcNalClass::Vcl) + { + return Err(DecodeHeicError::MissingVclNalUnit); + } + + let decoded = + heic_decoder::hevc::decode(stream).map_err(|err| DecodeHeicError::BackendDecodeFailed { + detail: err.to_string(), + })?; + heic_frame_to_internal_image(decoded) +} + +fn heic_frame_to_internal_image(frame: HeicFrame) -> Result { + let width = frame.cropped_width(); + let height = frame.cropped_height(); + if width == 0 || height == 0 { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!("cropped geometry must be non-zero, got {width}x{height}"), + }); + } + + let layout = heic_layout_from_sps_chroma_array_type(frame.chroma_format)?; + let bit_depth = frame.bit_depth; + let ycbcr_range = if frame.full_range { + YCbCrRange::Full + } else { + YCbCrRange::Limited + }; + let ycbcr_matrix = YCbCrMatrixCoefficients { + matrix_coefficients: u16::from(frame.matrix_coeffs), + colour_primaries: u16::from(frame.colour_primaries), + }; + let full_width = frame.width; + let full_height = frame.height; + let crop_left = frame.crop_left; + let crop_right = frame.crop_right; + let crop_top = frame.crop_top; + let crop_bottom = frame.crop_bottom; + let y_stride = frame.y_stride(); + let c_stride = frame.c_stride(); + + let y_plane = materialize_heic_plane( + frame.y_plane, + y_stride, + full_width, + full_height, + crop_left, + crop_top, + width, + height, + "Y", + )?; + + let (u_plane, v_plane) = match layout { + HeicPixelLayout::Yuv400 => (None, None), + HeicPixelLayout::Yuv420 | HeicPixelLayout::Yuv422 | HeicPixelLayout::Yuv444 => { + let (subsample_x, subsample_y) = heic_chroma_subsampling(layout); + if !crop_left.is_multiple_of(subsample_x) + || !crop_right.is_multiple_of(subsample_x) + || !crop_top.is_multiple_of(subsample_y) + || !crop_bottom.is_multiple_of(subsample_y) + { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "chroma crop alignment mismatch for layout {layout:?}: crop=({}, {}, {}, {})", + crop_left, crop_right, crop_top, crop_bottom + ), + }); + } + + let chroma_width = width.div_ceil(subsample_x); + let chroma_height = height.div_ceil(subsample_y); + let chroma_full_width = full_width.div_ceil(subsample_x); + let chroma_full_height = full_height.div_ceil(subsample_y); + let chroma_crop_left = crop_left / subsample_x; + let chroma_crop_top = crop_top / subsample_y; + + let cb_plane = materialize_heic_plane( + frame.cb_plane, + c_stride, + chroma_full_width, + chroma_full_height, + chroma_crop_left, + chroma_crop_top, + chroma_width, + chroma_height, + "U", + )?; + let cr_plane = materialize_heic_plane( + frame.cr_plane, + c_stride, + chroma_full_width, + chroma_full_height, + chroma_crop_left, + chroma_crop_top, + chroma_width, + chroma_height, + "V", + )?; + (Some(cb_plane), Some(cr_plane)) + } + }; + + Ok(DecodedHeicImage { + width, + height, + bit_depth_luma: bit_depth, + bit_depth_chroma: bit_depth, + layout, + // Provenance: mirror libheif decoder-plugin color handoff where + // bitstream-derived range/matrix metadata is attached when available + // (libheif/libheif/plugins/decoder_libde265.cc: + // de265_get_image_{full_range_flag,matrix_coefficients}). + ycbcr_range, + ycbcr_matrix, + y_plane, + u_plane, + v_plane, + }) +} + +fn heic_chroma_subsampling(layout: HeicPixelLayout) -> (u32, u32) { + match layout { + HeicPixelLayout::Yuv400 | HeicPixelLayout::Yuv444 => (1, 1), + HeicPixelLayout::Yuv420 => (2, 2), + HeicPixelLayout::Yuv422 => (2, 1), + } +} + +#[allow(clippy::too_many_arguments)] +fn materialize_heic_plane( + samples: Vec, + stride: usize, + source_width: u32, + source_height: u32, + crop_left: u32, + crop_top: u32, + width: u32, + height: u32, + plane: &'static str, +) -> Result { + let width_usize = usize::try_from(width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} plane width does not fit in usize ({width})"), + })?; + let expected_samples = heic_sample_count(width, height, plane)?; + if crop_left == 0 + && crop_top == 0 + && width == source_width + && height == source_height + && stride == width_usize + && samples.len() == expected_samples + { + return Ok(HeicPlane { + width, + height, + samples, + }); + } + + extract_cropped_heic_plane(&samples, stride, crop_left, crop_top, width, height, plane) +} + +fn extract_cropped_heic_plane( + source: &[u16], + stride: usize, + crop_left: u32, + crop_top: u32, + width: u32, + height: u32, + plane: &'static str, +) -> Result { + let width_usize = usize::try_from(width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} plane width does not fit in usize ({width})"), + })?; + let height_usize = + usize::try_from(height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} plane height does not fit in usize ({height})"), + })?; + let crop_left_usize = + usize::try_from(crop_left).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} plane crop_left does not fit in usize ({crop_left})"), + })?; + let crop_top_usize = + usize::try_from(crop_top).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} plane crop_top does not fit in usize ({crop_top})"), + })?; + + let row_end = crop_left_usize + .checked_add(width_usize) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} plane row bound overflows: crop_left={crop_left_usize}, width={width_usize}" + ), + })?; + if row_end > stride { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} plane stride {stride} smaller than crop+width bound {row_end}" + ), + }); + } + + let expected_samples = width_usize.checked_mul(height_usize).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} plane sample count overflow for {width_usize}x{height_usize}"), + } + })?; + let mut samples = Vec::with_capacity(expected_samples); + + for row in 0..height_usize { + let src_row = crop_top_usize.checked_add(row).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} plane row index overflow: crop_top={crop_top_usize}, row={row}" + ), + } + })?; + let src_start = src_row + .checked_mul(stride) + .and_then(|offset| offset.checked_add(crop_left_usize)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} plane source index overflow at row {row} (stride={stride}, crop_left={crop_left_usize})" + ), + })?; + let src_end = src_start + .checked_add(width_usize) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} plane source row end overflow at row {row} (start={src_start}, width={width_usize})" + ), + })?; + if src_end > source.len() { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} plane row {row} exceeds decoded buffer: end={src_end}, available={}", + source.len() + ), + }); + } + samples.extend_from_slice(&source[src_start..src_end]); + } + + Ok(HeicPlane { + width, + height, + samples, + }) +} + +fn assemble_heic_hevc_stream_from_components( + hvcc: &isobmff::HevcDecoderConfigurationBox, + payload: &[u8], +) -> Result, DecodeHeicError> { + let nal_length_size = hvcc.nal_length_size; + if !(1..=4).contains(&nal_length_size) { + return Err(DecodeHeicError::InvalidNalLengthSize { nal_length_size }); + } + + let mut stream = Vec::new(); + append_hvcc_header_nals(&hvcc.nal_arrays, &mut stream)?; append_normalized_hevc_payload_nals(payload, usize::from(nal_length_size), &mut stream)?; Ok(stream) } -fn parse_length_prefixed_hevc_nal_units( - stream: &[u8], -) -> Result>, DecodeHeicError> { - let mut units = Vec::new(); - let mut cursor = 0usize; - while cursor < stream.len() { - let length_offset = cursor; - let remaining = stream.len() - cursor; - if remaining < 4 { - return Err(DecodeHeicError::TruncatedLengthPrefixedStreamLength { - offset: length_offset, - available: remaining, - }); +fn parse_length_prefixed_hevc_nal_units( + stream: &[u8], +) -> Result>, DecodeHeicError> { + let mut units = Vec::new(); + let mut cursor = 0usize; + while cursor < stream.len() { + let length_offset = cursor; + let remaining = stream.len() - cursor; + if remaining < 4 { + return Err(DecodeHeicError::TruncatedLengthPrefixedStreamLength { + offset: length_offset, + available: remaining, + }); + } + + let nal_size = u32::from_be_bytes([ + stream[cursor], + stream[cursor + 1], + stream[cursor + 2], + stream[cursor + 3], + ]) as usize; + cursor += 4; + + let available = stream.len() - cursor; + if available < nal_size { + return Err(DecodeHeicError::TruncatedLengthPrefixedStreamNalUnit { + offset: cursor, + declared: nal_size, + available, + }); + } + + let nal_offset = cursor; + let nal_end = cursor + nal_size; + units.push(LengthPrefixedHevcNalUnit { + offset: nal_offset, + bytes: &stream[nal_offset..nal_end], + }); + cursor = nal_end; + } + + Ok(units) +} + +fn decode_hevc_stream_metadata_from_sps( + stream: &[u8], +) -> Result { + // Provenance: length-prefixed NAL iteration mirrors libheif's decoder + // plugin handoff loop in libheif/libheif/plugins/decoder_libde265.cc + // (libde265_v2_push_data/libde265_v1_push_data2), while SPS parsing is + // delegated to the pure-Rust scuffle-h265 backend. + for nal_unit in parse_length_prefixed_hevc_nal_units(stream)? { + if nal_unit.class() != HevcNalClass::ParameterSet { + continue; + } + if nal_unit.nal_unit_type() != Some(NALUnitType::SpsNut) { + continue; + } + return hevc_metadata_from_sps_nal(nal_unit.bytes, nal_unit.offset); + } + + Err(DecodeHeicError::MissingSpsNalUnit) +} + +/// Parse decoded-image metadata from a single SPS NAL unit; `nal_offset` is +/// only used to locate parse failures in error details. +fn hevc_metadata_from_sps_nal( + nal_bytes: &[u8], + nal_offset: usize, +) -> Result { + let sps = heic_decoder::hevc::bitstream::parse_single_nal(nal_bytes) + .and_then(|nal| heic_decoder::hevc::params::parse_sps(&nal.payload)) + .map_err(|err| DecodeHeicError::SpsParseFailed { + offset: nal_offset, + detail: err.to_string(), + })?; + + let (sub_width_c, sub_height_c) = match sps.chroma_format_idc { + 1 => (2u32, 2u32), + 2 => (2, 1), + _ => (1, 1), + }; + let crop_x = sps + .conf_win_offset + .0 + .saturating_add(sps.conf_win_offset.1) + .saturating_mul(sub_width_c); + let crop_y = sps + .conf_win_offset + .2 + .saturating_add(sps.conf_win_offset.3) + .saturating_mul(sub_height_c); + let width = sps.pic_width_in_luma_samples.saturating_sub(crop_x); + let height = sps.pic_height_in_luma_samples.saturating_sub(crop_y); + if width == 0 || height == 0 { + return Err(DecodeHeicError::InvalidSpsGeometry { + width: u64::from(width), + height: u64::from(height), + }); + } + + let chroma_array_type = if sps.separate_colour_plane_flag { + 0 + } else { + sps.chroma_format_idc + }; + let layout = heic_layout_from_sps_chroma_array_type(chroma_array_type)?; + + Ok(DecodedHeicImageMetadata { + width, + height, + bit_depth_luma: sps.bit_depth_y(), + bit_depth_chroma: sps.bit_depth_c(), + layout, + }) +} + +/// SPS-derived metadata from the hvcC parameter-set arrays alone, or `None` +/// when the arrays carry no SPS (`hev1` items may keep parameter sets only +/// in-stream). Selection matches the assembled-stream scan: first NAL whose +/// own header says SPS, in hvcC array order. +#[cfg(feature = "image-integration")] +fn hevc_metadata_from_hvcc_nal_arrays( + hvcc: &isobmff::HevcDecoderConfigurationBox, +) -> Result, DecodeHeicError> { + for nal_array in &hvcc.nal_arrays { + for nal_unit in &nal_array.nal_units { + let unit = LengthPrefixedHevcNalUnit { + offset: 0, + bytes: nal_unit, + }; + if unit.nal_unit_type() != Some(NALUnitType::SpsNut) { + continue; + } + return hevc_metadata_from_sps_nal(nal_unit, 0).map(Some); + } + } + Ok(None) +} + +/// SPS-derived metadata without assembling a decoder stream: prefer the hvcC +/// parameter-set arrays, then scan the item payload's length-prefixed NAL +/// units in place. This visits NAL units in the same order as assembling the +/// stream and scanning it, but copies no payload bytes — the layout probe +/// runs this once per image-hook decode. +#[cfg(feature = "image-integration")] +fn decode_hevc_metadata_from_hvcc_or_payload( + hvcc: &isobmff::HevcDecoderConfigurationBox, + payload: &[u8], +) -> Result { + let nal_length_size = hvcc.nal_length_size; + if !(1..=4).contains(&nal_length_size) { + return Err(DecodeHeicError::InvalidNalLengthSize { nal_length_size }); + } + if let Some(metadata) = hevc_metadata_from_hvcc_nal_arrays(hvcc)? { + return Ok(metadata); + } + + let mut metadata = None; + walk_length_prefixed_payload_nals(payload, usize::from(nal_length_size), |offset, nal| { + let unit = LengthPrefixedHevcNalUnit { offset, bytes: nal }; + if unit.nal_unit_type() != Some(NALUnitType::SpsNut) { + return Ok(false); + } + metadata = Some(hevc_metadata_from_sps_nal(nal, offset)?); + Ok(true) + })?; + metadata.ok_or(DecodeHeicError::MissingSpsNalUnit) +} + +fn heic_layout_from_sps_chroma_array_type( + chroma_array_type: u8, +) -> Result { + match chroma_array_type { + 0 => Ok(HeicPixelLayout::Yuv400), + 1 => Ok(HeicPixelLayout::Yuv420), + 2 => Ok(HeicPixelLayout::Yuv422), + 3 => Ok(HeicPixelLayout::Yuv444), + _ => Err(DecodeHeicError::UnsupportedSpsChromaArrayType { chroma_array_type }), + } +} + +const FTYP_BOX_TYPE: [u8; 4] = *b"ftyp"; +const META_BOX_TYPE: [u8; 4] = *b"meta"; +const IDAT_BOX_TYPE: [u8; 4] = *b"idat"; +const UUID_BOX_TYPE: [u8; 4] = *b"uuid"; +const BASIC_BOX_HEADER_SIZE: usize = 8; +const LARGE_BOX_SIZE_FIELD_SIZE: usize = 8; +const UUID_EXTENDED_TYPE_SIZE: usize = 16; +const TOP_LEVEL_BOX_HEADER_PROBE_SIZE: usize = + BASIC_BOX_HEADER_SIZE + LARGE_BOX_SIZE_FIELD_SIZE + UUID_EXTENDED_TYPE_SIZE; +const GENERIC_COMPRESSION_TYPE_BROTLI: [u8; 4] = *b"brot"; +const GENERIC_COMPRESSION_TYPE_ZLIB: [u8; 4] = *b"zlib"; +const GENERIC_COMPRESSION_TYPE_DEFLATE: [u8; 4] = *b"defl"; +const GENERIC_COMPRESSED_UNIT_FULL_ITEM: u8 = 0; +const GENERIC_COMPRESSED_UNIT_IMAGE: u8 = 1; +const GENERIC_COMPRESSED_UNIT_IMAGE_TILE: u8 = 2; +const GENERIC_COMPRESSED_UNIT_IMAGE_ROW: u8 = 3; +const GENERIC_COMPRESSED_UNIT_IMAGE_PIXEL: u8 = 4; +const ICEF_OFFSET_BITS_TABLE: [u8; 5] = [0, 16, 24, 32, 64]; +const ICEF_SIZE_BITS_TABLE: [u8; 5] = [8, 16, 24, 32, 64]; +const AV01_ITEM_TYPE: [u8; 4] = *b"av01"; +const HVC1_ITEM_TYPE: [u8; 4] = *b"hvc1"; +const HEV1_ITEM_TYPE: [u8; 4] = *b"hev1"; +const AUXL_REFERENCE_TYPE: [u8; 4] = *b"auxl"; +const CDSC_REFERENCE_TYPE: [u8; 4] = *b"cdsc"; +const EXIF_ITEM_TYPE: [u8; 4] = *b"Exif"; +const MIME_ITEM_TYPE: [u8; 4] = *b"mime"; +const EXIF_ORIENTATION_TAG: u16 = 0x0112; +const EXIF_HEADER: &[u8] = b"Exif\0\0"; +const TIFF_TAG_TYPE_SHORT: u16 = 3; +const TIFF_MAGIC_NUMBER: u16 = 42; +const EXIF_CONTENT_TYPE_APPLICATION_EXIF: &[u8] = b"application/exif"; +const EXIF_CONTENT_TYPE_IMAGE_TIFF: &[u8] = b"image/tiff"; +const AUXC_PROPERTY_TYPE: [u8; 4] = *b"auxC"; +const AV1C_PROPERTY_TYPE: [u8; 4] = *b"av1C"; +const HVCC_PROPERTY_TYPE: [u8; 4] = *b"hvcC"; +const UNCOMPRESSED_SAMPLING_NO_SUBSAMPLING: u8 = 0; +const UNCOMPRESSED_SAMPLING_422: u8 = 1; +const UNCOMPRESSED_SAMPLING_420: u8 = 2; +const UNCOMPRESSED_INTERLEAVE_COMPONENT: u8 = 0; +const UNCOMPRESSED_INTERLEAVE_PIXEL: u8 = 1; +const UNCOMPRESSED_INTERLEAVE_MIXED: u8 = 2; +const UNCOMPRESSED_INTERLEAVE_ROW: u8 = 3; +const UNCOMPRESSED_INTERLEAVE_TILE_COMPONENT: u8 = 4; +const UNCOMPRESSED_INTERLEAVE_MULTI_Y: u8 = 5; +const UNCOMPRESSED_COMPONENT_FORMAT_UNSIGNED: u8 = 0; +const UNCOMPRESSED_COMPONENT_TYPE_MONOCHROME: u16 = 0; +const UNCOMPRESSED_COMPONENT_TYPE_LUMA: u16 = 1; +const UNCOMPRESSED_COMPONENT_TYPE_CB: u16 = 2; +const UNCOMPRESSED_COMPONENT_TYPE_CR: u16 = 3; +const UNCOMPRESSED_COMPONENT_TYPE_RED: u16 = 4; +const UNCOMPRESSED_COMPONENT_TYPE_GREEN: u16 = 5; +const UNCOMPRESSED_COMPONENT_TYPE_BLUE: u16 = 6; +const UNCOMPRESSED_COMPONENT_TYPE_ALPHA: u16 = 7; +const UNCOMPRESSED_COMPONENT_TYPE_PADDED: u16 = 12; +const UNCOMPRESSED_CHANNEL_COUNT: usize = 8; +const UNCOMPRESSED_CHANNEL_MONO: usize = 0; +const UNCOMPRESSED_CHANNEL_LUMA: usize = 1; +const UNCOMPRESSED_CHANNEL_CB: usize = 2; +const UNCOMPRESSED_CHANNEL_CR: usize = 3; +const UNCOMPRESSED_CHANNEL_RED: usize = 4; +const UNCOMPRESSED_CHANNEL_GREEN: usize = 5; +const UNCOMPRESSED_CHANNEL_BLUE: usize = 6; +const UNCOMPRESSED_CHANNEL_ALPHA: usize = 7; +const ALPHA_AUX_TYPES: [&[u8]; 3] = [ + b"urn:mpeg:avc:2015:auxid:1", + b"urn:mpeg:hevc:2015:auxid:1", + b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha", +]; + +/// Return `true` when the primary item already carries `irot` or `imir`. +/// +/// When this is `true`, applying EXIF orientation in addition to decode output may +/// double-rotate or double-mirror the image. +pub fn primary_item_has_orientation_transform(input: &[u8]) -> bool { + let Ok(transforms) = isobmff::parse_primary_item_transform_properties(input) else { + return false; + }; + transforms_include_orientation(&transforms.transforms) +} + +/// Parse the raw EXIF orientation (`1..=8`) associated with the primary HEIF item. +/// +/// Returns `None` when no primary-linked EXIF orientation is present. +pub fn primary_exif_orientation(input: &[u8]) -> Option { + let mut source: Option<&mut dyn RandomAccessSource> = None; + primary_exif_orientation_from_heif(input, &mut source) + .and_then(|orientation| u8::try_from(orientation).ok()) + .filter(|orientation| (1..=8).contains(orientation)) +} + +/// Inspect EXIF orientation and primary-item transform signalling for caller-controlled orientation handling. +pub fn exif_orientation_hint(input: &[u8]) -> ExifOrientationHint { + ExifOrientationHint { + exif_orientation: primary_exif_orientation(input), + primary_item_has_orientation_transform: primary_item_has_orientation_transform(input), + } +} + +/// Parse the raw EXIF orientation (`1..=8`) from a HEIF/HEIC file path without decoding pixel data. +/// +/// This reads container metadata and the EXIF item payload (when present), but does +/// not decode image planes into RGB/RGBA. +pub fn primary_exif_orientation_from_path(input_path: &Path) -> Result, DecodeError> { + Ok(exif_orientation_hint_from_path(input_path)?.exif_orientation) +} + +/// Inspect EXIF orientation and primary-item transform signalling from a file path. +/// +/// This path-based variant avoids loading the whole file into memory and avoids +/// full image decode. +pub fn exif_orientation_hint_from_path( + input_path: &Path, +) -> Result { + if !input_path.exists() { + return Err(DecodeError::Unsupported(format!( + "Input file does not exist: {}", + input_path.display() + ))); + } + + // Cheap caller-facing gate: only HEIF/HEIC extensions participate in EXIF + // orientation handling. AVIF and unknown extensions short-circuit. + if !path_extension_is_heif(input_path) { + return Ok(ExifOrientationHint { + exif_orientation: None, + primary_item_has_orientation_transform: false, + }); + } + + let mut source = FileSource::open(input_path).map_err(decode_error_from_source_read_error)?; + let selected = + read_selected_top_level_boxes_from_source(&mut source, &[FTYP_BOX_TYPE, META_BOX_TYPE])?; + let source_family_hint = detect_input_family_from_source_selected_boxes(&selected)?; + if source_family_hint != Some(HeifInputFamily::Heif) { + return Ok(ExifOrientationHint { + exif_orientation: None, + primary_item_has_orientation_transform: false, + }); + } + + let input = encode_source_selected_top_level_boxes(&selected); + let primary_item_has_orientation_transform = + isobmff::parse_primary_item_transform_properties(&input) + .map(|transforms| transforms_include_orientation(&transforms.transforms)) + .unwrap_or(false); + + let mut source_handle: Option<&mut dyn RandomAccessSource> = Some(&mut source); + let exif_orientation = primary_exif_orientation_from_heif(&input, &mut source_handle) + .and_then(|orientation| u8::try_from(orientation).ok()) + .filter(|orientation| (1..=8).contains(orientation)); + + Ok(ExifOrientationHint { + exif_orientation, + primary_item_has_orientation_transform, + }) +} + +fn transforms_include_orientation(transforms: &[isobmff::PrimaryItemTransformProperty]) -> bool { + transforms.iter().any(|transform| { + matches!( + transform, + isobmff::PrimaryItemTransformProperty::Rotation(rotation) + if rotation.rotation_ccw_degrees % 360 != 0 + ) || matches!(transform, isobmff::PrimaryItemTransformProperty::Mirror(_)) + }) +} + +fn primary_exif_orientation_from_heif( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, +) -> Option { + for payload in primary_exif_item_payloads(input, source) { + let Some(orientation) = parse_exif_orientation_from_item_payload(&payload) else { + continue; + }; + if (1..=8).contains(&orientation) { + return Some(orientation); + } + } + + None +} + +/// Collect the payloads of every cdsc-linked EXIF candidate item that +/// describes the primary item (usually zero or one). +fn primary_exif_item_payloads( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, +) -> Vec> { + let mut payloads = Vec::new(); + let Ok(top_level) = isobmff::parse_boxes(input) else { + return payloads; + }; + let Some(meta_box) = find_first_box_by_type(&top_level, META_BOX_TYPE) else { + return payloads; + }; + let Ok(meta) = meta_box.parse_meta() else { + return payloads; + }; + let Ok(resolved) = meta.resolve_primary_item() else { + return payloads; + }; + let Some(iref) = resolved.iref.as_ref() else { + return payloads; + }; + let primary_item_id = resolved.primary_item.item_id; + + for reference in &iref.references { + if reference.reference_type.as_bytes() != CDSC_REFERENCE_TYPE { + continue; + } + if !reference.to_item_ids.contains(&primary_item_id) { + continue; + } + + let item_id = reference.from_item_id; + let Some(item_info) = resolved + .iinf + .entries + .iter() + .find(|entry| entry.item_id == item_id) + else { + continue; + }; + if !item_info_is_exif_candidate(item_info) { + continue; + } + + let Some(location) = resolved + .iloc + .items + .iter() + .find(|item| item.item_id == item_id) + else { + continue; + }; + if location.data_reference_index != 0 { + continue; + } + + if let Some(payload) = extract_heic_item_payload_with_source(input, source, &meta, location) + { + payloads.push(payload); + } + } + + payloads +} + +/// Raw TIFF-aligned EXIF block for the primary item, in the shape +/// `ImageDecoder::exif_metadata` consumers expect (starting at the TIFF +/// byte-order marker, i.e. what `Orientation::from_exif_chunk` parses). +#[cfg(feature = "image-integration")] +pub(crate) fn primary_exif_tiff_payload(input: &[u8]) -> Option> { + let mut source: Option<&mut dyn RandomAccessSource> = None; + for payload in primary_exif_item_payloads(input, &mut source) { + if let Some(tiff_start) = exif_item_payload_tiff_start(&payload) { + return Some(payload[tiff_start..].to_vec()); + } + } + None +} + +fn item_info_is_exif_candidate(item_info: &isobmff::ItemInfoEntryBox) -> bool { + let Some(item_type) = item_info.item_type else { + return false; + }; + if item_type.as_bytes() == EXIF_ITEM_TYPE { + return true; + } + if item_type.as_bytes() != MIME_ITEM_TYPE { + return false; + } + + if bytes_eq_ignore_ascii_case(&item_info.item_name, b"Exif") { + return true; + } + let Some(content_type) = item_info.content_type.as_deref() else { + return false; + }; + bytes_eq_ignore_ascii_case(content_type, EXIF_CONTENT_TYPE_APPLICATION_EXIF) + || bytes_eq_ignore_ascii_case(content_type, EXIF_CONTENT_TYPE_IMAGE_TIFF) +} + +fn bytes_eq_ignore_ascii_case(lhs: &[u8], rhs: &[u8]) -> bool { + lhs.len() == rhs.len() + && lhs + .iter() + .zip(rhs) + .all(|(left, right)| left.eq_ignore_ascii_case(right)) +} + +fn exif_orientation_to_primary_item_transforms( + orientation: u16, +) -> Option> { + use isobmff::{ + ImageMirrorDirection, ImageMirrorProperty, ImageRotationProperty, + PrimaryItemTransformProperty, + }; + + let mirror_horizontal = || { + PrimaryItemTransformProperty::Mirror(ImageMirrorProperty { + direction: ImageMirrorDirection::Horizontal, + }) + }; + let mirror_vertical = || { + PrimaryItemTransformProperty::Mirror(ImageMirrorProperty { + direction: ImageMirrorDirection::Vertical, + }) + }; + let rotate_ccw = |rotation_ccw_degrees| { + PrimaryItemTransformProperty::Rotation(ImageRotationProperty { + rotation_ccw_degrees, + }) + }; + + match orientation { + 1 => Some(Vec::new()), + 2 => Some(vec![mirror_horizontal()]), + 3 => Some(vec![rotate_ccw(180)]), + 4 => Some(vec![mirror_vertical()]), + 5 => Some(vec![mirror_horizontal(), rotate_ccw(90)]), + 6 => Some(vec![rotate_ccw(270)]), + 7 => Some(vec![mirror_horizontal(), rotate_ccw(270)]), + 8 => Some(vec![rotate_ccw(90)]), + _ => None, + } +} + +fn parse_exif_orientation_from_item_payload(payload: &[u8]) -> Option { + for tiff_start in exif_item_payload_tiff_start_candidates(payload) { + let Some(orientation) = parse_exif_orientation_from_tiff(payload, tiff_start) else { + continue; + }; + if (1..=8).contains(&orientation) { + return Some(orientation); + } + } + None +} + +/// Candidate offsets of the TIFF block inside a HEIF EXIF item payload: the +/// 4-byte exif_tiff_header_offset prefix, an embedded "Exif\0\0" marker, and +/// the payload start itself. +fn exif_item_payload_tiff_start_candidates(payload: &[u8]) -> Vec { + let mut candidates = Vec::new(); + if payload.len() >= 4 + && let Ok(prefix) = <[u8; 4]>::try_from(&payload[0..4]) + { + let tiff_offset = usize::try_from(u32::from_be_bytes(prefix)).ok(); + if let Some(tiff_start) = tiff_offset.and_then(|offset| 4_usize.checked_add(offset)) { + candidates.push(tiff_start); } + } + if let Some(tiff_start) = find_subslice(payload, EXIF_HEADER) + .and_then(|header_start| header_start.checked_add(EXIF_HEADER.len())) + && !candidates.contains(&tiff_start) + { + candidates.push(tiff_start); + } + if !candidates.contains(&0) { + candidates.push(0); + } + candidates +} + +/// First candidate offset that carries a valid TIFF header, i.e. where the +/// EXIF block handed to image-crate consumers must start. +#[cfg(feature = "image-integration")] +fn exif_item_payload_tiff_start(payload: &[u8]) -> Option { + exif_item_payload_tiff_start_candidates(payload) + .into_iter() + .find(|&tiff_start| tiff_byte_order_at(payload, tiff_start).is_some()) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || haystack.len() < needle.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} - let nal_size = u32::from_be_bytes([ - stream[cursor], - stream[cursor + 1], - stream[cursor + 2], - stream[cursor + 3], - ]) as usize; - cursor += 4; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TiffByteOrder { + LittleEndian, + BigEndian, +} - let available = stream.len() - cursor; - if available < nal_size { - return Err(DecodeHeicError::TruncatedLengthPrefixedStreamNalUnit { - offset: cursor, - declared: nal_size, - available, - }); - } +/// Byte order of the TIFF header at `tiff_start`, or `None` when no valid +/// TIFF header (byte-order marker plus magic) starts there. +fn tiff_byte_order_at(payload: &[u8], tiff_start: usize) -> Option { + let byte_order = payload.get(tiff_start..tiff_start.checked_add(2)?)?; + let byte_order = match byte_order { + b"II" => TiffByteOrder::LittleEndian, + b"MM" => TiffByteOrder::BigEndian, + _ => return None, + }; - let nal_offset = cursor; - let nal_end = cursor + nal_size; - units.push(LengthPrefixedHevcNalUnit { - offset: nal_offset, - bytes: &stream[nal_offset..nal_end], - }); - cursor = nal_end; + let magic = read_tiff_u16(payload, tiff_start.checked_add(2)?, byte_order)?; + if magic != TIFF_MAGIC_NUMBER { + return None; } + Some(byte_order) +} - Ok(units) +fn parse_exif_orientation_from_tiff(payload: &[u8], tiff_start: usize) -> Option { + let byte_order = tiff_byte_order_at(payload, tiff_start)?; + let first_ifd_offset = read_tiff_u32(payload, tiff_start.checked_add(4)?, byte_order)?; + let first_ifd_offset = usize::try_from(first_ifd_offset).ok()?; + let first_ifd = tiff_start.checked_add(first_ifd_offset)?; + parse_exif_orientation_from_ifd(payload, tiff_start, first_ifd, byte_order) } -fn decode_hevc_stream_metadata_from_sps( - stream: &[u8], -) -> Result { - // Provenance: length-prefixed NAL iteration mirrors libheif's decoder - // plugin handoff loop in libheif/libheif/plugins/decoder_libde265.cc - // (libde265_v2_push_data/libde265_v1_push_data2), while SPS parsing is - // delegated to the pure-Rust scuffle-h265 backend. - for nal_unit in parse_length_prefixed_hevc_nal_units(stream)? { - if nal_unit.class() != HevcNalClass::ParameterSet { - continue; - } - if nal_unit.nal_unit_type() != Some(NALUnitType::SpsNut) { +fn parse_exif_orientation_from_ifd( + payload: &[u8], + tiff_start: usize, + ifd_offset: usize, + byte_order: TiffByteOrder, +) -> Option { + let entry_count = usize::from(read_tiff_u16(payload, ifd_offset, byte_order)?); + let entries_start = ifd_offset.checked_add(2)?; + + for entry_index in 0..entry_count { + let entry_offset = entries_start.checked_add(entry_index.checked_mul(12)?)?; + let tag = read_tiff_u16(payload, entry_offset, byte_order)?; + if tag != EXIF_ORIENTATION_TAG { continue; } - let nal_offset = nal_unit.offset; - let sps = heic_decoder::hevc::bitstream::parse_single_nal(nal_unit.bytes) - .and_then(|nal| heic_decoder::hevc::params::parse_sps(&nal.payload)) - .map_err(|err| DecodeHeicError::SpsParseFailed { - offset: nal_offset, - detail: err.to_string(), - })?; - - let (sub_width_c, sub_height_c) = match sps.chroma_format_idc { - 1 => (2u32, 2u32), - 2 => (2, 1), - _ => (1, 1), - }; - let crop_x = sps - .conf_win_offset - .0 - .saturating_add(sps.conf_win_offset.1) - .saturating_mul(sub_width_c); - let crop_y = sps - .conf_win_offset - .2 - .saturating_add(sps.conf_win_offset.3) - .saturating_mul(sub_height_c); - let width = sps.pic_width_in_luma_samples.saturating_sub(crop_x); - let height = sps.pic_height_in_luma_samples.saturating_sub(crop_y); - if width == 0 || height == 0 { - return Err(DecodeHeicError::InvalidSpsGeometry { - width: u64::from(width), - height: u64::from(height), - }); + let field_type = read_tiff_u16(payload, entry_offset.checked_add(2)?, byte_order)?; + let value_count = read_tiff_u32(payload, entry_offset.checked_add(4)?, byte_order)?; + if field_type != TIFF_TAG_TYPE_SHORT || value_count == 0 { + continue; } - let chroma_array_type = if sps.separate_colour_plane_flag { - 0 + let orientation = if value_count == 1 { + read_tiff_u16(payload, entry_offset.checked_add(8)?, byte_order)? } else { - sps.chroma_format_idc + let value_offset = read_tiff_u32(payload, entry_offset.checked_add(8)?, byte_order)?; + let value_offset = usize::try_from(value_offset).ok()?; + let value_position = tiff_start.checked_add(value_offset)?; + read_tiff_u16(payload, value_position, byte_order)? }; - let layout = heic_layout_from_sps_chroma_array_type(chroma_array_type)?; - return Ok(DecodedHeicImageMetadata { - width, - height, - bit_depth_luma: sps.bit_depth_y(), - bit_depth_chroma: sps.bit_depth_c(), - layout, - }); + if (1..=8).contains(&orientation) { + return Some(orientation); + } } - Err(DecodeHeicError::MissingSpsNalUnit) + None } -fn heic_layout_from_sps_chroma_array_type( - chroma_array_type: u8, -) -> Result { - match chroma_array_type { - 0 => Ok(HeicPixelLayout::Yuv400), - 1 => Ok(HeicPixelLayout::Yuv420), - 2 => Ok(HeicPixelLayout::Yuv422), - 3 => Ok(HeicPixelLayout::Yuv444), - _ => Err(DecodeHeicError::UnsupportedSpsChromaArrayType { chroma_array_type }), - } +fn read_tiff_u16(payload: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option { + let bytes = payload.get(offset..offset.checked_add(2)?)?; + let bytes: [u8; 2] = bytes.try_into().ok()?; + Some(match byte_order { + TiffByteOrder::LittleEndian => u16::from_le_bytes(bytes), + TiffByteOrder::BigEndian => u16::from_be_bytes(bytes), + }) } -const FTYP_BOX_TYPE: [u8; 4] = *b"ftyp"; -const META_BOX_TYPE: [u8; 4] = *b"meta"; -const IDAT_BOX_TYPE: [u8; 4] = *b"idat"; -const UUID_BOX_TYPE: [u8; 4] = *b"uuid"; -const BASIC_BOX_HEADER_SIZE: usize = 8; -const LARGE_BOX_SIZE_FIELD_SIZE: usize = 8; -const UUID_EXTENDED_TYPE_SIZE: usize = 16; -const TOP_LEVEL_BOX_HEADER_PROBE_SIZE: usize = - BASIC_BOX_HEADER_SIZE + LARGE_BOX_SIZE_FIELD_SIZE + UUID_EXTENDED_TYPE_SIZE; -const GENERIC_COMPRESSION_TYPE_BROTLI: [u8; 4] = *b"brot"; -const GENERIC_COMPRESSION_TYPE_ZLIB: [u8; 4] = *b"zlib"; -const GENERIC_COMPRESSION_TYPE_DEFLATE: [u8; 4] = *b"defl"; -const GENERIC_COMPRESSED_UNIT_FULL_ITEM: u8 = 0; -const GENERIC_COMPRESSED_UNIT_IMAGE: u8 = 1; -const GENERIC_COMPRESSED_UNIT_IMAGE_TILE: u8 = 2; -const GENERIC_COMPRESSED_UNIT_IMAGE_ROW: u8 = 3; -const GENERIC_COMPRESSED_UNIT_IMAGE_PIXEL: u8 = 4; -const ICEF_OFFSET_BITS_TABLE: [u8; 5] = [0, 16, 24, 32, 64]; -const ICEF_SIZE_BITS_TABLE: [u8; 5] = [8, 16, 24, 32, 64]; -const AV01_ITEM_TYPE: [u8; 4] = *b"av01"; -const HVC1_ITEM_TYPE: [u8; 4] = *b"hvc1"; -const HEV1_ITEM_TYPE: [u8; 4] = *b"hev1"; -const AUXL_REFERENCE_TYPE: [u8; 4] = *b"auxl"; -const CDSC_REFERENCE_TYPE: [u8; 4] = *b"cdsc"; -const EXIF_ITEM_TYPE: [u8; 4] = *b"Exif"; -const MIME_ITEM_TYPE: [u8; 4] = *b"mime"; -const EXIF_ORIENTATION_TAG: u16 = 0x0112; -const EXIF_HEADER: &[u8] = b"Exif\0\0"; -const TIFF_TAG_TYPE_SHORT: u16 = 3; -const TIFF_MAGIC_NUMBER: u16 = 42; -const EXIF_CONTENT_TYPE_APPLICATION_EXIF: &[u8] = b"application/exif"; -const EXIF_CONTENT_TYPE_IMAGE_TIFF: &[u8] = b"image/tiff"; -const AUXC_PROPERTY_TYPE: [u8; 4] = *b"auxC"; -const AV1C_PROPERTY_TYPE: [u8; 4] = *b"av1C"; -const HVCC_PROPERTY_TYPE: [u8; 4] = *b"hvcC"; -const UNCOMPRESSED_SAMPLING_NO_SUBSAMPLING: u8 = 0; -const UNCOMPRESSED_SAMPLING_422: u8 = 1; -const UNCOMPRESSED_SAMPLING_420: u8 = 2; -const UNCOMPRESSED_INTERLEAVE_COMPONENT: u8 = 0; -const UNCOMPRESSED_INTERLEAVE_PIXEL: u8 = 1; -const UNCOMPRESSED_INTERLEAVE_MIXED: u8 = 2; -const UNCOMPRESSED_INTERLEAVE_ROW: u8 = 3; -const UNCOMPRESSED_INTERLEAVE_TILE_COMPONENT: u8 = 4; -const UNCOMPRESSED_INTERLEAVE_MULTI_Y: u8 = 5; -const UNCOMPRESSED_COMPONENT_FORMAT_UNSIGNED: u8 = 0; -const UNCOMPRESSED_COMPONENT_TYPE_MONOCHROME: u16 = 0; -const UNCOMPRESSED_COMPONENT_TYPE_LUMA: u16 = 1; -const UNCOMPRESSED_COMPONENT_TYPE_CB: u16 = 2; -const UNCOMPRESSED_COMPONENT_TYPE_CR: u16 = 3; -const UNCOMPRESSED_COMPONENT_TYPE_RED: u16 = 4; -const UNCOMPRESSED_COMPONENT_TYPE_GREEN: u16 = 5; -const UNCOMPRESSED_COMPONENT_TYPE_BLUE: u16 = 6; -const UNCOMPRESSED_COMPONENT_TYPE_ALPHA: u16 = 7; -const UNCOMPRESSED_COMPONENT_TYPE_PADDED: u16 = 12; -const UNCOMPRESSED_CHANNEL_COUNT: usize = 8; -const UNCOMPRESSED_CHANNEL_MONO: usize = 0; -const UNCOMPRESSED_CHANNEL_LUMA: usize = 1; -const UNCOMPRESSED_CHANNEL_CB: usize = 2; -const UNCOMPRESSED_CHANNEL_CR: usize = 3; -const UNCOMPRESSED_CHANNEL_RED: usize = 4; -const UNCOMPRESSED_CHANNEL_GREEN: usize = 5; -const UNCOMPRESSED_CHANNEL_BLUE: usize = 6; -const UNCOMPRESSED_CHANNEL_ALPHA: usize = 7; -const ALPHA_AUX_TYPES: [&[u8]; 3] = [ - b"urn:mpeg:avc:2015:auxid:1", - b"urn:mpeg:hevc:2015:auxid:1", - b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha", -]; - -/// Return `true` when the primary item already carries `irot` or `imir`. -/// -/// When this is `true`, applying EXIF orientation in addition to decode output may -/// double-rotate or double-mirror the image. -pub fn primary_item_has_orientation_transform(input: &[u8]) -> bool { - let Ok(transforms) = isobmff::parse_primary_item_transform_properties(input) else { - return false; - }; - transforms_include_orientation(&transforms.transforms) +fn read_tiff_u32(payload: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option { + let bytes = payload.get(offset..offset.checked_add(4)?)?; + let bytes: [u8; 4] = bytes.try_into().ok()?; + Some(match byte_order { + TiffByteOrder::LittleEndian => u32::from_le_bytes(bytes), + TiffByteOrder::BigEndian => u32::from_be_bytes(bytes), + }) } -/// Parse the raw EXIF orientation (`1..=8`) associated with the primary HEIF item. -/// -/// Returns `None` when no primary-linked EXIF orientation is present. -pub fn primary_exif_orientation(input: &[u8]) -> Option { - let mut source: Option<&mut dyn RandomAccessSource> = None; - primary_exif_orientation_from_heif(input, &mut source) - .and_then(|orientation| u8::try_from(orientation).ok()) - .filter(|orientation| (1..=8).contains(orientation)) -} +fn decode_primary_avif_auxiliary_alpha_plane( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + meta: &isobmff::MetaBox<'_>, + resolved: &isobmff::ResolvedPrimaryItemGraph<'_>, + expected_width: u32, + expected_height: u32, +) -> Option { + // Provenance: mirrors libheif auxiliary alpha linkage in + // libheif/libheif/context.cc (`auxl` reference direction and aux-type + // filtering) and ImageItem alpha composition flow in + // libheif/libheif/image-items/image_item.cc (`decode_image`). + let iref = resolved.iref.as_ref()?; + let primary_item_id = resolved.primary_item.item_id; -/// Inspect EXIF orientation and primary-item transform signalling for caller-controlled orientation handling. -pub fn exif_orientation_hint(input: &[u8]) -> ExifOrientationHint { - ExifOrientationHint { - exif_orientation: primary_exif_orientation(input), - primary_item_has_orientation_transform: primary_item_has_orientation_transform(input), - } -} + for reference in &iref.references { + if reference.reference_type.as_bytes() != AUXL_REFERENCE_TYPE { + continue; + } + if !reference.to_item_ids.contains(&primary_item_id) { + continue; + } -/// Parse the raw EXIF orientation (`1..=8`) from a HEIF/HEIC file path without decoding pixel data. -/// -/// This reads container metadata and the EXIF item payload (when present), but does -/// not decode image planes into RGB/RGBA. -pub fn primary_exif_orientation_from_path(input_path: &Path) -> Result, DecodeError> { - Ok(exif_orientation_hint_from_path(input_path)?.exif_orientation) + let Some(alpha_plane) = decode_auxiliary_alpha_avif_item_candidate( + input, + source, + meta, + resolved, + reference.from_item_id, + ) else { + continue; + }; + if alpha_plane.width != expected_width || alpha_plane.height != expected_height { + continue; + } + return Some(alpha_plane); + } + + None } -/// Inspect EXIF orientation and primary-item transform signalling from a file path. -/// -/// This path-based variant avoids loading the whole file into memory and avoids -/// full image decode. -pub fn exif_orientation_hint_from_path( - input_path: &Path, -) -> Result { - if !input_path.exists() { - return Err(DecodeError::Unsupported(format!( - "Input file does not exist: {}", - input_path.display() - ))); +fn decode_auxiliary_alpha_avif_item_candidate<'a>( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + meta: &isobmff::MetaBox<'a>, + resolved: &isobmff::ResolvedPrimaryItemGraph<'a>, + item_id: u32, +) -> Option { + let item_info = resolved + .iinf + .entries + .iter() + .find(|entry| entry.item_id == item_id)?; + let item_type = item_info.item_type?; + if item_type.as_bytes() != AV01_ITEM_TYPE { + return None; } - // Cheap caller-facing gate: only HEIF/HEIC extensions participate in EXIF - // orientation handling. AVIF and unknown extensions short-circuit. - if !path_extension_is_heif(input_path) { - return Ok(ExifOrientationHint { - exif_orientation: None, - primary_item_has_orientation_transform: false, - }); + let location = resolved + .iloc + .items + .iter() + .find(|item| item.item_id == item_id)?; + if location.data_reference_index != 0 { + return None; } - let mut source = FileSource::open(input_path).map_err(decode_error_from_source_read_error)?; - let selected = - read_selected_top_level_boxes_from_source(&mut source, &[FTYP_BOX_TYPE, META_BOX_TYPE])?; - let source_family_hint = detect_input_family_from_source_selected_boxes(&selected)?; - if source_family_hint != Some(HeifInputFamily::Heif) { - return Ok(ExifOrientationHint { - exif_orientation: None, - primary_item_has_orientation_transform: false, - }); + let properties = resolved_item_properties_for_item(resolved, item_id)?; + if !properties + .iter() + .any(property_is_alpha_auxiliary_type_property) + { + return None; } - let input = encode_source_selected_top_level_boxes(&selected); - let primary_item_has_orientation_transform = - isobmff::parse_primary_item_transform_properties(&input) - .map(|transforms| transforms_include_orientation(&transforms.transforms)) - .unwrap_or(false); + let av1c = properties + .iter() + .find(|property| property.header.box_type.as_bytes() == AV1C_PROPERTY_TYPE)? + .parse_av1c() + .ok()?; + let payload = extract_heic_item_payload_with_source(input, source, meta, location)?; + let mut elementary_stream = av1c.config_obus; + elementary_stream.extend_from_slice(&payload); - let mut source_handle: Option<&mut dyn RandomAccessSource> = Some(&mut source); - let exif_orientation = primary_exif_orientation_from_heif(&input, &mut source_handle) - .and_then(|orientation| u8::try_from(orientation).ok()) - .filter(|orientation| (1..=8).contains(orientation)); + let decoded = decode_av1_bitstream_to_image(&elementary_stream).ok()?; + let expected_alpha_samples = sample_count(decoded.width, decoded.height, "alpha").ok()?; + let alpha_samples = decoded.y_plane.samples; + let actual_alpha_samples = match &alpha_samples { + AvifPlaneSamples::U8(samples) => samples.len(), + AvifPlaneSamples::U16(samples) => samples.len(), + }; + if actual_alpha_samples != expected_alpha_samples { + return None; + } - Ok(ExifOrientationHint { - exif_orientation, - primary_item_has_orientation_transform, + Some(AvifAuxiliaryAlphaPlane { + width: decoded.width, + height: decoded.height, + bit_depth: decoded.bit_depth, + samples: alpha_samples, }) } -fn transforms_include_orientation(transforms: &[isobmff::PrimaryItemTransformProperty]) -> bool { - transforms.iter().any(|transform| { - matches!( - transform, - isobmff::PrimaryItemTransformProperty::Rotation(rotation) - if rotation.rotation_ccw_degrees % 360 != 0 - ) || matches!(transform, isobmff::PrimaryItemTransformProperty::Mirror(_)) - }) +#[derive(Clone, Debug, Eq, PartialEq)] +struct HeicAuxiliaryAlphaPlane { + width: u32, + height: u32, + bit_depth: u8, + samples: Vec, } -fn primary_exif_orientation_from_heif( +fn decode_primary_heic_auxiliary_alpha_plane_internal( input: &[u8], source: &mut Option<&mut dyn RandomAccessSource>, -) -> Option { + expected_width: u32, + expected_height: u32, +) -> Option { + // Provenance: mirrors libheif auxiliary alpha linkage in + // libheif/libheif/context.cc (auxl reference direction, auxC alpha-type + // filtering) and auxC payload parsing in libheif/libheif/box.cc:Box_auxC::parse. let top_level = isobmff::parse_boxes(input).ok()?; let meta_box = find_first_box_by_type(&top_level, META_BOX_TYPE)?; let meta = meta_box.parse_meta().ok()?; @@ -5105,706 +7179,1576 @@ fn primary_exif_orientation_from_heif( let primary_item_id = resolved.primary_item.item_id; for reference in &iref.references { - if reference.reference_type.as_bytes() != CDSC_REFERENCE_TYPE { + if reference.reference_type.as_bytes() != AUXL_REFERENCE_TYPE { continue; } if !reference.to_item_ids.contains(&primary_item_id) { continue; } - let item_id = reference.from_item_id; - let Some(item_info) = resolved - .iinf - .entries - .iter() - .find(|entry| entry.item_id == item_id) - else { + let Some(alpha_plane) = decode_auxiliary_alpha_item_candidate( + input, + source, + &meta, + &resolved, + reference.from_item_id, + ) else { continue; }; - if !item_info_is_exif_candidate(item_info) { + + if alpha_plane.width != expected_width || alpha_plane.height != expected_height { continue; } + return Some(alpha_plane); + } + + None +} + +fn decode_auxiliary_alpha_item_candidate<'a>( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + meta: &isobmff::MetaBox<'a>, + resolved: &isobmff::ResolvedPrimaryItemGraph<'a>, + item_id: u32, +) -> Option { + let item_info = resolved + .iinf + .entries + .iter() + .find(|entry| entry.item_id == item_id)?; + let item_type = item_info.item_type?; + if item_type.as_bytes() != HVC1_ITEM_TYPE && item_type.as_bytes() != HEV1_ITEM_TYPE { + return None; + } + + let location = resolved + .iloc + .items + .iter() + .find(|item| item.item_id == item_id)?; + if location.data_reference_index != 0 { + return None; + } + + let properties = resolved_item_properties_for_item(resolved, item_id)?; + if !properties + .iter() + .any(property_is_alpha_auxiliary_type_property) + { + return None; + } + + let hvcc = properties + .iter() + .find(|property| property.header.box_type.as_bytes() == HVCC_PROPERTY_TYPE)? + .parse_hvcc() + .ok()?; + + let payload = extract_heic_item_payload_with_source(input, source, meta, location)?; + let stream = assemble_heic_hevc_stream_from_components(&hvcc, &payload).ok()?; + let decoded = decode_hevc_stream_to_image(&stream).ok()?; + let expected_alpha_samples = heic_sample_count(decoded.width, decoded.height, "alpha").ok()?; + if decoded.y_plane.samples.len() != expected_alpha_samples { + return None; + } + + Some(HeicAuxiliaryAlphaPlane { + width: decoded.width, + height: decoded.height, + bit_depth: decoded.bit_depth_luma, + samples: decoded.y_plane.samples, + }) +} + +fn resolved_item_properties_for_item<'a>( + resolved: &isobmff::ResolvedPrimaryItemGraph<'a>, + item_id: u32, +) -> Option>> { + let mut flattened_properties = Vec::new(); + for container in &resolved.iprp.property_containers { + flattened_properties.extend(container.properties.iter().cloned()); + } + + let mut properties = Vec::new(); + for association_box in &resolved.iprp.associations { + for entry in &association_box.entries { + if entry.item_id != item_id { + continue; + } + + for association in &entry.associations { + if association.property_index == 0 { + continue; + } + let property_index = usize::from(association.property_index - 1); + let property = flattened_properties.get(property_index)?.clone(); + properties.push(property); + } + } + } + + Some(properties) +} + +fn property_is_alpha_auxiliary_type_property(property: &isobmff::ParsedBox<'_>) -> bool { + if property.header.box_type.as_bytes() != AUXC_PROPERTY_TYPE { + return false; + } + if property.payload.len() < 4 { + return false; + } + if property.payload[0] != 0 { + return false; + } + + let aux_payload = &property.payload[4..]; + let aux_type_end = aux_payload + .iter() + .position(|byte| *byte == 0) + .unwrap_or(aux_payload.len()); + let aux_type = &aux_payload[..aux_type_end]; + ALPHA_AUX_TYPES.contains(&aux_type) +} + +fn extract_heic_item_payload_with_source( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + meta: &isobmff::MetaBox<'_>, + location: &isobmff::ItemLocationItem, +) -> Option> { + let total_length = location + .extents + .iter() + .try_fold(0_u64, |acc, extent| acc.checked_add(extent.length))?; + let payload_capacity = usize::try_from(total_length).ok()?; + let mut payload = Vec::with_capacity(payload_capacity); - let Some(location) = resolved - .iloc - .items - .iter() - .find(|item| item.item_id == item_id) - else { - continue; - }; - if location.data_reference_index != 0 { - continue; + match location.construction_method { + 0 => { + if let Some(source) = source.as_mut() { + append_heic_item_location_extents_from_source(*source, location, &mut payload)?; + } else { + append_heic_item_location_extents(input, location, &mut payload)?; + } } - - let Some(payload) = extract_heic_item_payload_with_source(input, source, &meta, location) - else { - continue; - }; - let Some(orientation) = parse_exif_orientation_from_item_payload(&payload) else { - continue; - }; - if (1..=8).contains(&orientation) { - return Some(orientation); + 1 => { + let children = meta.parse_children().ok()?; + let idat_box = find_first_box_by_type(&children, IDAT_BOX_TYPE)?; + append_heic_item_location_extents(idat_box.payload, location, &mut payload)?; } + _ => return None, } - None + Some(payload) } -fn item_info_is_exif_candidate(item_info: &isobmff::ItemInfoEntryBox) -> bool { - let Some(item_type) = item_info.item_type else { - return false; - }; - if item_type.as_bytes() == EXIF_ITEM_TYPE { - return true; - } - if item_type.as_bytes() != MIME_ITEM_TYPE { - return false; +fn append_heic_item_location_extents( + source: &[u8], + location: &isobmff::ItemLocationItem, + output: &mut Vec, +) -> Option<()> { + let available = source.len() as u64; + for extent in &location.extents { + let start = location.base_offset.checked_add(extent.offset)?; + let end = start.checked_add(extent.length)?; + if end > available { + return None; + } + + let start = usize::try_from(start).ok()?; + let end = usize::try_from(end).ok()?; + output.extend_from_slice(&source[start..end]); } + Some(()) +} - if bytes_eq_ignore_ascii_case(&item_info.item_name, b"Exif") { - return true; +fn append_heic_item_location_extents_from_source( + source: &mut dyn RandomAccessSource, + location: &isobmff::ItemLocationItem, + output: &mut Vec, +) -> Option<()> { + for extent in &location.extents { + let start = location.base_offset.checked_add(extent.offset)?; + start.checked_add(extent.length)?; + let extent_len = usize::try_from(extent.length).ok()?; + let output_start = output.len(); + let output_end = output_start.checked_add(extent_len)?; + output.resize(output_end, 0); + if source + .read_exact_at(start, &mut output[output_start..output_end]) + .is_err() + { + output.truncate(output_start); + return None; + } } - let Some(content_type) = item_info.content_type.as_deref() else { - return false; - }; - bytes_eq_ignore_ascii_case(content_type, EXIF_CONTENT_TYPE_APPLICATION_EXIF) - || bytes_eq_ignore_ascii_case(content_type, EXIF_CONTENT_TYPE_IMAGE_TIFF) + Some(()) } -fn bytes_eq_ignore_ascii_case(lhs: &[u8], rhs: &[u8]) -> bool { - lhs.len() == rhs.len() - && lhs - .iter() - .zip(rhs) - .all(|(left, right)| left.eq_ignore_ascii_case(right)) +fn find_first_box_by_type<'a, 'b>( + boxes: &'b [isobmff::ParsedBox<'a>], + box_type: [u8; 4], +) -> Option<&'b isobmff::ParsedBox<'a>> { + boxes + .iter() + .find(|child| child.header.box_type.as_bytes() == box_type) } -fn exif_orientation_to_primary_item_transforms( - orientation: u16, -) -> Option> { - use isobmff::{ - ImageMirrorDirection, ImageMirrorProperty, ImageRotationProperty, - PrimaryItemTransformProperty, - }; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HeifInputFamily { + Avif, + Heif, +} - let mirror_horizontal = || { - PrimaryItemTransformProperty::Mirror(ImageMirrorProperty { - direction: ImageMirrorDirection::Horizontal, - }) - }; - let mirror_vertical = || { - PrimaryItemTransformProperty::Mirror(ImageMirrorProperty { - direction: ImageMirrorDirection::Vertical, - }) - }; - let rotate_ccw = |rotation_ccw_degrees| { - PrimaryItemTransformProperty::Rotation(ImageRotationProperty { - rotation_ccw_degrees, - }) - }; +const AVIF_FILE_BRANDS: [[u8; 4]; 2] = [*b"avif", *b"avis"]; +const HEIF_FILE_BRANDS: [[u8; 4]; 9] = [ + *b"mif1", *b"msf1", *b"miaf", *b"heic", *b"heix", *b"hevc", *b"hevx", *b"heim", *b"heis", +]; - match orientation { - 1 => Some(Vec::new()), - 2 => Some(vec![mirror_horizontal()]), - 3 => Some(vec![rotate_ccw(180)]), - 4 => Some(vec![mirror_vertical()]), - 5 => Some(vec![mirror_horizontal(), rotate_ccw(90)]), - 6 => Some(vec![rotate_ccw(270)]), - 7 => Some(vec![mirror_horizontal(), rotate_ccw(270)]), - 8 => Some(vec![rotate_ccw(90)]), - _ => None, - } +fn decode_avif_bytes_to_rgba( + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + let (meta, resolved) = isobmff::resolve_primary_avif_item_graph(input) + .map_err(DecodeAvifError::ExtractPrimaryPayload)?; + let mut source: Option<&mut dyn RandomAccessSource> = None; + decode_avif_to_rgba_from_resolved_graph(input, &mut source, &meta, &resolved, guardrails) } -fn parse_exif_orientation_from_item_payload(payload: &[u8]) -> Option { - let mut candidates = Vec::new(); - if payload.len() >= 4 { - let tiff_offset = u32::from_be_bytes(payload[0..4].try_into().ok()?); - let tiff_offset = usize::try_from(tiff_offset).ok()?; - let tiff_start = 4_usize.checked_add(tiff_offset)?; - candidates.push(tiff_start); - } - if let Some(exif_header_start) = find_subslice(payload, EXIF_HEADER) { - let tiff_start = exif_header_start.checked_add(EXIF_HEADER.len())?; - if !candidates.contains(&tiff_start) { - candidates.push(tiff_start); +fn decode_avif_to_rgba_from_resolved_graph( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + meta: &isobmff::MetaBox<'_>, + resolved: &isobmff::ResolvedPrimaryItemGraph<'_>, + guardrails: DecodeGuardrails, +) -> Result { + let transforms = isobmff::parse_primary_item_transform_properties_from_resolved_graph(resolved) + .map_err(DecodeAvifError::ParsePrimaryTransforms)?; + let icc_profile = primary_icc_profile_from_resolved_avif_graph(resolved); + let decoded = decode_primary_avif_to_image_from_resolved_graph(input, source, meta, resolved)?; + guardrails.enforce_pixel_count(decoded.width, decoded.height)?; + decoded_avif_to_rgba_image(&decoded, &transforms.transforms, icc_profile) +} + +fn decode_avif_source_to_rgba( + source: &mut S, + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + let (meta, resolved) = isobmff::resolve_primary_avif_item_graph(input) + .map_err(DecodeAvifError::ExtractPrimaryPayload)?; + let mut source: Option<&mut dyn RandomAccessSource> = Some(source); + decode_avif_to_rgba_from_resolved_graph(input, &mut source, &meta, &resolved, guardrails) +} + +fn decode_heif_bytes_to_rgba( + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + match decode_primary_uncompressed_to_image(input) { + Ok(decoded) => { + guardrails.enforce_pixel_count(decoded.width, decoded.height)?; + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeUncompressedError::ParsePrimaryTransforms)? + .transforms; + return decoded_uncompressed_to_rgba_image(decoded, &transforms); } + Err(DecodeUncompressedError::ParsePrimaryProperties( + isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. }, + )) => {} + Err(err) => return Err(err.into()), } - if !candidates.contains(&0) { - candidates.push(0); + + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeHeicError::ParsePrimaryTransforms)? + .transforms; + let icc_profile = primary_icc_profile_from_heic(input); + let mut source: Option<&mut dyn RandomAccessSource> = None; + decode_primary_heic_to_rgba_from_resolved_input( + input, + &mut source, + guardrails, + &transforms, + icc_profile, + ) +} + +fn decode_heif_source_to_rgba( + source: &mut S, + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + let mut source: Option<&mut dyn RandomAccessSource> = Some(source); + match decode_primary_uncompressed_to_image_internal(input, &mut source) { + Ok(decoded) => { + guardrails.enforce_pixel_count(decoded.width, decoded.height)?; + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeUncompressedError::ParsePrimaryTransforms)? + .transforms; + return decoded_uncompressed_to_rgba_image(decoded, &transforms); + } + Err(DecodeUncompressedError::ParsePrimaryProperties( + isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. }, + )) => {} + Err(err) => return Err(err.into()), } - for tiff_start in candidates { - let Some(orientation) = parse_exif_orientation_from_tiff(payload, tiff_start) else { - continue; - }; - if (1..=8).contains(&orientation) { - return Some(orientation); + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeHeicError::ParsePrimaryTransforms)? + .transforms; + let icc_profile = primary_icc_profile_from_heic(input); + decode_primary_heic_to_rgba_from_resolved_input( + input, + &mut source, + guardrails, + &transforms, + icc_profile, + ) +} + +fn decode_primary_heic_to_rgba_from_resolved_input( + input: &[u8], + source: &mut Option<&mut dyn RandomAccessSource>, + guardrails: DecodeGuardrails, + transforms: &[isobmff::PrimaryItemTransformProperty], + icc_profile: Option>, +) -> Result { + let primary_with_grid = if let Some(source) = source.as_mut() { + isobmff::extract_primary_heic_item_data_with_grid_from_source(source, input) + .map_err(DecodeHeicError::from)? + } else { + isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)? + }; + + match primary_with_grid { + isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { + let auxiliary_alpha = + decode_primary_heic_grid_auxiliary_alpha(input, source, &grid_data, &guardrails)?; + decode_primary_heic_grid_to_rgba_image( + &grid_data, + transforms, + auxiliary_alpha.as_ref(), + icc_profile, + ) + } + isobmff::HeicPrimaryItemDataWithGrid::Coded(item_data) => { + let (decoded, auxiliary_alpha) = + decode_primary_heic_coded_item_with_alpha(input, source, &item_data, &guardrails)?; + decoded_heic_to_rgba_image(decoded, transforms, auxiliary_alpha.as_ref(), icc_profile) } } - None } -fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - if needle.is_empty() || haystack.len() < needle.len() { - return None; +#[cfg(feature = "image-integration")] +fn heic_bit_depth_for_png_conversion_metadata( + metadata: &DecodedHeicImageMetadata, +) -> Result { + if metadata.bit_depth_luma != metadata.bit_depth_chroma { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "HEIC luma/chroma bit-depth mismatch during PNG conversion: {}/{}", + metadata.bit_depth_luma, metadata.bit_depth_chroma + ), + }); } - haystack - .windows(needle.len()) - .position(|window| window == needle) -} -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TiffByteOrder { - LittleEndian, - BigEndian, + if metadata.bit_depth_luma == 0 || metadata.bit_depth_luma > 16 { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "HEIC bit depth {} is outside supported PNG conversion range 1..=16", + metadata.bit_depth_luma + ), + }); + } + + Ok(metadata.bit_depth_luma) } -fn parse_exif_orientation_from_tiff(payload: &[u8], tiff_start: usize) -> Option { - let byte_order = payload.get(tiff_start..tiff_start.checked_add(2)?)?; - let byte_order = match byte_order { - b"II" => TiffByteOrder::LittleEndian, - b"MM" => TiffByteOrder::BigEndian, - _ => return None, - }; +#[cfg(feature = "image-integration")] +fn heic_grid_source_bit_depth_for_png_conversion( + grid_data: &isobmff::HeicGridPrimaryItemData, +) -> Result { + let first_tile = + grid_data + .tiles + .first() + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile list cannot be empty".to_string(), + })?; + let metadata = + decode_hevc_metadata_from_hvcc_or_payload(&first_tile.hvcc, &first_tile.payload)?; + heic_bit_depth_for_png_conversion_metadata(&metadata) +} - let magic = read_tiff_u16(payload, tiff_start.checked_add(2)?, byte_order)?; - if magic != TIFF_MAGIC_NUMBER { - return None; - } +/// RGBA storage width (8 or 16 bits per sample) for a HEIC source bit depth. +#[cfg(feature = "image-integration")] +fn heic_storage_bit_depth(source_bit_depth: u8) -> u8 { + if source_bit_depth <= 8 { 8 } else { 16 } +} - let first_ifd_offset = read_tiff_u32(payload, tiff_start.checked_add(4)?, byte_order)?; - let first_ifd_offset = usize::try_from(first_ifd_offset).ok()?; - let first_ifd = tiff_start.checked_add(first_ifd_offset)?; - parse_exif_orientation_from_ifd(payload, tiff_start, first_ifd, byte_order) +#[cfg(feature = "image-integration")] +fn decoded_rgba_layout_from_heic_geometry( + width: u32, + height: u32, + source_bit_depth: u8, + transforms: &[isobmff::PrimaryItemTransformProperty], + icc_profile: Option>, +) -> Result { + let (width, height) = transformed_rgba_dimensions(width, height, transforms)?; + Ok(DecodedRgbaLayout { + width, + height, + source_bit_depth, + storage_bit_depth: heic_storage_bit_depth(source_bit_depth), + icc_profile, + }) } -fn parse_exif_orientation_from_ifd( - payload: &[u8], - tiff_start: usize, - ifd_offset: usize, - byte_order: TiffByteOrder, -) -> Option { - let entry_count = usize::from(read_tiff_u16(payload, ifd_offset, byte_order)?); - let entries_start = ifd_offset.checked_add(2)?; +/// Layout-probe result: the advertised layout plus any primary-item payload +/// extraction the probe had to perform to compute it. Returning the +/// extraction lets the deferred pixel decode reuse it instead of copying +/// every payload out of the container a second time. +#[cfg(feature = "image-integration")] +pub(crate) struct RgbaLayoutProbe { + pub(crate) layout: DecodedRgbaLayout, + pub(crate) preextracted_heic: Option, +} - for entry_index in 0..entry_count { - let entry_offset = entries_start.checked_add(entry_index.checked_mul(12)?)?; - let tag = read_tiff_u16(payload, entry_offset, byte_order)?; - if tag != EXIF_ORIENTATION_TAG { - continue; +#[cfg(feature = "image-integration")] +impl RgbaLayoutProbe { + fn without_extraction(layout: DecodedRgbaLayout) -> Self { + Self { + layout, + preextracted_heic: None, } + } +} - let field_type = read_tiff_u16(payload, entry_offset.checked_add(2)?, byte_order)?; - let value_count = read_tiff_u32(payload, entry_offset.checked_add(4)?, byte_order)?; - if field_type != TIFF_TAG_TYPE_SHORT || value_count == 0 { - continue; +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba_layout( + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + match isobmff::parse_primary_uncompressed_item_properties(input) { + Ok(properties) => { + guardrails.enforce_pixel_count(properties.ispe.width, properties.ispe.height)?; + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeUncompressedError::ParsePrimaryTransforms)? + .transforms; + let source_bit_depth = uncompressed_output_bit_depth_from_properties(&properties)?; + return decoded_rgba_layout_from_heic_geometry( + properties.ispe.width, + properties.ispe.height, + source_bit_depth, + &transforms, + icc_profile_from_color_properties(&properties.colr), + ) + .map(RgbaLayoutProbe::without_extraction); } + Err(isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { + .. + }) => {} + Err(err) => return Err(DecodeUncompressedError::ParsePrimaryProperties(err).into()), + } - let orientation = if value_count == 1 { - read_tiff_u16(payload, entry_offset.checked_add(8)?, byte_order)? - } else { - let value_offset = read_tiff_u32(payload, entry_offset.checked_add(8)?, byte_order)?; - let value_offset = usize::try_from(value_offset).ok()?; - let value_position = tiff_start.checked_add(value_offset)?; - read_tiff_u16(payload, value_position, byte_order)? - }; + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeHeicError::ParsePrimaryTransforms)? + .transforms; + let icc_profile = primary_icc_profile_from_heic(input); - if (1..=8).contains(&orientation) { - return Some(orientation); - } + // Coded (hvc1/hev1) primary items normally carry the SPS in their hvcC + // property, so the probe can read geometry and bit depth without + // extracting a single payload byte. On any miss — grid primary, hev1 + // with in-stream parameter sets, or a preflight/SPS/geometry problem — + // fall through to the extraction path below so errors stay identical to + // the decode path's. + if let Ok(preflight) = isobmff::parse_primary_heic_item_preflight_properties(input) + && let Ok(Some(metadata)) = hevc_metadata_from_hvcc_nal_arrays(&preflight.hvcc) + && validate_decoded_heic_geometry_against_ispe( + &metadata, + preflight.ispe.width, + preflight.ispe.height, + ) + .is_ok() + { + guardrails.enforce_pixel_count(metadata.width, metadata.height)?; + let source_bit_depth = heic_bit_depth_for_png_conversion_metadata(&metadata)?; + return decoded_rgba_layout_from_heic_geometry( + metadata.width, + metadata.height, + source_bit_depth, + &transforms, + icc_profile, + ) + .map(RgbaLayoutProbe::without_extraction); } - None -} + let primary_with_grid = + isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)?; -fn read_tiff_u16(payload: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option { - let bytes = payload.get(offset..offset.checked_add(2)?)?; - let bytes: [u8; 2] = bytes.try_into().ok()?; - Some(match byte_order { - TiffByteOrder::LittleEndian => u16::from_le_bytes(bytes), - TiffByteOrder::BigEndian => u16::from_be_bytes(bytes), + let layout = match &primary_with_grid { + isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { + guardrails.enforce_pixel_count( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )?; + let source_bit_depth = heic_grid_source_bit_depth_for_png_conversion(grid_data)?; + decoded_rgba_layout_from_heic_geometry( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + source_bit_depth, + &transforms, + icc_profile, + )? + } + isobmff::HeicPrimaryItemDataWithGrid::Coded(item_data) => { + // Same preflight and geometry validations as the decode path, + // but read the SPS by walking the hvcC arrays / payload NALs in + // place instead of assembling (and dropping) a normalized copy + // of the whole coded payload. + let properties = parse_and_validate_heic_coded_item_preflight(input, item_data)?; + let metadata = + decode_hevc_metadata_from_hvcc_or_payload(&properties.hvcc, &item_data.payload)?; + validate_decoded_heic_geometry_against_ispe( + &metadata, + properties.ispe.width, + properties.ispe.height, + )?; + guardrails.enforce_pixel_count(metadata.width, metadata.height)?; + let source_bit_depth = heic_bit_depth_for_png_conversion_metadata(&metadata)?; + decoded_rgba_layout_from_heic_geometry( + metadata.width, + metadata.height, + source_bit_depth, + &transforms, + icc_profile, + )? + } + }; + Ok(RgbaLayoutProbe { + layout, + preextracted_heic: Some(primary_with_grid), }) } -fn read_tiff_u32(payload: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option { - let bytes = payload.get(offset..offset.checked_add(4)?)?; - let bytes: [u8; 4] = bytes.try_into().ok()?; - Some(match byte_order { - TiffByteOrder::LittleEndian => u32::from_le_bytes(bytes), - TiffByteOrder::BigEndian => u32::from_be_bytes(bytes), +#[cfg(feature = "image-integration")] +fn decode_avif_bytes_to_rgba_layout( + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + let (meta, resolved) = isobmff::resolve_primary_avif_item_graph(input) + .map_err(DecodeAvifError::ExtractPrimaryPayload)?; + let properties = + isobmff::parse_primary_avif_item_preflight_properties_from_resolved_graph(&resolved) + .map_err(DecodeAvifError::ParsePrimaryProperties)?; + let transforms = + isobmff::parse_primary_item_transform_properties_from_resolved_graph(&resolved) + .map_err(DecodeAvifError::ParsePrimaryTransforms)?; + guardrails.enforce_pixel_count(properties.ispe.width, properties.ispe.height)?; + + let source_bit_depth = + avif_probe_source_bit_depth(input, &meta, &resolved, &properties.av1c.config_obus)?; + let (width, height) = transformed_rgba_dimensions( + properties.ispe.width, + properties.ispe.height, + &transforms.transforms, + )?; + Ok(DecodedRgbaLayout { + width, + height, + source_bit_depth, + storage_bit_depth: heic_storage_bit_depth(source_bit_depth), + icc_profile: primary_icc_profile_from_resolved_avif_graph(&resolved), }) } -fn decode_primary_avif_auxiliary_alpha_plane( - input: &[u8], - source: &mut Option<&mut dyn RandomAccessSource>, - meta: &isobmff::MetaBox<'_>, - resolved: &isobmff::ResolvedPrimaryItemGraph<'_>, - expected_width: u32, - expected_height: u32, -) -> Option { - // Provenance: mirrors libheif auxiliary alpha linkage in - // libheif/libheif/context.cc (`auxl` reference direction and aux-type - // filtering) and ImageItem alpha composition flow in - // libheif/libheif/image-items/image_item.cc (`decode_image`). - let iref = resolved.iref.as_ref()?; - let primary_item_id = resolved.primary_item.item_id; - - for reference in &iref.references { - if reference.reference_type.as_bytes() != AUXL_REFERENCE_TYPE { - continue; - } - if !reference.to_item_ids.contains(&primary_item_id) { - continue; - } - - let Some(alpha_plane) = decode_auxiliary_alpha_avif_item_candidate( - input, - source, - meta, - resolved, - reference.from_item_id, - ) else { - continue; - }; - if alpha_plane.width != expected_width || alpha_plane.height != expected_height { - continue; - } - return Some(alpha_plane); - } - - None +#[cfg(feature = "image-integration")] +trait RgbaSampleOutput { + fn sample_len(&self) -> usize; + fn write_sample(&mut self, index: usize, sample: T); } -fn decode_auxiliary_alpha_avif_item_candidate<'a>( - input: &[u8], - source: &mut Option<&mut dyn RandomAccessSource>, - meta: &isobmff::MetaBox<'a>, - resolved: &isobmff::ResolvedPrimaryItemGraph<'a>, - item_id: u32, -) -> Option { - let item_info = resolved - .iinf - .entries - .iter() - .find(|entry| entry.item_id == item_id)?; - let item_type = item_info.item_type?; - if item_type.as_bytes() != AV01_ITEM_TYPE { - return None; - } +#[cfg(feature = "image-integration")] +struct SliceRgbaOutput<'a, T>(&'a mut [T]); - let location = resolved - .iloc - .items - .iter() - .find(|item| item.item_id == item_id)?; - if location.data_reference_index != 0 { - return None; +#[cfg(feature = "image-integration")] +impl RgbaSampleOutput for SliceRgbaOutput<'_, T> { + fn sample_len(&self) -> usize { + self.0.len() } - let properties = resolved_item_properties_for_item(resolved, item_id)?; - if !properties - .iter() - .any(property_is_alpha_auxiliary_type_property) - { - return None; + fn write_sample(&mut self, index: usize, sample: T) { + self.0[index] = sample; } +} - let av1c = properties - .iter() - .find(|property| property.header.box_type.as_bytes() == AV1C_PROPERTY_TYPE)? - .parse_av1c() - .ok()?; - let payload = extract_heic_item_payload_with_source(input, source, meta, location)?; - let mut elementary_stream = av1c.config_obus; - elementary_stream.extend_from_slice(&payload); - - let decoded = decode_av1_bitstream_to_image(&elementary_stream).ok()?; - let expected_alpha_samples = sample_count(decoded.width, decoded.height, "alpha").ok()?; - let alpha_samples = decoded.y_plane.samples; - let actual_alpha_samples = match &alpha_samples { - AvifPlaneSamples::U8(samples) => samples.len(), - AvifPlaneSamples::U16(samples) => samples.len(), - }; - if actual_alpha_samples != expected_alpha_samples { - return None; - } +#[cfg(feature = "image-integration")] +struct NativeEndianRgba16Output<'a>(&'a mut [u8]); - Some(AvifAuxiliaryAlphaPlane { - width: decoded.width, - height: decoded.height, - bit_depth: decoded.bit_depth, - samples: alpha_samples, - }) +#[cfg(feature = "image-integration")] +impl RgbaSampleOutput for NativeEndianRgba16Output<'_> { + fn sample_len(&self) -> usize { + self.0.len() / std::mem::size_of::() + } + + fn write_sample(&mut self, index: usize, sample: u16) { + let byte_index = index * std::mem::size_of::(); + self.0[byte_index..byte_index + 2].copy_from_slice(&sample.to_ne_bytes()); + } } -#[derive(Clone, Debug, Eq, PartialEq)] -struct HeicAuxiliaryAlphaPlane { - width: u32, - height: u32, - bit_depth: u8, - samples: Vec, +#[cfg(feature = "image-integration")] +fn decode_primary_heic_grid_to_rgba8_slice( + grid_data: &isobmff::HeicGridPrimaryItemData, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [u8], +) -> Result<(), DecodeError> { + let mut output = SliceRgbaOutput(out); + decode_primary_heic_grid_to_rgba_output( + grid_data, + transforms, + auxiliary_alpha, + &mut output, + 8, + "HEIC grid RGBA8 image adapter output", + convert_heic_to_rgba8_into, + scale_sample_to_u8, + ) } -fn decode_primary_heic_auxiliary_alpha_plane( - input: &[u8], - expected_width: u32, - expected_height: u32, -) -> Option { - let mut source: Option<&mut dyn RandomAccessSource> = None; - decode_primary_heic_auxiliary_alpha_plane_internal( - input, - &mut source, - expected_width, - expected_height, +#[cfg(feature = "image-integration")] +#[allow(clippy::too_many_arguments)] +fn decode_primary_heic_grid_to_rgba16_native_endian_bytes( + grid_data: &isobmff::HeicGridPrimaryItemData, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [u8], +) -> Result<(), DecodeError> { + let mut output = NativeEndianRgba16Output(out); + decode_primary_heic_grid_to_rgba_output( + grid_data, + transforms, + auxiliary_alpha, + &mut output, + 16, + "HEIC grid RGBA16 image adapter byte output", + convert_heic_to_rgba16_into, + scale_sample_to_u16, ) } -fn decode_primary_heic_auxiliary_alpha_plane_internal( - input: &[u8], - source: &mut Option<&mut dyn RandomAccessSource>, - expected_width: u32, - expected_height: u32, -) -> Option { - // Provenance: mirrors libheif auxiliary alpha linkage in - // libheif/libheif/context.cc (auxl reference direction, auxC alpha-type - // filtering) and auxC payload parsing in libheif/libheif/box.cc:Box_auxC::parse. - let top_level = isobmff::parse_boxes(input).ok()?; - let meta_box = find_first_box_by_type(&top_level, META_BOX_TYPE)?; - let meta = meta_box.parse_meta().ok()?; - let resolved = meta.resolve_primary_item().ok()?; - let iref = resolved.iref.as_ref()?; - let primary_item_id = resolved.primary_item.item_id; +#[cfg(feature = "image-integration")] +#[allow(clippy::too_many_arguments)] +fn decode_primary_heic_grid_to_rgba_output>( + grid_data: &isobmff::HeicGridPrimaryItemData, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut O, + storage_bit_depth: u8, + sample_count_stage: &'static str, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, + scale_alpha: fn(u16, u8) -> T, +) -> Result<(), DecodeError> { + validate_heic_grid_descriptor_and_tile_count(grid_data)?; + let (first_tile, source_bit_depth) = decode_and_validate_heic_grid_first_tile(grid_data)?; + let source_storage_bit_depth = heic_storage_bit_depth(source_bit_depth); + if source_storage_bit_depth != storage_bit_depth { + return Err(DecodeError::Unsupported(format!( + "HEIC grid storage is RGBA{source_storage_bit_depth}, not RGBA{storage_bit_depth}" + ))); + } - for reference in &iref.references { - if reference.reference_type.as_bytes() != AUXL_REFERENCE_TYPE { - continue; + let transform_plan = RgbaTransformPlan::from_primary_transforms( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + transforms, + )?; + let expected = checked_rgba_sample_count( + transform_plan.destination_width, + transform_plan.destination_height, + )?; + if out.sample_len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: sample_count_stage, + actual: out.sample_len(), + expected, + width: transform_plan.destination_width, + height: transform_plan.destination_height, + }, + )); + } + + let reference = HeicGridTileReference::from_first_tile(&first_tile, &grid_data.colr); + // The paste loop indexes the alpha plane's samples directly, so validate + // it up front regardless of tile coverage. + if let Some(alpha) = auxiliary_alpha { + validate_auxiliary_alpha_plane( + alpha, + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )?; + } + // When the tiles cover the whole descriptor, the paste below rewrites + // every destination sample (alpha included), so the caller's buffer — + // while not guaranteed to be pre-cleared — needs no seeding. Otherwise + // match the owned grid path (and libheif's zero-filled YUV canvas) for + // descriptor pixels clipped tiles do not cover: the opaque + // converted-zero-YUV color (not transparent black), with the auxiliary + // alpha plane applied across the whole canvas. + if !heic_grid_tiles_cover_descriptor(&grid_data.descriptor, &reference) { + let gap_pixel = heic_grid_gap_rgba_pixel(&reference, convert_tile)?; + let mut sample_index = 0; + while sample_index < expected { + out.write_sample(sample_index, gap_pixel[0]); + out.write_sample(sample_index + 1, gap_pixel[1]); + out.write_sample(sample_index + 2, gap_pixel[2]); + out.write_sample(sample_index + 3, gap_pixel[3]); + sample_index += 4; } - if !reference.to_item_ids.contains(&primary_item_id) { - continue; + if let Some(alpha) = auxiliary_alpha { + let source_width = + usize::try_from(grid_data.descriptor.output_width).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: "grid alpha width cannot be represented".to_string(), + } + })?; + let source_height = + usize::try_from(grid_data.descriptor.output_height).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: "grid alpha height cannot be represented".to_string(), + } + })?; + let destination_width = + usize::try_from(transform_plan.destination_width).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: "transformed grid alpha width cannot be represented".to_string(), + } + })?; + // The owned path applies the auxiliary plane to the whole + // gap-filled grid canvas, including any descriptor pixels not + // covered by tiles. Seed alpha for every transformed source pixel + // before tile RGB is pasted so the direct path preserves those + // gap pixels exactly. + for source_y in 0..source_height { + for source_x in 0..source_width { + let Some((destination_x, destination_y)) = + transform_plan.map_source_pixel(source_x, source_y)? + else { + continue; + }; + let source_index = source_y * source_width + source_x; + let destination_alpha_index = + (destination_y * destination_width + destination_x) * 4 + 3; + out.write_sample( + destination_alpha_index, + scale_alpha(alpha.samples[source_index], alpha.bit_depth), + ); + } + } } + } + paste_heic_grid_tiles_to_transformed_rgba_slice( + grid_data, + first_tile, + out, + &reference, + &transform_plan, + auxiliary_alpha, + convert_tile, + scale_alpha, + ) +} - let Some(alpha_plane) = decode_auxiliary_alpha_item_candidate( - input, - source, - &meta, - &resolved, - reference.from_item_id, - ) else { - continue; - }; - - if alpha_plane.width != expected_width || alpha_plane.height != expected_height { - continue; +#[cfg(feature = "image-integration")] +#[allow(clippy::too_many_arguments)] +fn paste_heic_grid_tiles_to_transformed_rgba_slice>( + grid_data: &isobmff::HeicGridPrimaryItemData, + first_tile: DecodedHeicImage, + output: &mut O, + reference: &HeicGridTileReference, + transform_plan: &RgbaTransformPlan, + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, + scale_alpha: fn(u16, u8) -> T, +) -> Result<(), DecodeError> { + let descriptor = &grid_data.descriptor; + let destination_width = usize::try_from(transform_plan.destination_width).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "transformed grid width does not fit in usize ({})", + transform_plan.destination_width + ), } - return Some(alpha_plane); - } + })?; + let source_width = usize::try_from(descriptor.output_width).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid output width does not fit in usize ({})", + descriptor.output_width + ), + } + })?; + let source_height = usize::try_from(descriptor.output_height).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "grid output height does not fit in usize ({})", + descriptor.output_height + ), + } + })?; + for_each_heic_grid_tile_rgba( + grid_data, + first_tile, + reference, + convert_tile, + |tile, tile_pixels, x_origin, y_origin| { + let tile_width = + usize::try_from(tile.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("grid tile width {} cannot be represented", tile.width), + })?; + let tile_height = + usize::try_from(tile.height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("grid tile height {} cannot be represented", tile.height), + })?; + validate_rgba_paste_buffer_len( + tile_pixels.len(), + tile_width, + tile_height, + tile.width, + tile.height, + "grid tile RGBA", + "source", + )?; - None + let x_origin = + usize::try_from(x_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile x-origin cannot be represented".to_string(), + })?; + let y_origin = + usize::try_from(y_origin).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile y-origin cannot be represented".to_string(), + })?; + + for tile_y in 0..tile_height { + let source_y = y_origin.checked_add(tile_y).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile source y-coordinate overflow".to_string(), + } + })?; + if source_y >= source_height { + break; + } + for tile_x in 0..tile_width { + let source_x = x_origin.checked_add(tile_x).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile source x-coordinate overflow".to_string(), + } + })?; + if source_x >= source_width { + break; + } + let Some((destination_x, destination_y)) = + transform_plan.map_source_pixel(source_x, source_y)? + else { + continue; + }; + // In-bounds by construction + // (`validate_rgba_paste_buffer_len` proved the tile sample + // count fits usize, the plan maps into the validated + // destination canvas), so plain indexing cannot overflow — + // same as the coded HEIC/AVIF per-pixel loops. + let source_sample = (tile_y * tile_width + tile_x) * 4; + let destination_sample = + (destination_y * destination_width + destination_x) * 4; + output.write_sample(destination_sample, tile_pixels[source_sample]); + output.write_sample(destination_sample + 1, tile_pixels[source_sample + 1]); + output.write_sample(destination_sample + 2, tile_pixels[source_sample + 2]); + output.write_sample( + destination_sample + 3, + auxiliary_alpha.map_or(tile_pixels[source_sample + 3], |alpha| { + let alpha_index = source_y * source_width + source_x; + scale_alpha(alpha.samples[alpha_index], alpha.bit_depth) + }), + ); + } + } + + Ok(()) + }, + ) } -fn decode_auxiliary_alpha_item_candidate<'a>( - input: &[u8], - source: &mut Option<&mut dyn RandomAccessSource>, - meta: &isobmff::MetaBox<'a>, - resolved: &isobmff::ResolvedPrimaryItemGraph<'a>, - item_id: u32, -) -> Option { - let item_info = resolved - .iinf - .entries - .iter() - .find(|entry| entry.item_id == item_id)?; - let item_type = item_info.item_type?; - if item_type.as_bytes() != HVC1_ITEM_TYPE && item_type.as_bytes() != HEV1_ITEM_TYPE { - return None; - } +#[cfg(feature = "image-integration")] +fn decoded_heic_to_rgba8_slice( + decoded: DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [u8], +) -> Result<(), DecodeError> { + let mut output = SliceRgbaOutput(out); + decoded_heic_to_rgba_output( + decoded, + transforms, + auxiliary_alpha, + &mut output, + 8, + scale_sample_to_u8, + u8::MAX, + ) +} - let location = resolved - .iloc - .items - .iter() - .find(|item| item.item_id == item_id)?; - if location.data_reference_index != 0 { - return None; +#[cfg(feature = "image-integration")] +fn decoded_heic_to_rgba16_native_endian_bytes( + decoded: DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [u8], +) -> Result<(), DecodeError> { + let mut output = NativeEndianRgba16Output(out); + decoded_heic_to_rgba_output( + decoded, + transforms, + auxiliary_alpha, + &mut output, + 16, + scale_sample_to_u16, + u16::MAX, + ) +} + +#[cfg(feature = "image-integration")] +fn decoded_heic_to_rgba_output>( + decoded: DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut O, + storage_bit_depth: u8, + scale_sample: fn(u16, u8) -> T, + opaque_alpha: T, +) -> Result<(), DecodeError> { + let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; + let source_storage_bit_depth = heic_storage_bit_depth(source_bit_depth); + if source_storage_bit_depth != storage_bit_depth { + return Err(DecodeError::Unsupported(format!( + "HEIC storage is RGBA{source_storage_bit_depth}, not RGBA{storage_bit_depth}" + ))); } - let properties = resolved_item_properties_for_item(resolved, item_id)?; - if !properties - .iter() - .any(property_is_alpha_auxiliary_type_property) - { - return None; + let transform_plan = + RgbaTransformPlan::from_primary_transforms(decoded.width, decoded.height, transforms)?; + let expected = checked_rgba_sample_count( + transform_plan.destination_width, + transform_plan.destination_height, + )?; + if out.sample_len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "HEIC direct transformed image adapter output", + actual: out.sample_len(), + expected, + width: transform_plan.destination_width, + height: transform_plan.destination_height, + }, + )); } - let hvcc = properties - .iter() - .find(|property| property.header.box_type.as_bytes() == HVCC_PROPERTY_TYPE)? - .parse_hvcc() - .ok()?; + let ycbcr_transform = + ycbcr_transform_from_matrix(decoded.ycbcr_matrix).map_err(|matrix_coefficients| { + DecodeHeicError::UnsupportedMatrixCoefficients { + matrix_coefficients, + } + })?; + validate_heic_plane_dimensions(&decoded.y_plane, decoded.width, decoded.height, "Y")?; + let expected_y_samples = heic_sample_count(decoded.width, decoded.height, "Y")?; + if decoded.y_plane.samples.len() != expected_y_samples { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "Y plane has {} samples, expected {expected_y_samples}", + decoded.y_plane.samples.len() + ), + } + .into()); + } - let payload = extract_heic_item_payload_with_source(input, source, meta, location)?; - let stream = assemble_heic_hevc_stream_from_components(&hvcc, &payload).ok()?; - let decoded = decode_hevc_stream_to_image(&stream).ok()?; - let expected_alpha_samples = heic_sample_count(decoded.width, decoded.height, "alpha").ok()?; - if decoded.y_plane.samples.len() != expected_alpha_samples { - return None; + let source_width = + usize::try_from(decoded.width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("HEIC width does not fit in usize ({})", decoded.width), + })?; + let destination_width = usize::try_from(transform_plan.destination_width).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "HEIC transformed width does not fit in usize ({})", + transform_plan.destination_width + ), + } + })?; + let destination_height = usize::try_from(transform_plan.destination_height).map_err(|_| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "HEIC transformed height does not fit in usize ({})", + transform_plan.destination_height + ), + } + })?; + let chroma = prepare_heic_chroma(&decoded)?; + let chroma_midpoint = chroma_midpoint(source_bit_depth); + let converter = PreparedYcbcrToRgb::new( + source_bit_depth, + decoded.ycbcr_range, + ycbcr_transform, + decoded.layout == HeicPixelLayout::Yuv420, + ); + let mono_verbatim = matches!(chroma, HeicChromaPlanes::Monochrome) && source_bit_depth == 8; + if let Some(alpha) = auxiliary_alpha { + validate_auxiliary_alpha_plane(alpha, decoded.width, decoded.height)?; + } + + for destination_y in 0..destination_height { + for destination_x in 0..destination_width { + let (source_x, source_y) = + transform_plan.map_destination_pixel(destination_x, destination_y)?; + // In-bounds by construction (the plan maps into the validated + // source dimensions) and the validated sample counts fit usize, + // so plain indexing cannot overflow — same as the AVIF and + // uncompressed twins of this loop. + let source_index = source_y * source_width + source_x; + let y_sample = i32::from(decoded.y_plane.samples[source_index]); + let (cb_sample, cr_sample) = match &chroma { + HeicChromaPlanes::Monochrome => (chroma_midpoint, chroma_midpoint), + HeicChromaPlanes::Color { + u_samples, + v_samples, + chroma_width, + layout, + } => { + let chroma_index = + heic_chroma_sample_index(source_x, source_y, *chroma_width, *layout); + ( + i32::from(u_samples[chroma_index]), + i32::from(v_samples[chroma_index]), + ) + } + }; + let (red, green, blue) = if mono_verbatim { + let value = y_sample.clamp(0, 255) as u16; + (value, value, value) + } else { + converter.convert(y_sample, cb_sample, cr_sample) + }; + let alpha = auxiliary_alpha.map_or(opaque_alpha, |alpha| { + scale_sample(alpha.samples[source_index], alpha.bit_depth) + }); + let destination_index = (destination_y * destination_width + destination_x) * 4; + out.write_sample(destination_index, scale_sample(red, source_bit_depth)); + out.write_sample(destination_index + 1, scale_sample(green, source_bit_depth)); + out.write_sample(destination_index + 2, scale_sample(blue, source_bit_depth)); + out.write_sample(destination_index + 3, alpha); + } } - Some(HeicAuxiliaryAlphaPlane { - width: decoded.width, - height: decoded.height, - bit_depth: decoded.bit_depth_luma, - samples: decoded.y_plane.samples, - }) + Ok(()) } -fn resolved_item_properties_for_item<'a>( - resolved: &isobmff::ResolvedPrimaryItemGraph<'a>, - item_id: u32, -) -> Option>> { - let mut flattened_properties = Vec::new(); - for container in &resolved.iprp.property_containers { - flattened_properties.extend(container.properties.iter().cloned()); - } +#[cfg(feature = "image-integration")] +fn decode_avif_bytes_to_rgba8_slice( + input: &[u8], + guardrails: DecodeGuardrails, + out: &mut [u8], +) -> Result<(), DecodeError> { + let (meta, resolved) = isobmff::resolve_primary_avif_item_graph(input) + .map_err(DecodeAvifError::ExtractPrimaryPayload)?; + let transforms = + isobmff::parse_primary_item_transform_properties_from_resolved_graph(&resolved) + .map_err(DecodeAvifError::ParsePrimaryTransforms)?; + let mut source: Option<&mut dyn RandomAccessSource> = None; + let decoded = + decode_primary_avif_to_image_from_resolved_graph(input, &mut source, &meta, &resolved)?; + guardrails.enforce_pixel_count(decoded.width, decoded.height)?; + decoded_avif_to_rgba8_slice(&decoded, &transforms.transforms, out) +} - let mut properties = Vec::new(); - for association_box in &resolved.iprp.associations { - for entry in &association_box.entries { - if entry.item_id != item_id { - continue; - } +#[cfg(feature = "image-integration")] +fn decode_avif_bytes_to_rgba16_native_endian_bytes( + input: &[u8], + guardrails: DecodeGuardrails, + out: &mut [u8], +) -> Result<(), DecodeError> { + let (meta, resolved) = isobmff::resolve_primary_avif_item_graph(input) + .map_err(DecodeAvifError::ExtractPrimaryPayload)?; + let transforms = + isobmff::parse_primary_item_transform_properties_from_resolved_graph(&resolved) + .map_err(DecodeAvifError::ParsePrimaryTransforms)?; + let mut source: Option<&mut dyn RandomAccessSource> = None; + let decoded = + decode_primary_avif_to_image_from_resolved_graph(input, &mut source, &meta, &resolved)?; + guardrails.enforce_pixel_count(decoded.width, decoded.height)?; + let mut output = NativeEndianRgba16Output(out); + decoded_avif_to_rgba16_output(&decoded, &transforms.transforms, &mut output) +} - for association in &entry.associations { - if association.property_index == 0 { - continue; - } - let property_index = usize::from(association.property_index - 1); - let property = flattened_properties.get(property_index)?.clone(); - properties.push(property); - } - } +#[cfg(feature = "image-integration")] +fn decoded_avif_to_rgba8_slice( + decoded: &DecodedAvifImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + out: &mut [u8], +) -> Result<(), DecodeError> { + if decoded.bit_depth > 8 { + return Err(DecodeError::Unsupported( + "AVIF storage is RGBA16, not RGBA8".to_string(), + )); } - - Some(properties) + let mut output = SliceRgbaOutput(out); + decoded_avif_to_rgba_output( + decoded, + transforms, + &mut output, + "AVIF direct transformed RGBA8 image adapter output", + plane_samples_u8, + avif_auxiliary_alpha_sample_to_u8, + scale_sample_to_u8, + u8::MAX, + ) } -fn property_is_alpha_auxiliary_type_property(property: &isobmff::ParsedBox<'_>) -> bool { - if property.header.box_type.as_bytes() != AUXC_PROPERTY_TYPE { - return false; - } - if property.payload.len() < 4 { - return false; - } - if property.payload[0] != 0 { - return false; +#[cfg(feature = "image-integration")] +fn decoded_avif_to_rgba16_output>( + decoded: &DecodedAvifImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + out: &mut O, +) -> Result<(), DecodeError> { + if decoded.bit_depth <= 8 { + return Err(DecodeError::Unsupported( + "AVIF storage is RGBA8, not RGBA16".to_string(), + )); } - - let aux_payload = &property.payload[4..]; - let aux_type_end = aux_payload - .iter() - .position(|byte| *byte == 0) - .unwrap_or(aux_payload.len()); - let aux_type = &aux_payload[..aux_type_end]; - ALPHA_AUX_TYPES.contains(&aux_type) + decoded_avif_to_rgba_output( + decoded, + transforms, + out, + "AVIF direct transformed RGBA16 image adapter output", + plane_samples_u16, + avif_auxiliary_alpha_sample_to_u16, + scale_sample_to_u16, + u16::MAX, + ) } -fn extract_heic_item_payload_with_source( - input: &[u8], - source: &mut Option<&mut dyn RandomAccessSource>, - meta: &isobmff::MetaBox<'_>, - location: &isobmff::ItemLocationItem, -) -> Option> { - let total_length = location - .extents - .iter() - .try_fold(0_u64, |acc, extent| acc.checked_add(extent.length))?; - let payload_capacity = usize::try_from(total_length).ok()?; - let mut payload = Vec::with_capacity(payload_capacity); +/// Shared AVIF caller-buffer conversion: transform-plan mapping, YCbCr +/// conversion, and auxiliary alpha, generic over the source plane sample +/// type `S` (u8/u16, gated by the wrappers' bit-depth checks) and the RGBA +/// output sink. +#[cfg(feature = "image-integration")] +#[allow(clippy::too_many_arguments)] +fn decoded_avif_to_rgba_output( + decoded: &DecodedAvifImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + out: &mut O, + sample_count_stage: &'static str, + plane_samples: for<'a> fn(&'a AvifPlane, &'static str) -> Result<&'a [S], DecodeAvifError>, + alpha_sample: fn(&AvifAuxiliaryAlpha<'_>, usize) -> T, + scale_sample: fn(u16, u8) -> T, + opaque_alpha: T, +) -> Result<(), DecodeError> +where + S: Copy + Into, + T: Copy, + O: RgbaSampleOutput, +{ + let transform_plan = + RgbaTransformPlan::from_primary_transforms(decoded.width, decoded.height, transforms)?; + let expected = checked_rgba_sample_count( + transform_plan.destination_width, + transform_plan.destination_height, + )?; + if out.sample_len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: sample_count_stage, + actual: out.sample_len(), + expected, + width: transform_plan.destination_width, + height: transform_plan.destination_height, + }, + )); + } - match location.construction_method { - 0 => { - if let Some(source) = source.as_mut() { - append_heic_item_location_extents_from_source(*source, location, &mut payload)?; - } else { - append_heic_item_location_extents(input, location, &mut payload)?; + let ycbcr_transform = + ycbcr_transform_from_matrix(decoded.ycbcr_matrix).map_err(|matrix_coefficients| { + DecodeAvifError::UnsupportedMatrixCoefficients { + matrix_coefficients, } + })?; + validate_plane_dimensions(&decoded.y_plane, decoded.width, decoded.height, "Y")?; + let y_samples = plane_samples(&decoded.y_plane, "Y")?; + let expected_y_samples = sample_count(decoded.width, decoded.height, "Y")?; + if y_samples.len() != expected_y_samples { + return Err(DecodeAvifError::PlaneSampleCountMismatch { + plane: "Y", + expected: expected_y_samples, + actual: y_samples.len(), } - 1 => { - let children = meta.parse_children().ok()?; - let idat_box = find_first_box_by_type(&children, IDAT_BOX_TYPE)?; - append_heic_item_location_extents(idat_box.payload, location, &mut payload)?; + .into()); + } + let source_width = + usize::try_from(decoded.width).map_err(|_| DecodeAvifError::PlaneSizeOverflow { + plane: "RGBA", + width: decoded.width, + height: decoded.height, + })?; + let destination_width = usize::try_from(transform_plan.destination_width).map_err(|_| { + DecodeAvifError::PlaneSizeOverflow { + plane: "RGBA", + width: transform_plan.destination_width, + height: transform_plan.destination_height, + } + })?; + let destination_height = usize::try_from(transform_plan.destination_height).map_err(|_| { + DecodeAvifError::PlaneSizeOverflow { + plane: "RGBA", + width: transform_plan.destination_width, + height: transform_plan.destination_height, + } + })?; + let chroma = prepare_chroma(decoded, plane_samples)?; + let alpha = prepare_avif_auxiliary_alpha(decoded, expected_y_samples)?; + let chroma_midpoint = chroma_midpoint(decoded.bit_depth); + let converter = PreparedYcbcrToRgb::new( + decoded.bit_depth, + decoded.ycbcr_range, + ycbcr_transform, + decoded.layout == AvifPixelLayout::Yuv420, + ); + let mono_verbatim = matches!(chroma, ChromaPlanes::Monochrome) && decoded.bit_depth == 8; + + for destination_y in 0..destination_height { + for destination_x in 0..destination_width { + let (source_x, source_y) = + transform_plan.map_destination_pixel(destination_x, destination_y)?; + let source_index = source_y * source_width + source_x; + let y_sample: i32 = y_samples[source_index].into(); + let (cb_sample, cr_sample) = match &chroma { + ChromaPlanes::Monochrome => (chroma_midpoint, chroma_midpoint), + ChromaPlanes::Color { + u_samples, + v_samples, + chroma_width, + layout, + } => { + let chroma_index = + chroma_sample_index(source_x, source_y, *chroma_width, *layout); + ( + u_samples[chroma_index].into(), + v_samples[chroma_index].into(), + ) + } + }; + let (red, green, blue) = if mono_verbatim { + let value = y_sample.clamp(0, 255) as u16; + (value, value, value) + } else { + converter.convert(y_sample, cb_sample, cr_sample) + }; + let destination_index = (destination_y * destination_width + destination_x) * 4; + out.write_sample(destination_index, scale_sample(red, decoded.bit_depth)); + out.write_sample( + destination_index + 1, + scale_sample(green, decoded.bit_depth), + ); + out.write_sample(destination_index + 2, scale_sample(blue, decoded.bit_depth)); + out.write_sample( + destination_index + 3, + alpha + .as_ref() + .map(|plane| alpha_sample(plane, source_index)) + .unwrap_or(opaque_alpha), + ); } - _ => return None, } - Some(payload) + Ok(()) } -fn append_heic_item_location_extents( - source: &[u8], - location: &isobmff::ItemLocationItem, - output: &mut Vec, -) -> Option<()> { - let available = source.len() as u64; - for extent in &location.extents { - let start = location.base_offset.checked_add(extent.offset)?; - let end = start.checked_add(extent.length)?; - if end > available { - return None; - } +#[cfg(feature = "image-integration")] +fn try_decode_uncompressed_heif_to_rgba_output>( + input: &[u8], + guardrails: &DecodeGuardrails, + out: &mut O, + storage_bit_depth: u8, + scale_sample: fn(u16, u8) -> T, +) -> Result { + let properties = match isobmff::parse_primary_uncompressed_item_properties(input) { + Ok(properties) => properties, + Err(isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { + .. + }) => return Ok(false), + Err(err) => return Err(DecodeUncompressedError::ParsePrimaryProperties(err).into()), + }; + guardrails.enforce_pixel_count(properties.ispe.width, properties.ispe.height)?; + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeUncompressedError::ParsePrimaryTransforms)? + .transforms; + let mut source: Option<&mut dyn RandomAccessSource> = None; + let decoded = decode_primary_uncompressed_to_channels_internal(input, &mut source)?; + let source_storage_bit_depth = heic_storage_bit_depth(decoded.output_bit_depth); + if source_storage_bit_depth != storage_bit_depth { + return Err(DecodeError::Unsupported(format!( + "uncompressed HEIF storage is RGBA{source_storage_bit_depth}, not RGBA{storage_bit_depth}" + ))); + } - let start = usize::try_from(start).ok()?; - let end = usize::try_from(end).ok()?; - output.extend_from_slice(&source[start..end]); + let transform_plan = + RgbaTransformPlan::from_primary_transforms(decoded.width, decoded.height, &transforms)?; + let expected = checked_rgba_sample_count( + transform_plan.destination_width, + transform_plan.destination_height, + )?; + if out.sample_len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "uncompressed HEIF direct transformed image adapter output", + actual: out.sample_len(), + expected, + width: transform_plan.destination_width, + height: transform_plan.destination_height, + }, + )); } - Some(()) -} -fn append_heic_item_location_extents_from_source( - source: &mut dyn RandomAccessSource, - location: &isobmff::ItemLocationItem, - output: &mut Vec, -) -> Option<()> { - for extent in &location.extents { - let start = location.base_offset.checked_add(extent.offset)?; - start.checked_add(extent.length)?; - let extent_len = usize::try_from(extent.length).ok()?; - let output_start = output.len(); - let output_end = output_start.checked_add(extent_len)?; - output.resize(output_end, 0); - if source - .read_exact_at(start, &mut output[output_start..output_end]) - .is_err() - { - output.truncate(output_start); - return None; + let source_width = + usize::try_from(decoded.width).map_err(|_| DecodeUncompressedError::InvalidInput { + detail: format!( + "uncompressed image width {} cannot be represented", + decoded.width + ), + })?; + let destination_width = usize::try_from(transform_plan.destination_width).map_err(|_| { + DecodeUncompressedError::InvalidInput { + detail: format!( + "uncompressed transformed width {} cannot be represented", + transform_plan.destination_width + ), + } + })?; + let destination_height = usize::try_from(transform_plan.destination_height).map_err(|_| { + DecodeUncompressedError::InvalidInput { + detail: format!( + "uncompressed transformed height {} cannot be represented", + transform_plan.destination_height + ), + } + })?; + for destination_y in 0..destination_height { + for destination_x in 0..destination_width { + let (source_x, source_y) = + transform_plan.map_destination_pixel(destination_x, destination_y)?; + let source_index = source_y * source_width + source_x; + let rgba = decoded.rgba_at(source_index)?; + let destination_index = (destination_y * destination_width + destination_x) * 4; + for (channel, sample) in rgba.into_iter().enumerate() { + out.write_sample( + destination_index + channel, + scale_sample(sample, decoded.output_bit_depth), + ); + } } } - Some(()) + + Ok(true) } -fn find_first_box_by_type<'a, 'b>( - boxes: &'b [isobmff::ParsedBox<'a>], - box_type: [u8; 4], -) -> Option<&'b isobmff::ParsedBox<'a>> { - boxes - .iter() - .find(|child| child.header.box_type.as_bytes() == box_type) +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba8_slice( + input: &[u8], + guardrails: DecodeGuardrails, + preextracted: Option, + out: &mut [u8], +) -> Result<(), DecodeError> { + let mut output = SliceRgbaOutput(out); + if try_decode_uncompressed_heif_to_rgba_output( + input, + &guardrails, + &mut output, + 8, + scale_sample_to_u8, + )? { + return Ok(()); + } + decode_heif_bytes_to_rgba_slice( + input, + guardrails, + preextracted, + out, + decode_primary_heic_grid_to_rgba8_slice, + decoded_heic_to_rgba8_slice, + ) +} + +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba16_native_endian_bytes( + input: &[u8], + guardrails: DecodeGuardrails, + preextracted: Option, + out: &mut [u8], +) -> Result<(), DecodeError> { + let mut output = NativeEndianRgba16Output(out); + if try_decode_uncompressed_heif_to_rgba_output( + input, + &guardrails, + &mut output, + 16, + scale_sample_to_u16, + )? { + return Ok(()); + } + // The 16-bit functions write native-endian u16 samples into the byte + // slice, so they instantiate the shared dispatch at T = u8 just like the + // RGBA8 twin above. + decode_heif_bytes_to_rgba_slice( + input, + guardrails, + preextracted, + out, + decode_primary_heic_grid_to_rgba16_native_endian_bytes, + decoded_heic_to_rgba16_native_endian_bytes, + ) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum HeifInputFamily { - Avif, - Heif, -} +#[cfg(feature = "image-integration")] +type HeicGridSliceDecode = fn( + &isobmff::HeicGridPrimaryItemData, + &[isobmff::PrimaryItemTransformProperty], + Option<&HeicAuxiliaryAlphaPlane>, + &mut [T], +) -> Result<(), DecodeError>; -const AVIF_FILE_BRANDS: [[u8; 4]; 2] = [*b"avif", *b"avis"]; -const HEIF_FILE_BRANDS: [[u8; 4]; 9] = [ - *b"mif1", *b"msf1", *b"miaf", *b"heic", *b"heix", *b"hevc", *b"hevx", *b"heim", *b"heis", -]; +#[cfg(feature = "image-integration")] +type HeicCodedSliceDecode = fn( + DecodedHeicImage, + &[isobmff::PrimaryItemTransformProperty], + Option<&HeicAuxiliaryAlphaPlane>, + &mut [T], +) -> Result<(), DecodeError>; -fn decode_avif_bytes_to_rgba( +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba_slice( input: &[u8], guardrails: DecodeGuardrails, -) -> Result { - let (meta, resolved) = isobmff::resolve_primary_avif_item_graph(input) - .map_err(DecodeAvifError::ExtractPrimaryPayload)?; + preextracted: Option, + out: &mut [T], + decode_grid_slice: HeicGridSliceDecode, + decode_coded_slice: HeicCodedSliceDecode, +) -> Result<(), DecodeError> { + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeHeicError::ParsePrimaryTransforms)? + .transforms; let mut source: Option<&mut dyn RandomAccessSource> = None; - decode_avif_to_rgba_from_resolved_graph(input, &mut source, &meta, &resolved, guardrails) + // Reuse the extraction the layout probe already performed (`input` is the + // same immutable buffer, so re-extracting could only produce the same + // payload copies again). + let primary_with_grid = match preextracted { + Some(primary_with_grid) => primary_with_grid, + None => isobmff::extract_primary_heic_item_data_with_grid(input) + .map_err(DecodeHeicError::from)?, + }; + + match primary_with_grid { + isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { + let auxiliary_alpha = decode_primary_heic_grid_auxiliary_alpha( + input, + &mut source, + &grid_data, + &guardrails, + )?; + decode_grid_slice(&grid_data, &transforms, auxiliary_alpha.as_ref(), out) + } + isobmff::HeicPrimaryItemDataWithGrid::Coded(item_data) => { + let (decoded, auxiliary_alpha) = decode_primary_heic_coded_item_with_alpha( + input, + &mut source, + &item_data, + &guardrails, + )?; + decode_coded_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) + } + } } -fn decode_avif_to_rgba_from_resolved_graph( +/// Enforce the input-size guardrail and resolve the input family from the +/// ftyp brands, falling back to the caller-provided hint. +fn enforce_and_resolve_input_family( input: &[u8], - source: &mut Option<&mut dyn RandomAccessSource>, - meta: &isobmff::MetaBox<'_>, - resolved: &isobmff::ResolvedPrimaryItemGraph<'_>, - guardrails: DecodeGuardrails, -) -> Result { - let transforms = isobmff::parse_primary_item_transform_properties_from_resolved_graph(resolved) - .map_err(DecodeAvifError::ParsePrimaryTransforms)?; - let icc_profile = primary_icc_profile_from_resolved_avif_graph(resolved); - let decoded = decode_primary_avif_to_image_from_resolved_graph(input, source, meta, resolved)?; - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - decoded_avif_to_rgba_image(&decoded, &transforms.transforms, icc_profile) + hint: Option, + guardrails: &DecodeGuardrails, +) -> Result { + guardrails.enforce_input_bytes(input.len() as u64)?; + detect_input_family_from_ftyp(input) + .or(hint) + .ok_or_else(unknown_input_family_error) } -fn decode_avif_source_to_rgba( - source: &mut S, +fn unknown_input_family_error() -> DecodeError { + DecodeError::Unsupported( + "Unsupported HEIF/AVIF file type: could not infer image family from ftyp brands" + .to_string(), + ) +} + +#[cfg(feature = "image-integration")] +fn decode_bytes_to_rgba_layout_with_hint_and_guardrails( input: &[u8], + hint: Option, guardrails: DecodeGuardrails, -) -> Result { - let (meta, resolved) = isobmff::resolve_primary_avif_item_graph(input) - .map_err(DecodeAvifError::ExtractPrimaryPayload)?; - let mut source: Option<&mut dyn RandomAccessSource> = Some(source); - decode_avif_to_rgba_from_resolved_graph(input, &mut source, &meta, &resolved, guardrails) +) -> Result { + match enforce_and_resolve_input_family(input, hint, &guardrails)? { + HeifInputFamily::Heif => decode_heif_bytes_to_rgba_layout(input, guardrails), + HeifInputFamily::Avif => decode_avif_bytes_to_rgba_layout(input, guardrails) + .map(RgbaLayoutProbe::without_extraction), + } } -fn decode_heif_bytes_to_rgba( +#[cfg(feature = "image-integration")] +fn decode_bytes_to_rgba8_slice_with_hint_and_guardrails( input: &[u8], + hint: Option, guardrails: DecodeGuardrails, -) -> Result { - match decode_primary_uncompressed_to_image(input) { - Ok(decoded) => { - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - let transforms = isobmff::parse_primary_item_transform_properties(input) - .map_err(DecodeUncompressedError::ParsePrimaryTransforms)? - .transforms; - return decoded_uncompressed_to_rgba_image(decoded, &transforms); + preextracted_heic: Option, + out: &mut [u8], +) -> Result<(), DecodeError> { + match enforce_and_resolve_input_family(input, hint, &guardrails)? { + HeifInputFamily::Heif => { + decode_heif_bytes_to_rgba8_slice(input, guardrails, preextracted_heic, out) } - Err(DecodeUncompressedError::ParsePrimaryProperties( - isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. }, - )) => {} - Err(err) => return Err(err.into()), + HeifInputFamily::Avif => decode_avif_bytes_to_rgba8_slice(input, guardrails, out), } - - let transforms = isobmff::parse_primary_item_transform_properties(input) - .map_err(DecodeHeicError::ParsePrimaryTransforms)? - .transforms; - let icc_profile = primary_icc_profile_from_heic(input); - let decoded = decode_primary_heic_to_image(input)?; - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - let auxiliary_alpha = - decode_primary_heic_auxiliary_alpha_plane(input, decoded.width, decoded.height); - decoded_heic_to_rgba_image(&decoded, &transforms, auxiliary_alpha.as_ref(), icc_profile) } -fn decode_heif_source_to_rgba( - source: &mut S, +#[cfg(feature = "image-integration")] +fn decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails( input: &[u8], + hint: Option, guardrails: DecodeGuardrails, -) -> Result { - let mut source: Option<&mut dyn RandomAccessSource> = Some(source); - match decode_primary_uncompressed_to_image_internal(input, &mut source) { - Ok(decoded) => { - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - let transforms = isobmff::parse_primary_item_transform_properties(input) - .map_err(DecodeUncompressedError::ParsePrimaryTransforms)? - .transforms; - return decoded_uncompressed_to_rgba_image(decoded, &transforms); + preextracted_heic: Option, + out: &mut [u8], +) -> Result<(), DecodeError> { + if !out.len().is_multiple_of(std::mem::size_of::()) { + return Err(DecodeError::Unsupported( + "RGBA16 image adapter output has an odd byte length".to_string(), + )); + } + match enforce_and_resolve_input_family(input, hint, &guardrails)? { + HeifInputFamily::Heif => decode_heif_bytes_to_rgba16_native_endian_bytes( + input, + guardrails, + preextracted_heic, + out, + ), + HeifInputFamily::Avif => { + decode_avif_bytes_to_rgba16_native_endian_bytes(input, guardrails, out) } - Err(DecodeUncompressedError::ParsePrimaryProperties( - isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. }, - )) => {} - Err(err) => return Err(err.into()), } - - let transforms = isobmff::parse_primary_item_transform_properties(input) - .map_err(DecodeHeicError::ParsePrimaryTransforms)? - .transforms; - let icc_profile = primary_icc_profile_from_heic(input); - let decoded = decode_primary_heic_to_image_internal(input, &mut source)?; - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( - input, - &mut source, - decoded.width, - decoded.height, - ); - decoded_heic_to_rgba_image(&decoded, &transforms, auxiliary_alpha.as_ref(), icc_profile) } fn decode_avif_bytes_to_png( @@ -6135,16 +9079,7 @@ fn decode_bytes_to_rgba_with_hint_and_guardrails( hint: Option, guardrails: DecodeGuardrails, ) -> Result { - guardrails.enforce_input_bytes(input.len() as u64)?; - let family = detect_input_family_from_ftyp(input) - .or(hint) - .ok_or_else(|| { - DecodeError::Unsupported( - "Unsupported HEIF/AVIF file type: could not infer image family from ftyp brands" - .to_string(), - ) - })?; - match family { + match enforce_and_resolve_input_family(input, hint, &guardrails)? { HeifInputFamily::Avif => decode_avif_bytes_to_rgba(input, guardrails), HeifInputFamily::Heif => decode_heif_bytes_to_rgba(input, guardrails), } @@ -6156,16 +9091,7 @@ fn decode_bytes_to_png_with_hint_and_guardrails( output_path: &Path, guardrails: DecodeGuardrails, ) -> Result<(), DecodeError> { - guardrails.enforce_input_bytes(input.len() as u64)?; - let family = detect_input_family_from_ftyp(input) - .or(hint) - .ok_or_else(|| { - DecodeError::Unsupported( - "Unsupported HEIF/AVIF file type: could not infer image family from ftyp brands" - .to_string(), - ) - })?; - match family { + match enforce_and_resolve_input_family(input, hint, &guardrails)? { HeifInputFamily::Avif => decode_avif_bytes_to_png(input, output_path, guardrails), HeifInputFamily::Heif => decode_heif_bytes_to_png(input, output_path, guardrails), } @@ -6212,12 +9138,9 @@ fn decode_source_to_rgba_with_hint_and_guardrails( read_selected_top_level_boxes_from_source(source, &[FTYP_BOX_TYPE, META_BOX_TYPE])?; let source_family_hint = detect_input_family_from_source_selected_boxes(&selected)?; let input = encode_source_selected_top_level_boxes(&selected); - let family = source_family_hint.or(hint).ok_or_else(|| { - DecodeError::Unsupported( - "Unsupported HEIF/AVIF file type: could not infer image family from ftyp brands" - .to_string(), - ) - })?; + let family = source_family_hint + .or(hint) + .ok_or_else(unknown_input_family_error)?; match family { HeifInputFamily::Avif => decode_avif_source_to_rgba(source, &input, guardrails), HeifInputFamily::Heif => decode_heif_source_to_rgba(source, &input, guardrails), @@ -6261,17 +9184,6 @@ fn decode_read_to_png_with_hint_and_guardrails( decode_source_to_png_with_hint_and_guardrails(&mut source, hint, output_path, guardrails) } -#[cfg(feature = "image-integration")] -fn decode_seekable_to_rgba_with_hint_and_guardrails( - input_reader: R, - hint: Option, - guardrails: DecodeGuardrails, -) -> Result { - let mut source = - source::SeekableSource::new(input_reader).map_err(decode_error_from_source_read_error)?; - decode_source_to_rgba_with_hint_and_guardrails(&mut source, hint, guardrails) -} - /// Decode bytes with configurable guardrails into an owned RGBA buffer. pub fn decode_bytes_to_rgba_with_guardrails( input: &[u8], @@ -6489,6 +9401,14 @@ fn nclx_to_icc_profile(nclx: &isobmff::NclxColorProfile) -> Option> { // while honouring genuinely different curves — so encode the sRGB curve. // A literal 709-curve profile renders brighter in lcms-class viewers and // darker on Apple than the source image. + // + // DELIBERATE DIVERGENCE — do not "fix" this to the mathematically + // literal BT.709 OETF. The synthesized profile intentionally describes + // how still-image consumers render these transfers, not the coded curve; + // making it literal reintroduces the brightness mismatches above. The + // qcms rendering-contract tests + // (bt709_family_profiles_are_srgb_identity_in_qcms and friends) encode + // this decision and fail on any change to the aliasing. let transfer_characteristics = match nclx.transfer_characteristics { 1 | 6 | 14 | 15 => 13, other => other, @@ -6536,7 +9456,20 @@ fn nclx_to_icc_profile(nclx: &isobmff::NclxColorProfile) -> Option> { "Public Domain.".to_string(), )])); - profile.encode().ok() + let mut encoded = profile.encode().ok()?; + // Synthesized profiles are a pure function of nclx metadata. moxcms + // stamps the current wall-clock time into bytes 24..36 of every encoded + // ICC header, ignoring ColorProfile::creation_date_time. Normalize that + // dateTimeNumber so separate direct and hook decodes are byte-identical. + encoded.get_mut(24..36)?.copy_from_slice(&[ + 0x07, 0xB2, // 1970 + 0, 1, // January + 0, 1, // first day + 0, 0, // 00 hours + 0, 0, // 00 minutes + 0, 0, // 00 seconds + ]); + Some(encoded) } fn ycbcr_range_from_primary_colr(colr: &isobmff::PrimaryItemColorProperties) -> YCbCrRange { @@ -6871,19 +9804,29 @@ fn decoded_avif_to_rgba_image( } fn decoded_heic_to_rgba_image( - decoded: &DecodedHeicImage, + mut decoded: DecodedHeicImage, transforms: &[isobmff::PrimaryItemTransformProperty], auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, icc_profile: Option>, ) -> Result { - let source_bit_depth = heic_bit_depth_for_png_conversion(decoded)?; + let mut remaining_transforms = transforms; + if auxiliary_alpha.is_none() { + (decoded, remaining_transforms) = + crop_heic_by_leading_chroma_aligned_clean_apertures(decoded, remaining_transforms)?; + } + + let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; if source_bit_depth <= 8 { - let mut pixels = convert_heic_to_rgba8(decoded)?; + let mut pixels = convert_heic_to_rgba8(&decoded)?; if let Some(alpha) = auxiliary_alpha { apply_auxiliary_alpha_to_rgba8(&mut pixels, decoded.width, decoded.height, alpha)?; } - let (width, height, transformed) = - apply_primary_item_transforms_rgba(decoded.width, decoded.height, pixels, transforms)?; + let (width, height, transformed) = apply_primary_item_transforms_rgba( + decoded.width, + decoded.height, + pixels, + remaining_transforms, + )?; return Ok(DecodedRgbaImage { width, height, @@ -6893,12 +9836,16 @@ fn decoded_heic_to_rgba_image( }); } - let mut pixels = convert_heic_to_rgba16(decoded)?; + let mut pixels = convert_heic_to_rgba16(&decoded)?; if let Some(alpha) = auxiliary_alpha { apply_auxiliary_alpha_to_rgba16(&mut pixels, decoded.width, decoded.height, alpha)?; } - let (width, height, transformed) = - apply_primary_item_transforms_rgba(decoded.width, decoded.height, pixels, transforms)?; + let (width, height, transformed) = apply_primary_item_transforms_rgba( + decoded.width, + decoded.height, + pixels, + remaining_transforms, + )?; Ok(DecodedRgbaImage { width, height, @@ -7161,7 +10108,7 @@ fn apply_primary_item_transforms_rgba( let (next_width, next_height, next_pixels) = crop_rgba_by_clean_aperture( current_width, current_height, - ¤t_pixels, + current_pixels, *clean_aperture, )?; current_width = next_width; @@ -7169,6 +10116,9 @@ fn apply_primary_item_transforms_rgba( current_pixels = next_pixels; } isobmff::PrimaryItemTransformProperty::Rotation(rotation) => { + if rotation.rotation_ccw_degrees % 360 == 0 { + continue; + } let (next_width, next_height, next_pixels) = rotate_rgba_ccw( current_width, current_height, @@ -7362,31 +10312,27 @@ fn mirror_rgba( Ok(out) } -fn crop_rgba_by_clean_aperture( +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CleanApertureCropBounds { + left: i128, + right: i128, + top: i128, + bottom: i128, + width: u32, + height: u32, +} + +fn clean_aperture_crop_bounds( width: u32, height: u32, - pixels: &[T], clean_aperture: isobmff::ImageCleanApertureProperty, -) -> Result<(u32, u32, Vec), DecodeError> { +) -> Result { if width == 0 || height == 0 { return Err(DecodeError::TransformGuard( TransformGuardError::EmptyImageGeometry { width, height }, )); } - let expected = checked_rgba_sample_count(width, height)?; - if pixels.len() != expected { - return Err(DecodeError::TransformGuard( - TransformGuardError::RgbaSampleCountMismatch { - stage: "clean-aperture input", - actual: pixels.len(), - expected, - width, - height, - }, - )); - } - // Provenance: crop rounding/clamp order mirrors libheif's primary decode // transform path in libheif/libheif/image-items/image_item.cc: // ImageItem::decode_image and Box_clap border math in @@ -7431,6 +10377,78 @@ fn crop_rgba_by_clean_aperture( }) })?; + Ok(CleanApertureCropBounds { + left, + right, + top, + bottom, + width: crop_width, + height: crop_height, + }) +} + +#[cfg(feature = "image-integration")] +fn transformed_rgba_dimensions( + width: u32, + height: u32, + transforms: &[isobmff::PrimaryItemTransformProperty], +) -> Result<(u32, u32), DecodeError> { + let mut current_width = width; + let mut current_height = height; + + for transform in transforms { + match *transform { + isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture) => { + let crop = + clean_aperture_crop_bounds(current_width, current_height, clean_aperture)?; + current_width = crop.width; + current_height = crop.height; + } + isobmff::PrimaryItemTransformProperty::Rotation(rotation) => { + match rotation.rotation_ccw_degrees % 360 { + 0 | 180 => {} + 90 | 270 => std::mem::swap(&mut current_width, &mut current_height), + _ => { + return Err(DecodeError::TransformGuard( + TransformGuardError::UnsupportedRotation { + rotation_ccw_degrees: rotation.rotation_ccw_degrees, + }, + )); + } + } + } + isobmff::PrimaryItemTransformProperty::Mirror(_) => {} + } + } + + Ok((current_width, current_height)) +} + +fn crop_rgba_by_clean_aperture( + width: u32, + height: u32, + pixels: Vec, + clean_aperture: isobmff::ImageCleanApertureProperty, +) -> Result<(u32, u32, Vec), DecodeError> { + let expected = checked_rgba_sample_count(width, height)?; + if pixels.len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "clean-aperture input", + actual: pixels.len(), + expected, + width, + height, + }, + )); + } + + let crop = clean_aperture_crop_bounds(width, height, clean_aperture)?; + + if crop.left == 0 && crop.top == 0 && crop.width == width && crop.height == height { + return Ok((width, height, pixels)); + } + let src_width = usize::try_from(width).map_err(|_| { DecodeError::TransformGuard(TransformGuardError::DimensionTooLargeForPlatform { stage: "clean aperture", @@ -7438,32 +10456,32 @@ fn crop_rgba_by_clean_aperture( value: u64::from(width), }) })?; - let left_usize = usize::try_from(left).map_err(|_| { + let left_usize = usize::try_from(crop.left).map_err(|_| { DecodeError::TransformGuard(TransformGuardError::CleanApertureBoundOutOfRange { bound: "left", - value: left, + value: crop.left, }) })?; - let right_usize = usize::try_from(right).map_err(|_| { + let right_usize = usize::try_from(crop.right).map_err(|_| { DecodeError::TransformGuard(TransformGuardError::CleanApertureBoundOutOfRange { bound: "right", - value: right, + value: crop.right, }) })?; - let top_usize = usize::try_from(top).map_err(|_| { + let top_usize = usize::try_from(crop.top).map_err(|_| { DecodeError::TransformGuard(TransformGuardError::CleanApertureBoundOutOfRange { bound: "top", - value: top, + value: crop.top, }) })?; - let bottom_usize = usize::try_from(bottom).map_err(|_| { + let bottom_usize = usize::try_from(crop.bottom).map_err(|_| { DecodeError::TransformGuard(TransformGuardError::CleanApertureBoundOutOfRange { bound: "bottom", - value: bottom, + value: crop.bottom, }) })?; - let out_len = checked_rgba_sample_count(crop_width, crop_height)?; + let out_len = checked_rgba_sample_count(crop.width, crop.height)?; let mut out = Vec::with_capacity(out_len); for y in top_usize..=bottom_usize { let row_pixel_start = y @@ -7510,7 +10528,7 @@ fn crop_rgba_by_clean_aperture( } debug_assert_eq!(out.len(), out_len); - Ok((crop_width, crop_height, out)) + Ok((crop.width, crop.height, out)) } #[derive(Clone, Copy)] @@ -7653,6 +10671,20 @@ fn append_normalized_hevc_payload_nals( payload: &[u8], nal_length_size: usize, stream: &mut Vec, +) -> Result<(), DecodeHeicError> { + walk_length_prefixed_payload_nals(payload, nal_length_size, |_, nal_unit| { + append_nal_with_u32_length_prefix(nal_unit, stream)?; + Ok(false) + }) +} + +/// Walk the length-prefixed NAL units of an item payload in place, calling +/// `visit` with each unit's payload offset and bytes until it returns `true` +/// to stop early. +fn walk_length_prefixed_payload_nals( + payload: &[u8], + nal_length_size: usize, + mut visit: impl FnMut(usize, &[u8]) -> Result, ) -> Result<(), DecodeHeicError> { let mut cursor = 0usize; while cursor < payload.len() { @@ -7682,7 +10714,9 @@ fn append_normalized_hevc_payload_nals( } let nal_end = cursor + nal_size; - append_nal_with_u32_length_prefix(&payload[cursor..nal_end], stream)?; + if visit(cursor, &payload[cursor..nal_end])? { + return Ok(()); + } cursor = nal_end; } @@ -7965,6 +10999,36 @@ fn convert_avif_to_rgba16(decoded: &DecodedAvifImage) -> Result, Decode } fn convert_heic_to_rgba8(decoded: &DecodedHeicImage) -> Result, DecodeHeicError> { + let mut out = Vec::new(); + convert_heic_to_rgba8_into(decoded, &mut out)?; + Ok(out) +} + +fn convert_heic_to_rgba8_into( + decoded: &DecodedHeicImage, + out: &mut Vec, +) -> Result<(), DecodeHeicError> { + let output_len = checked_heic_rgba_output_len(decoded)?; + out.resize(output_len, 0); + convert_heic_to_rgba8_slice(decoded, out) +} + +fn checked_heic_rgba_output_len(decoded: &DecodedHeicImage) -> Result { + let expected_y_samples = heic_sample_count(decoded.width, decoded.height, "Y")?; + expected_y_samples + .checked_mul(4) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "RGBA output sample count overflow for {}x{}", + decoded.width, decoded.height + ), + }) +} + +fn convert_heic_to_rgba8_slice( + decoded: &DecodedHeicImage, + out: &mut [u8], +) -> Result<(), DecodeHeicError> { let ycbcr_transform = ycbcr_transform_from_matrix(decoded.ycbcr_matrix).map_err(|matrix_coefficients| { DecodeHeicError::UnsupportedMatrixCoefficients { @@ -8002,7 +11066,14 @@ fn convert_heic_to_rgba8(decoded: &DecodedHeicImage) -> Result, DecodeHe decoded.width, decoded.height ), })?; - let mut out = vec![0_u8; output_len]; + if out.len() != output_len { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "RGBA8 output has {} samples, expected {output_len}", + out.len() + ), + }); + } let chroma = prepare_heic_chroma(decoded)?; let chroma_midpoint = chroma_midpoint(bit_depth); @@ -8057,10 +11128,28 @@ fn convert_heic_to_rgba8(decoded: &DecodedHeicImage) -> Result, DecodeHe } } - Ok(out) + Ok(()) } fn convert_heic_to_rgba16(decoded: &DecodedHeicImage) -> Result, DecodeHeicError> { + let mut out = Vec::new(); + convert_heic_to_rgba16_into(decoded, &mut out)?; + Ok(out) +} + +fn convert_heic_to_rgba16_into( + decoded: &DecodedHeicImage, + out: &mut Vec, +) -> Result<(), DecodeHeicError> { + let output_len = checked_heic_rgba_output_len(decoded)?; + out.resize(output_len, 0); + convert_heic_to_rgba16_slice(decoded, out) +} + +fn convert_heic_to_rgba16_slice( + decoded: &DecodedHeicImage, + out: &mut [u16], +) -> Result<(), DecodeHeicError> { let ycbcr_transform = ycbcr_transform_from_matrix(decoded.ycbcr_matrix).map_err(|matrix_coefficients| { DecodeHeicError::UnsupportedMatrixCoefficients { @@ -8098,7 +11187,14 @@ fn convert_heic_to_rgba16(decoded: &DecodedHeicImage) -> Result, Decode decoded.width, decoded.height ), })?; - let mut out = vec![0_u16; output_len]; + if out.len() != output_len { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "RGBA16 output has {} samples, expected {output_len}", + out.len() + ), + }); + } let chroma = prepare_heic_chroma(decoded)?; let chroma_midpoint = chroma_midpoint(bit_depth); @@ -8142,7 +11238,7 @@ fn convert_heic_to_rgba16(decoded: &DecodedHeicImage) -> Result, Decode } } - Ok(out) + Ok(()) } enum AvifAuxiliaryAlphaSamples<'a> { @@ -8390,37 +11486,33 @@ fn heic_chroma_sample_index( } } -enum ChromaPlanesU8<'a> { +enum ChromaPlanes<'a, S> { Monochrome, Color { - u_samples: &'a [u8], - v_samples: &'a [u8], + u_samples: &'a [S], + v_samples: &'a [S], chroma_width: usize, layout: AvifPixelLayout, }, } -enum ChromaPlanesU16<'a> { - Monochrome, - Color { - u_samples: &'a [u16], - v_samples: &'a [u16], - chroma_width: usize, - layout: AvifPixelLayout, - }, -} +type ChromaPlanesU8<'a> = ChromaPlanes<'a, u8>; +type ChromaPlanesU16<'a> = ChromaPlanes<'a, u16>; -fn prepare_chroma_u8(decoded: &DecodedAvifImage) -> Result, DecodeAvifError> { +fn prepare_chroma( + decoded: &DecodedAvifImage, + plane_samples: for<'a> fn(&'a AvifPlane, &'static str) -> Result<&'a [S], DecodeAvifError>, +) -> Result, DecodeAvifError> { if decoded.layout == AvifPixelLayout::Yuv400 { - return Ok(ChromaPlanesU8::Monochrome); + return Ok(ChromaPlanes::Monochrome); } let (u_plane, v_plane, expected_width, expected_height) = require_chroma_planes(decoded)?; validate_plane_dimensions(u_plane, expected_width, expected_height, "U")?; validate_plane_dimensions(v_plane, expected_width, expected_height, "V")?; - let u_samples = plane_samples_u8(u_plane, "U")?; - let v_samples = plane_samples_u8(v_plane, "V")?; + let u_samples = plane_samples(u_plane, "U")?; + let v_samples = plane_samples(v_plane, "V")?; let expected_samples = sample_count(expected_width, expected_height, "U/V")?; if u_samples.len() != expected_samples { return Err(DecodeAvifError::PlaneSampleCountMismatch { @@ -8443,7 +11535,7 @@ fn prepare_chroma_u8(decoded: &DecodedAvifImage) -> Result, D width: expected_width, height: expected_height, })?; - Ok(ChromaPlanesU8::Color { + Ok(ChromaPlanes::Color { u_samples, v_samples, chroma_width, @@ -8451,45 +11543,12 @@ fn prepare_chroma_u8(decoded: &DecodedAvifImage) -> Result, D }) } -fn prepare_chroma_u16(decoded: &DecodedAvifImage) -> Result, DecodeAvifError> { - if decoded.layout == AvifPixelLayout::Yuv400 { - return Ok(ChromaPlanesU16::Monochrome); - } - - let (u_plane, v_plane, expected_width, expected_height) = require_chroma_planes(decoded)?; - validate_plane_dimensions(u_plane, expected_width, expected_height, "U")?; - validate_plane_dimensions(v_plane, expected_width, expected_height, "V")?; - - let u_samples = plane_samples_u16(u_plane, "U")?; - let v_samples = plane_samples_u16(v_plane, "V")?; - let expected_samples = sample_count(expected_width, expected_height, "U/V")?; - if u_samples.len() != expected_samples { - return Err(DecodeAvifError::PlaneSampleCountMismatch { - plane: "U", - expected: expected_samples, - actual: u_samples.len(), - }); - } - if v_samples.len() != expected_samples { - return Err(DecodeAvifError::PlaneSampleCountMismatch { - plane: "V", - expected: expected_samples, - actual: v_samples.len(), - }); - } +fn prepare_chroma_u8(decoded: &DecodedAvifImage) -> Result, DecodeAvifError> { + prepare_chroma(decoded, plane_samples_u8) +} - let chroma_width = - usize::try_from(expected_width).map_err(|_| DecodeAvifError::PlaneSizeOverflow { - plane: "U", - width: expected_width, - height: expected_height, - })?; - Ok(ChromaPlanesU16::Color { - u_samples, - v_samples, - chroma_width, - layout: decoded.layout, - }) +fn prepare_chroma_u16(decoded: &DecodedAvifImage) -> Result, DecodeAvifError> { + prepare_chroma(decoded, plane_samples_u16) } fn require_chroma_planes( @@ -8869,6 +11928,64 @@ impl Drop for DecoderPictureGuard { } } +/// Parse the coded bit depth (8, 10, or 12) from the AV1 sequence header in +/// raw OBU bytes, or `None` when the bytes contain no sequence header. +#[cfg(feature = "image-integration")] +fn av1_sequence_header_bit_depth(obus: &[u8]) -> Option { + if obus.is_empty() { + return None; + } + let mut header = MaybeUninit::::uninit(); + let result = unsafe { + // SAFETY: `header` is valid writable storage for one sequence header + // and `obus` is a live slice for the duration of the call. + dav1d_parse_sequence_header( + Some(NonNull::new_unchecked(header.as_mut_ptr())), + NonNull::new(obus.as_ptr().cast_mut()), + obus.len(), + ) + }; + if result.0 != 0 { + return None; + } + // SAFETY: initialized by the successful parse above. + let header = unsafe { header.assume_init() }; + Some(match header.hbd { + 0 => 8, + 1 => 10, + _ => 12, + }) +} + +/// Coded bit depth for the AVIF layout probe, read from the actual AV1 +/// sequence header rather than the av1C flags. The slice decode validates +/// its output depth against the decoded frame (and the direct API trusts +/// only the decoded frame), so trusting flags that can disagree with the +/// coded stream would advertise a layout `read_image` then rejects. The +/// sequence header normally sits in av1C's configOBUs; fall back to the +/// primary item payload when it is not there. +#[cfg(feature = "image-integration")] +fn avif_probe_source_bit_depth( + input: &[u8], + meta: &isobmff::MetaBox<'_>, + resolved: &isobmff::ResolvedPrimaryItemGraph<'_>, + config_obus: &[u8], +) -> Result { + if let Some(bit_depth) = av1_sequence_header_bit_depth(config_obus) { + return Ok(bit_depth); + } + let mut source: Option<&mut dyn RandomAccessSource> = None; + let (_, payload) = isobmff::extract_avif_item_payload_from_location( + input, + &mut source, + meta, + &resolved.primary_item.location, + resolved.primary_item.item_id, + ) + .map_err(DecodeAvifError::ExtractPrimaryPayload)?; + av1_sequence_header_bit_depth(&payload).ok_or(DecodeAvifError::MissingSequenceHeader) +} + fn decode_av1_bitstream_to_image(bitstream: &[u8]) -> Result { let mut settings = MaybeUninit::::uninit(); // SAFETY: `dav1d_default_settings` writes a full valid `Dav1dSettings`. @@ -9240,6 +12357,628 @@ mod tests { [pixel[0], pixel[1], pixel[2]] } + fn test_rgba_pixels(width: u32, height: u32, base: u8) -> Vec { + let mut pixels = Vec::new(); + for y in 0..height { + for x in 0..width { + let value = base.wrapping_add((y * width + x) as u8); + pixels.extend_from_slice(&[ + value, + value.wrapping_add(1), + value.wrapping_add(2), + value.wrapping_add(3), + ]); + } + } + pixels + } + + #[test] + fn direct_grid_orientation_paste_matches_rgba_transform_path() { + let transforms = [ + isobmff::PrimaryItemTransformProperty::Mirror(isobmff::ImageMirrorProperty { + direction: isobmff::ImageMirrorDirection::Horizontal, + }), + isobmff::PrimaryItemTransformProperty::Rotation(isobmff::ImageRotationProperty { + rotation_ccw_degrees: 90, + }), + ]; + let orientation_transform = + super::rgba_orientation_transform_from_primary_transforms(3, 2, &transforms) + .expect("orientation transform should parse") + .expect("orientation transform should be effective"); + + let left_tile = test_rgba_pixels(2, 2, 10); + let right_tile = test_rgba_pixels(2, 2, 40); + let mut untransformed = vec![0_u8; 3 * 2 * 4]; + super::paste_rgba_tile_with_clip( + &left_tile, + 2, + 2, + &mut untransformed, + 3, + 2, + 0, + 0, + "test RGBA", + ) + .expect("left tile paste should succeed"); + super::paste_rgba_tile_with_clip( + &right_tile, + 2, + 2, + &mut untransformed, + 3, + 2, + 2, + 0, + "test RGBA", + ) + .expect("right tile paste should succeed"); + + let mut direct = vec![ + 0_u8; + orientation_transform.destination_width as usize + * orientation_transform.destination_height as usize + * 4 + ]; + super::paste_transformed_rgba_tile_with_clip( + &left_tile, + 2, + 2, + &mut direct, + &orientation_transform, + 0, + 0, + "test RGBA", + ) + .expect("left transformed paste should succeed"); + super::paste_transformed_rgba_tile_with_clip( + &right_tile, + 2, + 2, + &mut direct, + &orientation_transform, + 2, + 0, + "test RGBA", + ) + .expect("right transformed paste should succeed"); + + let (width, height, transformed) = + super::apply_primary_item_transforms_rgba(3, 2, untransformed, &transforms) + .expect("reference transform should succeed"); + assert_eq!(width, orientation_transform.destination_width); + assert_eq!(height, orientation_transform.destination_height); + assert_eq!(direct, transformed); + } + + #[cfg(feature = "image-integration")] + fn test_hvcc( + nal_arrays: Vec, + nal_length_size: u8, + ) -> super::isobmff::HevcDecoderConfigurationBox { + super::isobmff::HevcDecoderConfigurationBox { + configuration_version: 1, + general_profile_space: 0, + general_tier_flag: false, + general_profile_idc: 1, + general_profile_compatibility_flags: 0, + general_constraint_indicator_flags: [0; 6], + general_level_idc: 0, + min_spatial_segmentation_idc: 0, + parallelism_type: 0, + chroma_format: 1, + bit_depth_luma: 8, + bit_depth_chroma: 8, + avg_frame_rate: 0, + constant_frame_rate: 0, + num_temporal_layers: 1, + temporal_id_nested: true, + nal_length_size, + nal_arrays, + } + } + + /// The layout probe's assembly-free SPS read: hvcC parameter sets are + /// preferred, and `hev1`-style payloads whose parameter sets live only + /// in-stream are scanned in place. Exercised here because real hev1 + /// files are rare enough that the verify corpus may not cover the + /// fallback. + #[cfg(feature = "image-integration")] + #[test] + fn hevc_probe_metadata_scans_payload_when_hvcc_lacks_sps() { + use super::DecodeHeicError; + use super::isobmff::HevcNalArray; + + // hvcC carrying only a VPS (type 32): the probe must fall back to + // the payload, find the SPS NAL (type 33) by its own header, and + // report its parse failure at the payload offset. + let vps_only = test_hvcc( + vec![HevcNalArray { + array_completeness: true, + nal_unit_type: 32, + nal_units: vec![vec![0x40, 0x01]], + }], + 4, + ); + let payload_with_sps = [0, 0, 0, 2, 0x42, 0x01]; + assert!(matches!( + super::decode_hevc_metadata_from_hvcc_or_payload(&vps_only, &payload_with_sps), + Err(DecodeHeicError::SpsParseFailed { offset: 4, .. }) + )); + + // No SPS in hvcC or payload. + let payload_without_sps = [0, 0, 0, 2, 0x40, 0x01]; + assert!(matches!( + super::decode_hevc_metadata_from_hvcc_or_payload(&vps_only, &payload_without_sps), + Err(DecodeHeicError::MissingSpsNalUnit) + )); + + // An hvcC SPS wins before any payload bytes are considered: the + // truncated SPS in hvcC is reported at offset 0, not the payload's. + let sps_in_hvcc = test_hvcc( + vec![HevcNalArray { + array_completeness: true, + nal_unit_type: 33, + nal_units: vec![vec![0x42, 0x01]], + }], + 4, + ); + assert!(matches!( + super::decode_hevc_metadata_from_hvcc_or_payload(&sps_in_hvcc, &payload_with_sps), + Err(DecodeHeicError::SpsParseFailed { offset: 0, .. }) + )); + + // Malformed payload structure still fails loudly during the scan. + assert!(matches!( + super::decode_hevc_metadata_from_hvcc_or_payload(&vps_only, &[0, 0]), + Err(DecodeHeicError::TruncatedNalLengthField { .. }) + )); + assert!(matches!( + super::decode_hevc_metadata_from_hvcc_or_payload(&vps_only, &[0, 0, 0, 9, 0x42]), + Err(DecodeHeicError::TruncatedNalUnit { .. }) + )); + + // nal_length_size outside 1..=4 is rejected up front, matching the + // stream-assembly path. + let bad_length_size = test_hvcc(Vec::new(), 0); + assert!(matches!( + super::decode_hevc_metadata_from_hvcc_or_payload(&bad_length_size, &payload_with_sps), + Err(DecodeHeicError::InvalidNalLengthSize { nal_length_size: 0 }) + )); + } + + #[cfg(feature = "image-integration")] + #[test] + fn av1_sequence_header_bit_depth_reads_coded_depth() { + // Minimal monochrome reduced-still-picture sequence-header OBUs, + // preceded by a temporal-delimiter OBU to exercise the OBU scan the + // layout probe relies on for av1C configOBUs. + let eight_bit = [0x12, 0x00, 0x0A, 0x04, 0x18, 0x00, 0x00, 0x15]; + let ten_bit = [0x12, 0x00, 0x0A, 0x04, 0x18, 0x00, 0x00, 0x35]; + assert_eq!(super::av1_sequence_header_bit_depth(&eight_bit), Some(8)); + assert_eq!(super::av1_sequence_header_bit_depth(&ten_bit), Some(10)); + assert_eq!(super::av1_sequence_header_bit_depth(&[]), None); + // A lone temporal delimiter has no sequence header to find. + assert_eq!(super::av1_sequence_header_bit_depth(&[0x12, 0x00]), None); + } + + #[test] + fn grid_gap_pixel_matches_plane_canvas_conversion() { + // libheif composes grids on a zero-filled YUV canvas and converts the + // whole canvas, so tile-uncovered pixels must decode to the converted + // all-zero sample (opaque), never transparent black. + let reference = super::HeicGridTileReference { + tile_width: 3, + tile_height: 2, + layout: super::HeicPixelLayout::Yuv420, + bit_depth_luma: 8, + bit_depth_chroma: 8, + ycbcr_range: super::YCbCrRange::Limited, + ycbcr_matrix: super::YCbCrMatrixCoefficients::default(), + conversion_ycbcr_range: super::YCbCrRange::Limited, + conversion_ycbcr_matrix: super::YCbCrMatrixCoefficients::default(), + }; + let gap_pixel = + super::heic_grid_gap_rgba_pixel(&reference, super::convert_heic_to_rgba8_into) + .expect("gap pixel conversion should succeed"); + + // Reference: a zero-filled 2x2 plane canvas converted whole, exactly + // what the plane-canvas grid path produces for gap pixels. + let zero_canvas = super::DecodedHeicImage { + width: 2, + height: 2, + bit_depth_luma: 8, + bit_depth_chroma: 8, + layout: super::HeicPixelLayout::Yuv420, + ycbcr_range: super::YCbCrRange::Limited, + ycbcr_matrix: super::YCbCrMatrixCoefficients::default(), + y_plane: super::HeicPlane { + width: 2, + height: 2, + samples: vec![0; 4], + }, + u_plane: Some(super::HeicPlane { + width: 1, + height: 1, + samples: vec![0], + }), + v_plane: Some(super::HeicPlane { + width: 1, + height: 1, + samples: vec![0], + }), + }; + let converted = + super::convert_heic_to_rgba8(&zero_canvas).expect("zero canvas should convert"); + assert_eq!(gap_pixel.as_slice(), &converted[..4]); + // Opaque, unlike the transparent black a zero-filled RGBA buffer + // would produce. + assert_eq!(gap_pixel[3], u8::MAX); + + let descriptor = super::isobmff::HeicGridDescriptor { + version: 0, + rows: 1, + columns: 3, + output_width: 10, + output_height: 2, + }; + // 3x3 tiles cover 9 of 10 columns: gap fill required. + assert!(!super::heic_grid_tiles_cover_descriptor( + &descriptor, + &reference + )); + let covering = super::HeicGridTileReference { + tile_width: 4, + ..reference + }; + assert!(super::heic_grid_tiles_cover_descriptor( + &descriptor, + &covering + )); + } + + #[test] + fn unaligned_grid_tile_origin_is_rejected() { + // Mirrors the plane-canvas paste path: tile origins that are not + // chroma-aligned must fail loudly instead of silently pasting tiles + // whose seams sample chroma with a shifted phase. + assert!( + super::validate_heic_grid_tile_origin_alignment(super::HeicPixelLayout::Yuv420, 99, 0) + .is_err() + ); + assert!( + super::validate_heic_grid_tile_origin_alignment(super::HeicPixelLayout::Yuv420, 0, 99) + .is_err() + ); + assert!( + super::validate_heic_grid_tile_origin_alignment(super::HeicPixelLayout::Yuv420, 98, 98) + .is_ok() + ); + + // 4:2:2 chroma is only horizontally subsampled. + assert!( + super::validate_heic_grid_tile_origin_alignment(super::HeicPixelLayout::Yuv422, 99, 0) + .is_err() + ); + assert!( + super::validate_heic_grid_tile_origin_alignment(super::HeicPixelLayout::Yuv422, 0, 99) + .is_ok() + ); + + // 4:4:4 and monochrome have no subsampling to misalign. + assert!( + super::validate_heic_grid_tile_origin_alignment(super::HeicPixelLayout::Yuv444, 99, 99) + .is_ok() + ); + assert!( + super::validate_heic_grid_tile_origin_alignment(super::HeicPixelLayout::Yuv400, 99, 99) + .is_ok() + ); + } + + // Clean-aperture chroma-phase tests: libheif refuses to crop subsampled + // planes when the crop origin is chroma-unaligned and converts to 4:4:4 + // first (libheif/libheif/pixelimage.cc:HeifPixelImage::crop), so the + // YUV-space clap fast path must only run for aligned crops; unaligned + // crops fall back to cropping after RGBA conversion. + + /// 8x8 test image whose luma and chroma gradients stay far from the + /// clamping range, so a one-sample chroma phase shift always changes the + /// converted RGBA output. + fn synthetic_yuv_image(layout: super::HeicPixelLayout) -> super::DecodedHeicImage { + let width = 8_u32; + let height = 8_u32; + let y_samples = (0..width * height) + .map(|index| (64 + index) as u16) + .collect(); + let (subsample_x, subsample_y) = super::heic_chroma_subsampling(layout); + let chroma_width = width.div_ceil(subsample_x); + let chroma_height = height.div_ceil(subsample_y); + let mut u_samples = Vec::new(); + let mut v_samples = Vec::new(); + for chroma_y in 0..chroma_height { + for chroma_x in 0..chroma_width { + u_samples.push((100 + chroma_x * 12) as u16); + v_samples.push((96 + chroma_y * 12) as u16); + } + } + super::DecodedHeicImage { + width, + height, + bit_depth_luma: 8, + bit_depth_chroma: 8, + layout, + ycbcr_range: super::YCbCrRange::Full, + ycbcr_matrix: super::YCbCrMatrixCoefficients { + matrix_coefficients: 1, + colour_primaries: 1, + }, + y_plane: super::HeicPlane { + width, + height, + samples: y_samples, + }, + u_plane: Some(super::HeicPlane { + width: chroma_width, + height: chroma_height, + samples: u_samples, + }), + v_plane: Some(super::HeicPlane { + width: chroma_width, + height: chroma_height, + samples: v_samples, + }), + } + } + + /// 4x4 clean aperture on the 8x8 test image: the crop origin lands at + /// `(horizontal_offset + 2, vertical_offset + 2)`. + fn synthetic_clean_aperture( + horizontal_offset: i32, + vertical_offset: i32, + ) -> isobmff::ImageCleanApertureProperty { + isobmff::ImageCleanApertureProperty { + clean_aperture_width_num: 4, + clean_aperture_width_den: 1, + clean_aperture_height_num: 4, + clean_aperture_height_den: 1, + horizontal_offset_num: horizontal_offset, + horizontal_offset_den: 1, + vertical_offset_num: vertical_offset, + vertical_offset_den: 1, + } + } + + /// Reference pixels: full-frame RGBA conversion followed by RGBA-domain + /// transforms, the path whose output the clap fast path must reproduce. + fn rgba8_reference_after_transforms( + image: &super::DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + ) -> (u32, u32, Vec) { + let pixels = + super::convert_heic_to_rgba8(image).expect("full-frame conversion should succeed"); + super::apply_primary_item_transforms_rgba(image.width, image.height, pixels, transforms) + .expect("reference RGBA transform should succeed") + } + + #[test] + fn odd_clean_aperture_crop_falls_back_to_rgba_transform_path() { + let image = synthetic_yuv_image(super::HeicPixelLayout::Yuv420); + let clean_aperture = synthetic_clean_aperture(-1, -1); // origin (1, 1) + assert!(!super::heic_clean_aperture_crop_preserves_chroma_phase( + &image, + clean_aperture + )); + + let transforms = [isobmff::PrimaryItemTransformProperty::CleanAperture( + clean_aperture, + )]; + let (reference_width, reference_height, reference_pixels) = + rgba8_reference_after_transforms(&image, &transforms); + assert_eq!((reference_width, reference_height), (4, 4)); + + // The YUV-space crop floors the chroma origin, so its output must + // differ from the reference; otherwise this fixture could not detect + // a regression that re-enables the fast path for odd offsets. + let yuv_cropped = super::crop_heic_by_clean_aperture(image.clone(), clean_aperture) + .expect("YUV clap crop should succeed"); + let phase_shifted = + super::convert_heic_to_rgba8(&yuv_cropped).expect("cropped conversion should succeed"); + assert_ne!( + phase_shifted, reference_pixels, + "fixture must expose the chroma phase shift" + ); + + let decoded = super::decoded_heic_to_rgba_image(image, &transforms, None, None) + .expect("decode should succeed"); + assert_eq!((decoded.width, decoded.height), (4, 4)); + assert_eq!( + decoded.pixels, + super::DecodedRgbaPixels::U8(reference_pixels) + ); + } + + #[test] + fn chroma_aligned_clean_aperture_yuv_crop_matches_rgba_transform_path() { + let image = synthetic_yuv_image(super::HeicPixelLayout::Yuv420); + let clean_aperture = synthetic_clean_aperture(0, 0); // origin (2, 2) + assert!(super::heic_clean_aperture_crop_preserves_chroma_phase( + &image, + clean_aperture + )); + + let transforms = [isobmff::PrimaryItemTransformProperty::CleanAperture( + clean_aperture, + )]; + let (_, _, reference_pixels) = rgba8_reference_after_transforms(&image, &transforms); + + let decoded = super::decoded_heic_to_rgba_image(image, &transforms, None, None) + .expect("decode should succeed"); + assert_eq!((decoded.width, decoded.height), (4, 4)); + assert_eq!( + decoded.pixels, + super::DecodedRgbaPixels::U8(reference_pixels) + ); + } + + #[test] + fn clean_aperture_chroma_phase_guard_mirrors_libheif_conditions() { + let odd_left = synthetic_clean_aperture(-1, 0); // origin (1, 2) + let odd_top = synthetic_clean_aperture(0, -1); // origin (2, 1) + let aligned = synthetic_clean_aperture(0, 0); // origin (2, 2) + + let yuv420 = synthetic_yuv_image(super::HeicPixelLayout::Yuv420); + assert!(!super::heic_clean_aperture_crop_preserves_chroma_phase( + &yuv420, odd_left + )); + assert!(!super::heic_clean_aperture_crop_preserves_chroma_phase( + &yuv420, odd_top + )); + assert!(super::heic_clean_aperture_crop_preserves_chroma_phase( + &yuv420, aligned + )); + + // 4:2:2 chroma is only horizontally subsampled: an odd top offset + // keeps the chroma phase, matching libheif's left-only check. + let yuv422 = synthetic_yuv_image(super::HeicPixelLayout::Yuv422); + assert!(!super::heic_clean_aperture_crop_preserves_chroma_phase( + &yuv422, odd_left + )); + assert!(super::heic_clean_aperture_crop_preserves_chroma_phase( + &yuv422, odd_top + )); + + // 4:4:4 has no subsampling to misalign. + let yuv444 = synthetic_yuv_image(super::HeicPixelLayout::Yuv444); + assert!(super::heic_clean_aperture_crop_preserves_chroma_phase( + &yuv444, odd_left + )); + assert!(super::heic_clean_aperture_crop_preserves_chroma_phase( + &yuv444, odd_top + )); + } + + #[cfg(feature = "image-integration")] + #[test] + fn odd_clean_aperture_slice_decode_matches_rgba_transform_path() { + let image = synthetic_yuv_image(super::HeicPixelLayout::Yuv420); + let clean_aperture = synthetic_clean_aperture(-1, -1); // origin (1, 1) + let transforms = [isobmff::PrimaryItemTransformProperty::CleanAperture( + clean_aperture, + )]; + let (_, _, reference_pixels) = rgba8_reference_after_transforms(&image, &transforms); + + let mut out = vec![0_u8; reference_pixels.len()]; + super::decoded_heic_to_rgba8_slice(image, &transforms, None, &mut out) + .expect("slice decode should succeed"); + assert_eq!(out, reference_pixels); + } + + #[cfg(feature = "image-integration")] + #[test] + fn transformed_alpha_rgba16_byte_output_matches_owned_path_when_unaligned() { + let mut image = synthetic_yuv_image(super::HeicPixelLayout::Yuv420); + image.bit_depth_luma = 10; + image.bit_depth_chroma = 10; + let alpha = super::HeicAuxiliaryAlphaPlane { + width: image.width, + height: image.height, + bit_depth: 10, + samples: (0..image.width * image.height) + .map(|index| ((index * 13) % 1024) as u16) + .collect(), + }; + let transforms = [ + isobmff::PrimaryItemTransformProperty::CleanAperture(synthetic_clean_aperture(-1, -1)), + isobmff::PrimaryItemTransformProperty::Mirror(isobmff::ImageMirrorProperty { + direction: isobmff::ImageMirrorDirection::Horizontal, + }), + isobmff::PrimaryItemTransformProperty::Rotation(isobmff::ImageRotationProperty { + rotation_ccw_degrees: 90, + }), + ]; + + let owned = + super::decoded_heic_to_rgba_image(image.clone(), &transforms, Some(&alpha), None) + .expect("owned transformed decode should succeed"); + let expected = match owned.pixels { + super::DecodedRgbaPixels::U16(pixels) => pixels, + other => panic!("expected RGBA16 pixels, got {other:?}"), + }; + + let mut storage = vec![0_u8; expected.len() * 2 + 1]; + let unaligned = &mut storage[1..]; + super::decoded_heic_to_rgba16_native_endian_bytes( + image, + &transforms, + Some(&alpha), + unaligned, + ) + .expect("unaligned direct byte decode should succeed"); + let actual: Vec = unaligned + .chunks_exact(2) + .map(|chunk| u16::from_ne_bytes([chunk[0], chunk[1]])) + .collect(); + assert_eq!(actual, expected); + } + + #[cfg(feature = "image-integration")] + #[test] + fn transformed_avif_rgba16_byte_output_matches_owned_path() { + let width = 3; + let height = 2; + let decoded = super::DecodedAvifImage { + width, + height, + bit_depth: 10, + layout: super::AvifPixelLayout::Yuv400, + ycbcr_range: super::YCbCrRange::Full, + ycbcr_matrix: super::YCbCrMatrixCoefficients { + matrix_coefficients: 1, + colour_primaries: 1, + }, + y_plane: super::AvifPlane { + width, + height, + samples: super::AvifPlaneSamples::U16(vec![64, 128, 256, 512, 768, 960]), + }, + u_plane: None, + v_plane: None, + alpha_plane: Some(super::AvifAuxiliaryAlphaPlane { + width, + height, + bit_depth: 10, + samples: super::AvifPlaneSamples::U16(vec![0, 100, 200, 400, 800, 1023]), + }), + }; + let transforms = [ + isobmff::PrimaryItemTransformProperty::Mirror(isobmff::ImageMirrorProperty { + direction: isobmff::ImageMirrorDirection::Vertical, + }), + isobmff::PrimaryItemTransformProperty::Rotation(isobmff::ImageRotationProperty { + rotation_ccw_degrees: 270, + }), + ]; + let owned = super::decoded_avif_to_rgba_image(&decoded, &transforms, None) + .expect("owned AVIF conversion should succeed"); + let expected = match owned.pixels { + super::DecodedRgbaPixels::U16(pixels) => pixels, + other => panic!("expected RGBA16 pixels, got {other:?}"), + }; + + let mut bytes = vec![0_u8; expected.len() * 2]; + let mut output = super::NativeEndianRgba16Output(&mut bytes); + super::decoded_avif_to_rgba16_output(&decoded, &transforms, &mut output) + .expect("direct AVIF byte conversion should succeed"); + let actual: Vec = bytes + .chunks_exact(2) + .map(|chunk| u16::from_ne_bytes([chunk[0], chunk[1]])) + .collect(); + assert_eq!(actual, expected); + } + #[test] fn bt709_family_profiles_are_srgb_identity_in_qcms() { for transfer in [1u16, 6, 14, 15] { @@ -9392,4 +13131,63 @@ mod tests { assert!(nclx_to_icc_profile(&nclx).is_none()); } + + #[test] + fn synthesized_icc_creation_time_is_deterministic() { + let first = synthesize_nclx_icc(1, 13, 1).expect("expected synthesized ICC"); + let second = synthesize_nclx_icc(1, 13, 1).expect("expected synthesized ICC"); + assert_eq!(first, second); + assert_eq!( + &first[24..36], + &[0x07, 0xB2, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0], + "ICC dateTimeNumber must stay pinned to 1970-01-01T00:00:00" + ); + } + + // Lazy-image-adapter contract tests: uncompressed HEIF must expose a + // layout without decoding to owned RGBA, then write pixel-identical output + // directly into the caller's slice. + + #[test] + fn eager_decode_supports_minimal_uncompressed_heif() { + let (file, expected_rgba) = isobmff::test_support::minimal_uncompressed_rgb3_heif(); + + let decoded = super::decode_bytes_to_rgba(&file).expect("eager decode should succeed"); + assert_eq!((decoded.width, decoded.height), (2, 1)); + assert_eq!(decoded.pixels, super::DecodedRgbaPixels::U8(expected_rgba)); + } + + #[cfg(feature = "image-integration")] + #[test] + fn lazy_layout_probe_accepts_uncompressed_heif() { + let (file, _) = isobmff::test_support::minimal_uncompressed_rgb3_heif(); + + let layout = super::decode_bytes_to_rgba_layout_with_hint_and_guardrails( + &file, + None, + super::DecodeGuardrails::default(), + ) + .expect("layout probe must accept uncompressed HEIF") + .layout; + assert_eq!((layout.width, layout.height), (2, 1)); + assert_eq!(layout.source_bit_depth, 8); + assert_eq!(layout.storage_bit_depth, 8); + } + + #[cfg(feature = "image-integration")] + #[test] + fn lazy_slice_decodes_uncompressed_heif() { + let (file, expected_rgba) = isobmff::test_support::minimal_uncompressed_rgb3_heif(); + + let mut rgba8 = vec![0_u8; expected_rgba.len()]; + super::decode_bytes_to_rgba8_slice_with_hint_and_guardrails( + &file, + None, + super::DecodeGuardrails::default(), + None, + &mut rgba8, + ) + .expect("RGBA8 slice decode must support uncompressed HEIF"); + assert_eq!(rgba8, expected_rgba); + } }