From 0aba843067ccebe16305a88300560a16fe9f20d6 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 12:56:46 +0530 Subject: [PATCH 01/38] Reduce HEIC decode peak RSS --- src/lib.rs | 1074 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 849 insertions(+), 225 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 505ce73..dd766d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3969,29 +3969,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 +3980,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,149 +4111,646 @@ 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 { +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; + 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 + ), + } + .into()); + } + + 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 x-origin overflow for column {column} with tile width {tile_width}" + "grid tile count overflow for {}x{} descriptor", + descriptor.columns, descriptor.rows ), })?; - 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 { - detail: format!( - "grid tile y-origin overflow for row {row} with tile height {tile_height}" - ), + 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() + ), + } + .into()); + } + + 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(descriptor, &first_tile)?; - paste_heic_plane_with_clip( - &tile.y_plane, - &mut output.y_plane, - x_origin, - y_origin, - "grid tile Y", + let tile_width = first_tile.width; + let tile_height = first_tile.height; + let reference_layout = first_tile.layout; + let reference_bit_depth_luma = first_tile.bit_depth_luma; + let reference_bit_depth_chroma = first_tile.bit_depth_chroma; + let reference_ycbcr_range = first_tile.ycbcr_range; + let reference_ycbcr_matrix = first_tile.ycbcr_matrix; + let conversion_ycbcr_range = + ycbcr_range_override_from_primary_colr(&grid_data.colr).unwrap_or(reference_ycbcr_range); + let conversion_ycbcr_matrix = + ycbcr_matrix_override_from_primary_colr(&grid_data.colr).unwrap_or(reference_ycbcr_matrix); + let source_bit_depth = heic_bit_depth_for_png_conversion(&first_tile)?; + + if source_bit_depth <= 8 { + let mut output = + vec![ + 0_u8; + checked_rgba_sample_count(descriptor.output_width, descriptor.output_height)? + ]; + paste_heic_grid_tiles_to_rgba8( + grid_data, + first_tile, + &mut output, + tile_width, + tile_height, + reference_layout, + reference_bit_depth_luma, + reference_bit_depth_chroma, + reference_ycbcr_range, + reference_ycbcr_matrix, + conversion_ycbcr_range, + conversion_ycbcr_matrix, + )?; + if let Some(alpha) = auxiliary_alpha { + apply_auxiliary_alpha_to_rgba8( + &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, + )?; + return Ok(DecodedRgbaImage { + width, + height, + source_bit_depth, + pixels: DecodedRgbaPixels::U8(transformed), + icc_profile, + }); + } - if output.layout == HeicPixelLayout::Yuv400 { - continue; - } + let mut output = + vec![0_u16; checked_rgba_sample_count(descriptor.output_width, descriptor.output_height)?]; + paste_heic_grid_tiles_to_rgba16( + grid_data, + first_tile, + &mut output, + tile_width, + tile_height, + reference_layout, + reference_bit_depth_luma, + reference_bit_depth_chroma, + reference_ycbcr_range, + reference_ycbcr_matrix, + conversion_ycbcr_range, + conversion_ycbcr_matrix, + )?; + if let Some(alpha) = auxiliary_alpha { + apply_auxiliary_alpha_to_rgba16( + &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: DecodedRgbaPixels::U16(transformed), + icc_profile, + }) +} - 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 - ), - }); +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}"), + }); + } + + 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(()) +} + +#[allow(clippy::too_many_arguments)] +fn validate_decoded_heic_grid_tile_reference( + tile: &DecodedHeicImage, + tile_width: u32, + tile_height: u32, + reference_layout: HeicPixelLayout, + reference_bit_depth_luma: u8, + reference_bit_depth_chroma: u8, + reference_ycbcr_range: YCbCrRange, + reference_ycbcr_matrix: YCbCrMatrixCoefficients, + tile_index: usize, +) -> Result<(), DecodeHeicError> { + 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 != 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 tiles have inconsistent YCbCr metadata at index {tile_index}"), + }); + } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn paste_heic_grid_tiles_to_rgba8( + grid_data: &isobmff::HeicGridPrimaryItemData, + first_tile: DecodedHeicImage, + output: &mut [u8], + tile_width: u32, + tile_height: u32, + reference_layout: HeicPixelLayout, + reference_bit_depth_luma: u8, + reference_bit_depth_chroma: u8, + reference_ycbcr_range: YCbCrRange, + reference_ycbcr_matrix: YCbCrMatrixCoefficients, + conversion_ycbcr_range: YCbCrRange, + conversion_ycbcr_matrix: YCbCrMatrixCoefficients, +) -> Result<(), DecodeError> { + paste_heic_grid_tiles_to_rgba( + grid_data, + first_tile, + output, + tile_width, + tile_height, + reference_layout, + reference_bit_depth_luma, + reference_bit_depth_chroma, + reference_ycbcr_range, + reference_ycbcr_matrix, + conversion_ycbcr_range, + conversion_ycbcr_matrix, + convert_heic_to_rgba8, + ) +} + +#[allow(clippy::too_many_arguments)] +fn paste_heic_grid_tiles_to_rgba16( + grid_data: &isobmff::HeicGridPrimaryItemData, + first_tile: DecodedHeicImage, + output: &mut [u16], + tile_width: u32, + tile_height: u32, + reference_layout: HeicPixelLayout, + reference_bit_depth_luma: u8, + reference_bit_depth_chroma: u8, + reference_ycbcr_range: YCbCrRange, + reference_ycbcr_matrix: YCbCrMatrixCoefficients, + conversion_ycbcr_range: YCbCrRange, + conversion_ycbcr_matrix: YCbCrMatrixCoefficients, +) -> Result<(), DecodeError> { + paste_heic_grid_tiles_to_rgba( + grid_data, + first_tile, + output, + tile_width, + tile_height, + reference_layout, + reference_bit_depth_luma, + reference_bit_depth_chroma, + reference_ycbcr_range, + reference_ycbcr_matrix, + conversion_ycbcr_range, + conversion_ycbcr_matrix, + convert_heic_to_rgba16, + ) +} + +#[allow(clippy::too_many_arguments)] +fn paste_heic_grid_tiles_to_rgba( + grid_data: &isobmff::HeicGridPrimaryItemData, + first_tile: DecodedHeicImage, + output: &mut [T], + tile_width: u32, + tile_height: u32, + reference_layout: HeicPixelLayout, + reference_bit_depth_luma: u8, + reference_bit_depth_chroma: u8, + reference_ycbcr_range: YCbCrRange, + reference_ycbcr_matrix: YCbCrMatrixCoefficients, + conversion_ycbcr_range: YCbCrRange, + conversion_ycbcr_matrix: YCbCrMatrixCoefficients, + convert_tile: fn(&DecodedHeicImage) -> Result, DecodeHeicError>, +) -> Result<(), DecodeError> { + let descriptor = &grid_data.descriptor; + let columns = usize::from(descriptor.columns); + let mut first_tile = Some(first_tile); + 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, + tile_width, + tile_height, + reference_layout, + reference_bit_depth_luma, + reference_bit_depth_chroma, + reference_ycbcr_range, + reference_ycbcr_matrix, + tile_index, + )?; + tile.ycbcr_range = conversion_ycbcr_range; + tile.ycbcr_matrix = conversion_ycbcr_matrix; + let tile_pixels = convert_tile(&tile)?; + let row = tile_index / columns; + let column = tile_index % columns; + let (x_origin, y_origin) = heic_grid_tile_origin(tile_width, tile_height, row, column)?; + 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", + )?; + } + + Ok(()) +} + +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 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 { + detail: format!("grid tile y-origin overflow for row {row} with tile height {tile_height}"), + })?; + + Ok((x_origin, y_origin)) +} + +#[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(()); + } + + 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 destination_width_usize = + usize::try_from(destination_width).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination width {destination_width} cannot be represented"), + })?; + let destination_height_usize = + usize::try_from(destination_height).map_err(|_| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination height {destination_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"), + })?; + + let source_samples = source_width_usize + .checked_mul(source_height_usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} source sample count overflow for {source_width}x{source_height}" + ), + })?; + if source.len() != source_samples { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} source has {} samples, expected {source_samples}", + source.len() + ), + }); + } + + let destination_samples = destination_width_usize + .checked_mul(destination_height_usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination sample count overflow for {destination_width}x{destination_height}" + ), + })?; + if destination.len() != destination_samples { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination has {} samples, expected {destination_samples}", + destination.len() + ), + }); + } + + 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} 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_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} destination row start overflow at row {row}"), + })?; + let destination_end = destination_start.checked_add(copy_samples).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: format!("{plane} destination row end overflow at row {row}"), } + })?; - 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", - )?; + destination[destination_start..destination_end] + .copy_from_slice(&source[source_start..source_end]); + } - 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(()) +} + +#[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> { + 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}"), + }); + } + + 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 { + detail: format!("grid tile y-origin overflow for row {row} with tile height {tile_height}"), + })?; + + 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(()); } - Ok(output) + 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 + ), + }); + } + + 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( @@ -4591,20 +5122,14 @@ fn decode_hevc_stream_to_image(stream: &[u8]) -> Result Result { +fn heic_frame_to_internal_image(frame: HeicFrame) -> Result { let width = frame.cropped_width(); let height = frame.cropped_height(); if width == 0 || height == 0 { @@ -4614,11 +5139,32 @@ fn heic_frame_to_internal_image(frame: &HeicFrame) -> Result Result (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) + 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=({}, {}, {}, {})", - frame.crop_left, frame.crop_right, frame.crop_top, frame.crop_bottom + 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_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(), + 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 = extract_cropped_heic_plane( - &frame.cr_plane, - frame.c_stride(), + 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, @@ -4671,22 +5223,15 @@ fn heic_frame_to_internal_image(frame: &HeicFrame) -> Result (u32, u32) { } } +#[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, @@ -5449,20 +6027,6 @@ struct HeicAuxiliaryAlphaPlane { samples: Vec, } -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, - ) -} - fn decode_primary_heic_auxiliary_alpha_plane_internal( input: &[u8], source: &mut Option<&mut dyn RandomAccessSource>, @@ -5765,11 +6329,14 @@ fn decode_heif_bytes_to_rgba( .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) + 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( @@ -5796,15 +6363,69 @@ fn decode_heif_source_to_rgba( .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( + decode_primary_heic_to_rgba_from_resolved_input( input, &mut source, - decoded.width, - decoded.height, - ); - decoded_heic_to_rgba_image(&decoded, &transforms, auxiliary_alpha.as_ref(), icc_profile) + 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) => { + guardrails.enforce_pixel_count( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )?; + let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + input, + source, + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + ); + decode_primary_heic_grid_to_rgba_image( + &grid_data, + transforms, + auxiliary_alpha.as_ref(), + icc_profile, + ) + } + 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)?; + guardrails.enforce_pixel_count(decoded.width, decoded.height)?; + let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + input, + source, + decoded.width, + decoded.height, + ); + decoded_heic_to_rgba_image(&decoded, transforms, auxiliary_alpha.as_ref(), icc_profile) + } + } } fn decode_avif_bytes_to_png( @@ -7169,6 +7790,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, From 7dc36044136579d74290eea074e49e852a766d41 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 13:17:09 +0530 Subject: [PATCH 02/38] Avoid copying no-op clean aperture transforms --- src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index dd766d4..3b8de08 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7782,7 +7782,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; @@ -7989,7 +7989,7 @@ fn mirror_rgba( fn crop_rgba_by_clean_aperture( width: u32, height: u32, - pixels: &[T], + pixels: Vec, clean_aperture: isobmff::ImageCleanApertureProperty, ) -> Result<(u32, u32, Vec), DecodeError> { if width == 0 || height == 0 { @@ -8055,6 +8055,10 @@ fn crop_rgba_by_clean_aperture( }) })?; + if left == 0 && 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", From 49fd7994641051cea61248eb3c2083c38479efee Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 13:25:51 +0530 Subject: [PATCH 03/38] Apply HEIC clean aperture before RGBA conversion --- src/lib.rs | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3b8de08..458eb0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4840,6 +4840,14 @@ fn crop_heic_by_clean_aperture( detail: format!("grid tile clean-aperture top bound is out of range: {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, @@ -6423,7 +6431,7 @@ fn decode_primary_heic_to_rgba_from_resolved_input( decoded.width, decoded.height, ); - decoded_heic_to_rgba_image(&decoded, transforms, auxiliary_alpha.as_ref(), icc_profile) + decoded_heic_to_rgba_image(decoded, transforms, auxiliary_alpha.as_ref(), icc_profile) } } } @@ -7492,19 +7500,33 @@ 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() { + while let Some(isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture)) = + remaining_transforms.first() + { + decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; + remaining_transforms = &remaining_transforms[1..]; + } + } + + 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, @@ -7514,12 +7536,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, From 7fa78cbb5e66267b198e3b956e096ae161ddb3af Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 13:35:02 +0530 Subject: [PATCH 04/38] Reuse grid tile RGBA scratch buffer --- src/lib.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 458eb0c..3529585 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4354,7 +4354,7 @@ fn paste_heic_grid_tiles_to_rgba8( reference_ycbcr_matrix, conversion_ycbcr_range, conversion_ycbcr_matrix, - convert_heic_to_rgba8, + convert_heic_to_rgba8_into, ) } @@ -4386,7 +4386,7 @@ fn paste_heic_grid_tiles_to_rgba16( reference_ycbcr_matrix, conversion_ycbcr_range, conversion_ycbcr_matrix, - convert_heic_to_rgba16, + convert_heic_to_rgba16_into, ) } @@ -4404,11 +4404,12 @@ fn paste_heic_grid_tiles_to_rgba( reference_ycbcr_matrix: YCbCrMatrixCoefficients, conversion_ycbcr_range: YCbCrRange, conversion_ycbcr_matrix: YCbCrMatrixCoefficients, - convert_tile: fn(&DecodedHeicImage) -> Result, DecodeHeicError>, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, ) -> Result<(), DecodeError> { let descriptor = &grid_data.descriptor; let columns = usize::from(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 @@ -4432,7 +4433,7 @@ fn paste_heic_grid_tiles_to_rgba( )?; tile.ycbcr_range = conversion_ycbcr_range; tile.ycbcr_matrix = conversion_ycbcr_matrix; - let tile_pixels = convert_tile(&tile)?; + 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(tile_width, tile_height, row, column)?; @@ -8619,6 +8620,15 @@ 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 ycbcr_transform = ycbcr_transform_from_matrix(decoded.ycbcr_matrix).map_err(|matrix_coefficients| { DecodeHeicError::UnsupportedMatrixCoefficients { @@ -8656,7 +8666,7 @@ fn convert_heic_to_rgba8(decoded: &DecodedHeicImage) -> Result, DecodeHe decoded.width, decoded.height ), })?; - let mut out = vec![0_u8; output_len]; + out.resize(output_len, 0); let chroma = prepare_heic_chroma(decoded)?; let chroma_midpoint = chroma_midpoint(bit_depth); @@ -8711,10 +8721,19 @@ 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 ycbcr_transform = ycbcr_transform_from_matrix(decoded.ycbcr_matrix).map_err(|matrix_coefficients| { DecodeHeicError::UnsupportedMatrixCoefficients { @@ -8752,7 +8771,7 @@ fn convert_heic_to_rgba16(decoded: &DecodedHeicImage) -> Result, Decode decoded.width, decoded.height ), })?; - let mut out = vec![0_u16; output_len]; + out.resize(output_len, 0); let chroma = prepare_heic_chroma(decoded)?; let chroma_midpoint = chroma_midpoint(bit_depth); @@ -8796,7 +8815,7 @@ fn convert_heic_to_rgba16(decoded: &DecodedHeicImage) -> Result, Decode } } - Ok(out) + Ok(()) } enum AvifAuxiliaryAlphaSamples<'a> { From 9eef4d7374cbb4d6a6189993a55a86aeb7a36a7f Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 14:46:19 +0530 Subject: [PATCH 05/38] Write oriented HEIC grids directly to RGBA --- src/lib.rs | 608 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 590 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3529585..30ac544 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4175,13 +4175,30 @@ fn decode_primary_heic_grid_to_rgba_image( let conversion_ycbcr_matrix = ycbcr_matrix_override_from_primary_colr(&grid_data.colr).unwrap_or(reference_ycbcr_matrix); let source_bit_depth = heic_bit_depth_for_png_conversion(&first_tile)?; + // Direct orientation is safe for opaque grids. Alpha and clean aperture + // stay on the existing source-coordinate transform path. + let direct_orientation_transform = if auxiliary_alpha.is_none() { + rgba_orientation_transform_from_primary_transforms( + descriptor.output_width, + descriptor.output_height, + transforms, + )? + } else { + None + }; + let output_width = direct_orientation_transform + .as_ref() + .map_or(descriptor.output_width, |transform| { + transform.destination_width + }); + let output_height = direct_orientation_transform + .as_ref() + .map_or(descriptor.output_height, |transform| { + transform.destination_height + }); if source_bit_depth <= 8 { - let mut output = - vec![ - 0_u8; - checked_rgba_sample_count(descriptor.output_width, descriptor.output_height)? - ]; + let mut output = vec![0_u8; checked_rgba_sample_count(output_width, output_height)?]; paste_heic_grid_tiles_to_rgba8( grid_data, first_tile, @@ -4195,7 +4212,17 @@ fn decode_primary_heic_grid_to_rgba_image( reference_ycbcr_matrix, conversion_ycbcr_range, conversion_ycbcr_matrix, + direct_orientation_transform.as_ref(), )?; + if direct_orientation_transform.is_some() { + return Ok(DecodedRgbaImage { + width: output_width, + height: output_height, + source_bit_depth, + pixels: DecodedRgbaPixels::U8(output), + icc_profile, + }); + } if let Some(alpha) = auxiliary_alpha { apply_auxiliary_alpha_to_rgba8( &mut output, @@ -4219,8 +4246,7 @@ fn decode_primary_heic_grid_to_rgba_image( }); } - let mut output = - vec![0_u16; checked_rgba_sample_count(descriptor.output_width, descriptor.output_height)?]; + let mut output = vec![0_u16; checked_rgba_sample_count(output_width, output_height)?]; paste_heic_grid_tiles_to_rgba16( grid_data, first_tile, @@ -4234,7 +4260,17 @@ fn decode_primary_heic_grid_to_rgba_image( reference_ycbcr_matrix, conversion_ycbcr_range, conversion_ycbcr_matrix, + direct_orientation_transform.as_ref(), )?; + if direct_orientation_transform.is_some() { + return Ok(DecodedRgbaImage { + width: output_width, + height: output_height, + source_bit_depth, + pixels: DecodedRgbaPixels::U16(output), + icc_profile, + }); + } if let Some(alpha) = auxiliary_alpha { apply_auxiliary_alpha_to_rgba16( &mut output, @@ -4340,6 +4376,7 @@ fn paste_heic_grid_tiles_to_rgba8( reference_ycbcr_matrix: YCbCrMatrixCoefficients, conversion_ycbcr_range: YCbCrRange, conversion_ycbcr_matrix: YCbCrMatrixCoefficients, + orientation_transform: Option<&RgbaOrientationTransform>, ) -> Result<(), DecodeError> { paste_heic_grid_tiles_to_rgba( grid_data, @@ -4354,6 +4391,7 @@ fn paste_heic_grid_tiles_to_rgba8( reference_ycbcr_matrix, conversion_ycbcr_range, conversion_ycbcr_matrix, + orientation_transform, convert_heic_to_rgba8_into, ) } @@ -4372,6 +4410,7 @@ fn paste_heic_grid_tiles_to_rgba16( reference_ycbcr_matrix: YCbCrMatrixCoefficients, conversion_ycbcr_range: YCbCrRange, conversion_ycbcr_matrix: YCbCrMatrixCoefficients, + orientation_transform: Option<&RgbaOrientationTransform>, ) -> Result<(), DecodeError> { paste_heic_grid_tiles_to_rgba( grid_data, @@ -4386,6 +4425,7 @@ fn paste_heic_grid_tiles_to_rgba16( reference_ycbcr_matrix, conversion_ycbcr_range, conversion_ycbcr_matrix, + orientation_transform, convert_heic_to_rgba16_into, ) } @@ -4404,6 +4444,7 @@ fn paste_heic_grid_tiles_to_rgba( reference_ycbcr_matrix: YCbCrMatrixCoefficients, conversion_ycbcr_range: YCbCrRange, conversion_ycbcr_matrix: YCbCrMatrixCoefficients, + orientation_transform: Option<&RgbaOrientationTransform>, convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, ) -> Result<(), DecodeError> { let descriptor = &grid_data.descriptor; @@ -4437,17 +4478,30 @@ fn paste_heic_grid_tiles_to_rgba( let row = tile_index / columns; let column = tile_index % columns; let (x_origin, y_origin) = heic_grid_tile_origin(tile_width, tile_height, row, column)?; - 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", - )?; + 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", + )?; + } } Ok(()) @@ -4491,6 +4545,283 @@ fn heic_grid_tile_origin( Ok((x_origin, y_origin)) } +#[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, +} + +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 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, + )?, + )) + } +} + +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) +} + +#[derive(Clone, Copy, Debug)] +struct RgbaOrientationAxis { + from_source_x: i64, + from_source_y: i64, + offset: i64, +} + +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, + } +} + +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 rotate_orientation_axes_270_ccw( + x_axis: RgbaOrientationAxis, + y_axis: RgbaOrientationAxis, + height: u32, +) -> (RgbaOrientationAxis, RgbaOrientationAxis) { + (flipped_orientation_axis(y_axis, height), x_axis) +} + +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), + ) +} + +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 }, + )); + } + + 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, + }; + + 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); + } + } + } + } + } + + if !effective { + return Ok(None); + } + + 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, + })) +} + #[allow(clippy::too_many_arguments)] fn paste_rgba_tile_with_clip( source: &[T], @@ -4617,6 +4948,151 @@ fn paste_rgba_tile_with_clip( Ok(()) } +#[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"), + })?; + + let source_samples = source_width_usize + .checked_mul(source_height_usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} source sample count overflow for {source_width}x{source_height}" + ), + })?; + if source.len() != source_samples { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} source has {} samples, expected {source_samples}", + source.len() + ), + } + .into()); + } + + let destination_samples = orientation_transform + .destination_width_usize + .checked_mul(orientation_transform.destination_height_usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination sample count overflow for {}x{}", + orientation_transform.destination_width, orientation_transform.destination_height + ), + })?; + if destination.len() != destination_samples { + return Err(DecodeHeicError::InvalidDecodedFrame { + detail: format!( + "{plane} destination has {} samples, expected {destination_samples}", + destination.len() + ), + } + .into()); + } + + 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, @@ -9913,6 +10389,102 @@ 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); + } + #[test] fn bt709_family_profiles_are_srgb_identity_in_qcms() { for transfer in [1u16, 6, 14, 15] { From 8e21d514452c117b9d74f63bb6be474e315bb927 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 16:37:12 +0530 Subject: [PATCH 06/38] Decode image hooks into caller buffers --- TESTING.md | 4 + scripts/heic_tests.sh | 101 +++- src/image_integration.rs | 223 +++++++- src/lib.rs | 1117 ++++++++++++++++++++++++++++++++------ 4 files changed, 1262 insertions(+), 183 deletions(-) diff --git a/TESTING.md b/TESTING.md index adee7f9..ca0d5fd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -34,6 +34,10 @@ fetches; bump those pins in the workflow to move the CI 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..e327ca5 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,25 @@ 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" + 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 +1387,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..afb0a31 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -8,10 +8,13 @@ //! 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, DecodeGuardrailError, DecodeGuardrails, DecodedRgbaImage, DecodedRgbaLayout, + DecodedRgbaPixels, HeifInputFamily, decode_bufread_to_rgba_with_guardrails, + decode_bytes_to_rgba_layout_with_hint_and_guardrails, decode_bytes_to_rgba_with_guardrails, + decode_bytes_to_rgba_with_hint_and_guardrails, + decode_bytes_to_rgba8_slice_with_hint_and_guardrails, + decode_bytes_to_rgba16_slice_with_hint_and_guardrails, decode_path_to_rgba_with_guardrails, + decode_read_to_rgba_with_guardrails, decode_seekable_to_rgba_with_hint_and_guardrails, }; use image::error::{ DecodingError, ImageFormatHint, ParameterError, ParameterErrorKind, UnsupportedError, @@ -22,7 +25,7 @@ use image::{ColorType, DynamicImage, ImageBuffer, ImageDecoder, ImageError, Imag use std::error::Error; use std::ffi::OsString; use std::fmt::{Display, Formatter}; -use std::io::{BufRead, Read, Seek}; +use std::io::{BufRead, Read, Seek, SeekFrom}; use std::path::Path; use std::sync::Once; @@ -243,6 +246,191 @@ impl ImageDecoder for HeifImageDecoder { } } +#[derive(Clone, Debug, Eq, PartialEq)] +struct LazyHeifImageDecoder { + input: Vec, + hint: Option, + guardrails: DecodeGuardrails, + layout: DecodedRgbaLayout, +} + +impl LazyHeifImageDecoder { + fn from_encoded_input( + input: Vec, + hint: Option, + guardrails: DecodeGuardrails, + layout: DecodedRgbaLayout, + ) -> Self { + Self { + input, + hint, + guardrails, + layout, + } + } + + fn storage_color_type(&self) -> ColorType { + storage_color_type_from_bit_depth(self.layout.storage_bit_depth) + } + + fn expected_total_bytes(&self) -> ImageResult { + expected_rgba_byte_count( + 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.layout.width, self.layout.height + )) + }) + } +} + +impl ImageDecoder for LazyHeifImageDecoder { + fn dimensions(&self) -> (u32, u32) { + (self.layout.width, self.layout.height) + } + + fn color_type(&self) -> ColorType { + self.storage_color_type() + } + + fn icc_profile(&mut self) -> ImageResult>> { + Ok(self.layout.icc_profile.clone()) + } + + fn read_image(self, buf: &mut [u8]) -> ImageResult<()> + where + Self: Sized, + { + let expected_total_bytes = self.expected_total_bytes()?; + if buf.len() != expected_total_bytes { + return Err(ImageError::Parameter(ParameterError::from_kind( + ParameterErrorKind::DimensionMismatch, + ))); + } + + match self.layout.storage_bit_depth { + 8 => decode_bytes_to_rgba8_slice_with_hint_and_guardrails( + &self.input, + self.hint, + self.guardrails, + buf, + ) + .map_err(decode_error_to_image_error), + 16 => { + if let Some(samples) = native_endian_bytes_as_u16_slice_mut(buf) { + decode_bytes_to_rgba16_slice_with_hint_and_guardrails( + &self.input, + self.hint, + self.guardrails, + samples, + ) + .map_err(decode_error_to_image_error) + } else { + let decoded = decode_bytes_to_rgba_with_hint_and_guardrails( + &self.input, + self.hint, + self.guardrails, + ) + .map_err(decode_error_to_image_error)?; + HeifImageDecoder::from_decoded(decoded)?.read_image(buf) + } + } + other => { + unreachable!("validated storage bit depth must be 8 or 16, got {other}") + } + } + } + + fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { + (*self).read_image(buf) + } +} + +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)?; + match decode_bytes_to_rgba_layout_with_hint_and_guardrails(&input, hint, guardrails.clone()) { + Ok(layout) => Ok(Box::new(LazyHeifImageDecoder::from_encoded_input( + input, hint, guardrails, layout, + ))), + Err(DecodeError::Unsupported(_)) => { + let decoded = decode_bytes_to_rgba_with_hint_and_guardrails(&input, hint, guardrails) + .map_err(decode_error_to_image_error)?; + Ok(Box::new(HeifImageDecoder::from_decoded(decoded)?)) + } + Err(err) => Err(decode_error_to_image_error(err)), + } +} + +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)?; + if let Some(max_input_bytes) = guardrails.max_input_bytes + && input_len > max_input_bytes + { + return Err(decode_error_to_image_error( + DecodeGuardrailError::InputTooLarge { + actual_bytes: input_len, + max_input_bytes, + } + .into(), + )); + } + + input_reader + .seek(SeekFrom::Start(0)) + .map_err(ImageError::IoError)?; + let capacity = usize::try_from(input_len).map_err(|_| { + parameter_error(format!( + "input size {input_len} bytes does not fit in memory on this platform" + )) + })?; + let mut input = Vec::with_capacity(capacity); + input_reader + .read_to_end(&mut input) + .map_err(ImageError::IoError)?; + + if let Some(max_input_bytes) = guardrails.max_input_bytes + && input.len() as u64 > max_input_bytes + { + return Err(decode_error_to_image_error( + DecodeGuardrailError::InputTooLarge { + actual_bytes: input.len() as u64, + max_input_bytes, + } + .into(), + )); + } + + Ok(input) +} + +fn native_endian_bytes_as_u16_slice_mut(buf: &mut [u8]) -> Option<&mut [u16]> { + if !buf.len().is_multiple_of(std::mem::size_of::()) { + return None; + } + + // SAFETY: `u16` accepts every bit pattern. We only return the middle slice + // when the whole byte buffer was properly aligned and exactly covered. + let (prefix, samples, suffix) = unsafe { buf.align_to_mut::() }; + if prefix.is_empty() && suffix.is_empty() { + Some(samples) + } else { + None + } +} + /// Result of attempting to install `image` crate decoder hooks for this crate. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ImageHookRegistration { @@ -282,35 +470,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)) + ) }), ); @@ -595,6 +780,16 @@ fn write_rgba16_native_endian_bytes(samples: &[u16], out: &mut [u8]) { } } +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}") + } + } +} + fn heif_image_format_hint() -> ImageFormatHint { ImageFormatHint::Name("heif/heic/avif".to_string()) } diff --git a/src/lib.rs b/src/lib.rs index 30ac544..76b71d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -608,6 +608,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. @@ -6913,167 +6923,839 @@ fn decode_primary_heic_to_rgba_from_resolved_input( } } -fn decode_avif_bytes_to_png( - input: &[u8], - output_path: &Path, - guardrails: DecodeGuardrails, -) -> Result<(), DecodeError> { - let decoded = decode_avif_bytes_to_rgba(input, guardrails)?; - write_decoded_rgba_image_to_png(&decoded, output_path) -} - -fn decode_heif_bytes_to_png( - input: &[u8], - output_path: &Path, - guardrails: DecodeGuardrails, -) -> Result<(), DecodeError> { - let decoded = decode_heif_bytes_to_rgba(input, guardrails)?; - write_decoded_rgba_image_to_png(&decoded, output_path) -} - -fn extension_family_hint(path: &Path) -> Option { - let extension = path.extension()?.to_str()?; - if extension.eq_ignore_ascii_case("avif") { - return Some(HeifInputFamily::Avif); +#[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 + ), + }); } - if extension.eq_ignore_ascii_case("heic") - || extension.eq_ignore_ascii_case("heif") - || extension.eq_ignore_ascii_case("hif") - { - return Some(HeifInputFamily::Heif); + + 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 + ), + }); } - None -} -/// Return `true` when the path extension is `.heif`, `.heic`, or `.hif`. -/// -/// This helper is intended as a cheap caller-side gate before HEIF-specific -/// metadata handling such as EXIF orientation inspection. -pub fn path_extension_is_heif(path: &Path) -> bool { - matches!(extension_family_hint(path), Some(HeifInputFamily::Heif)) + Ok(metadata.bit_depth_luma) } -/// Return `true` when the path extension is one of `.heif`, `.heic`, `.hif`, or `.avif`. -pub fn path_extension_is_heif_family(path: &Path) -> bool { - extension_family_hint(path).is_some() +#[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 stream = assemble_heic_hevc_stream_from_components(&first_tile.hvcc, &first_tile.payload)?; + let metadata = decode_hevc_stream_metadata_from_sps(&stream)?; + heic_bit_depth_for_png_conversion_metadata(&metadata) } -fn has_file_brand(ftyp: &isobmff::FileTypeBox, accepted: &[[u8; 4]]) -> bool { - accepted.contains(&ftyp.major_brand.as_bytes()) - || ftyp - .compatible_brands - .iter() - .any(|brand| accepted.contains(&brand.as_bytes())) +#[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: if source_bit_depth <= 8 { 8 } else { 16 }, + icc_profile, + }) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct SourceTopLevelBoxHeader { - box_type: [u8; 4], - box_size: u64, - header_size: u8, -} +#[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(_) => { + return Err(DecodeError::Unsupported( + "lazy image adapter does not yet support uncompressed HEIF".to_string(), + )); + } + Err(isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { + .. + }) => {} + Err(err) => return Err(DecodeUncompressedError::ParsePrimaryProperties(err).into()), + } -#[derive(Clone, Debug, Eq, PartialEq)] -struct SourceTopLevelBox { - offset: u64, - header: SourceTopLevelBoxHeader, - bytes: Vec, -} + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeHeicError::ParsePrimaryTransforms)? + .transforms; + let icc_profile = primary_icc_profile_from_heic(input); + let primary_with_grid = + isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)?; -fn read_u32_be_from(bytes: &[u8]) -> u32 { - u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) + 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) => { + let (_, metadata, _, _) = + decode_primary_heic_stream_and_metadata_from_coded_item_data(input, &item_data)?; + 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, + ) + } + } } -fn read_u64_be_from(bytes: &[u8]) -> u64 { - u64::from_be_bytes([ - bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], - ]) +#[cfg(feature = "image-integration")] +fn copy_decoded_rgba8_to_slice( + decoded: DecodedRgbaImage, + out: &mut [u8], +) -> Result<(), DecodeError> { + match decoded.pixels { + DecodedRgbaPixels::U8(pixels) => { + if pixels.len() != out.len() { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "RGBA8 image adapter handoff", + actual: out.len(), + expected: pixels.len(), + width: decoded.width, + height: decoded.height, + }, + )); + } + out.copy_from_slice(&pixels); + Ok(()) + } + DecodedRgbaPixels::U16(_) => Err(DecodeError::Unsupported( + "decoded image storage is RGBA16, not RGBA8".to_string(), + )), + } } -fn parse_source_top_level_box_header( - probe: &[u8], - offset: u64, - available: u64, -) -> Result { - if probe.len() < BASIC_BOX_HEADER_SIZE { - return Err(DecodeError::Unsupported(format!( - "truncated BMFF box header at offset {offset} (available: {} bytes, required: {BASIC_BOX_HEADER_SIZE})", - probe.len() - ))); +#[cfg(feature = "image-integration")] +fn copy_decoded_rgba16_to_slice( + decoded: DecodedRgbaImage, + out: &mut [u16], +) -> Result<(), DecodeError> { + match decoded.pixels { + DecodedRgbaPixels::U16(pixels) => { + if pixels.len() != out.len() { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "RGBA16 image adapter handoff", + actual: out.len(), + expected: pixels.len(), + width: decoded.width, + height: decoded.height, + }, + )); + } + out.copy_from_slice(&pixels); + Ok(()) + } + DecodedRgbaPixels::U8(_) => Err(DecodeError::Unsupported( + "decoded image storage is RGBA8, not RGBA16".to_string(), + )), } +} - // Provenance: mirrors libheif header/range checks in - // libheif/libheif/box.cc:BoxHeader::parse_header and Box::read. - let size32 = read_u32_be_from(&probe[0..4]); - let box_type = [probe[4], probe[5], probe[6], probe[7]]; +#[cfg(feature = "image-integration")] +fn grid_transforms_can_write_directly( + transforms: &[isobmff::PrimaryItemTransformProperty], +) -> bool { + transforms.iter().all(|transform| { + matches!( + transform, + isobmff::PrimaryItemTransformProperty::Rotation(_) + | isobmff::PrimaryItemTransformProperty::Mirror(_) + ) + }) +} - let mut header_size = BASIC_BOX_HEADER_SIZE; - let box_size = if size32 == 1 { - let needed = BASIC_BOX_HEADER_SIZE + LARGE_BOX_SIZE_FIELD_SIZE; - if probe.len() < needed { - return Err(DecodeError::Unsupported(format!( - "truncated BMFF largesize field at offset {offset} (available: {} bytes, required: {needed})", - probe.len() - ))); +#[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> { + if auxiliary_alpha.is_none() && grid_transforms_can_write_directly(transforms) { + 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)?; + if source_bit_depth > 8 { + return Err(DecodeError::Unsupported( + "HEIC grid storage is RGBA16, not RGBA8".to_string(), + )); } - header_size = needed; - read_u64_be_from(&probe[BASIC_BOX_HEADER_SIZE..needed]) - } else if size32 == 0 { - available - } else { - u64::from(size32) - }; - if box_type == UUID_BOX_TYPE { - let needed = header_size + UUID_EXTENDED_TYPE_SIZE; - if probe.len() < needed { - return Err(DecodeError::Unsupported(format!( - "truncated BMFF uuid extended type at offset {offset} (available: {} bytes, required: {needed})", - probe.len() - ))); + let direct_orientation_transform = rgba_orientation_transform_from_primary_transforms( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + transforms, + )?; + let output_width = direct_orientation_transform + .as_ref() + .map_or(grid_data.descriptor.output_width, |transform| { + transform.destination_width + }); + let output_height = direct_orientation_transform + .as_ref() + .map_or(grid_data.descriptor.output_height, |transform| { + transform.destination_height + }); + let expected = checked_rgba_sample_count(output_width, output_height)?; + if out.len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "HEIC grid RGBA8 image adapter output", + actual: out.len(), + expected, + width: output_width, + height: output_height, + }, + )); } - header_size = needed; - } - let header_size_u8 = u8::try_from(header_size).map_err(|_| { - DecodeError::Unsupported(format!( - "BMFF header size {header_size} at offset {offset} does not fit in u8" - )) - })?; - let header_size_u64 = u64::from(header_size_u8); - if box_size < header_size_u64 { - return Err(DecodeError::Unsupported(format!( - "invalid BMFF box size at offset {offset}: box_size={box_size}, header_size={header_size_u8}" - ))); - } - if box_size > available { - return Err(DecodeError::Unsupported(format!( - "BMFF box at offset {offset} exceeds available bytes: box_size={box_size}, available={available}" - ))); + let tile_width = first_tile.width; + let tile_height = first_tile.height; + let reference_layout = first_tile.layout; + let reference_bit_depth_luma = first_tile.bit_depth_luma; + let reference_bit_depth_chroma = first_tile.bit_depth_chroma; + let reference_ycbcr_range = first_tile.ycbcr_range; + let reference_ycbcr_matrix = first_tile.ycbcr_matrix; + let conversion_ycbcr_range = ycbcr_range_override_from_primary_colr(&grid_data.colr) + .unwrap_or(reference_ycbcr_range); + let conversion_ycbcr_matrix = ycbcr_matrix_override_from_primary_colr(&grid_data.colr) + .unwrap_or(reference_ycbcr_matrix); + return paste_heic_grid_tiles_to_rgba8( + grid_data, + first_tile, + out, + tile_width, + tile_height, + reference_layout, + reference_bit_depth_luma, + reference_bit_depth_chroma, + reference_ycbcr_range, + reference_ycbcr_matrix, + conversion_ycbcr_range, + conversion_ycbcr_matrix, + direct_orientation_transform.as_ref(), + ); } - Ok(SourceTopLevelBoxHeader { - box_type, - box_size, - header_size: header_size_u8, - }) + let decoded = + decode_primary_heic_grid_to_rgba_image(grid_data, transforms, auxiliary_alpha, None)?; + copy_decoded_rgba8_to_slice(decoded, out) } -fn read_selected_top_level_boxes_from_source( - source: &mut S, - selected_types: &[[u8; 4]], -) -> Result, DecodeError> { - if selected_types.is_empty() { - return Ok(Vec::new()); - } - - let mut selected = Vec::new(); - let mut found = vec![false; selected_types.len()]; - let source_len = source.len(); - let mut cursor = 0_u64; - - while cursor < source_len { - let available = source_len - cursor; +#[cfg(feature = "image-integration")] +fn decode_primary_heic_grid_to_rgba16_slice( + grid_data: &isobmff::HeicGridPrimaryItemData, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [u16], +) -> Result<(), DecodeError> { + if auxiliary_alpha.is_none() && grid_transforms_can_write_directly(transforms) { + 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)?; + if source_bit_depth <= 8 { + return Err(DecodeError::Unsupported( + "HEIC grid storage is RGBA8, not RGBA16".to_string(), + )); + } + + let direct_orientation_transform = rgba_orientation_transform_from_primary_transforms( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + transforms, + )?; + let output_width = direct_orientation_transform + .as_ref() + .map_or(grid_data.descriptor.output_width, |transform| { + transform.destination_width + }); + let output_height = direct_orientation_transform + .as_ref() + .map_or(grid_data.descriptor.output_height, |transform| { + transform.destination_height + }); + let expected = checked_rgba_sample_count(output_width, output_height)?; + if out.len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "HEIC grid RGBA16 image adapter output", + actual: out.len(), + expected, + width: output_width, + height: output_height, + }, + )); + } + + let tile_width = first_tile.width; + let tile_height = first_tile.height; + let reference_layout = first_tile.layout; + let reference_bit_depth_luma = first_tile.bit_depth_luma; + let reference_bit_depth_chroma = first_tile.bit_depth_chroma; + let reference_ycbcr_range = first_tile.ycbcr_range; + let reference_ycbcr_matrix = first_tile.ycbcr_matrix; + let conversion_ycbcr_range = ycbcr_range_override_from_primary_colr(&grid_data.colr) + .unwrap_or(reference_ycbcr_range); + let conversion_ycbcr_matrix = ycbcr_matrix_override_from_primary_colr(&grid_data.colr) + .unwrap_or(reference_ycbcr_matrix); + return paste_heic_grid_tiles_to_rgba16( + grid_data, + first_tile, + out, + tile_width, + tile_height, + reference_layout, + reference_bit_depth_luma, + reference_bit_depth_chroma, + reference_ycbcr_range, + reference_ycbcr_matrix, + conversion_ycbcr_range, + conversion_ycbcr_matrix, + direct_orientation_transform.as_ref(), + ); + } + + let decoded = + decode_primary_heic_grid_to_rgba_image(grid_data, transforms, auxiliary_alpha, None)?; + copy_decoded_rgba16_to_slice(decoded, out) +} + +#[cfg(feature = "image-integration")] +fn decoded_heic_to_rgba8_slice( + mut decoded: DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [u8], +) -> Result<(), DecodeError> { + let mut remaining_transforms = transforms; + if auxiliary_alpha.is_none() { + while let Some(isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture)) = + remaining_transforms.first() + { + decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; + remaining_transforms = &remaining_transforms[1..]; + } + } + + let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; + if source_bit_depth > 8 { + return Err(DecodeError::Unsupported( + "HEIC storage is RGBA16, not RGBA8".to_string(), + )); + } + + if remaining_transforms.is_empty() { + convert_heic_to_rgba8_slice(&decoded, out)?; + if let Some(alpha) = auxiliary_alpha { + apply_auxiliary_alpha_to_rgba8(out, decoded.width, decoded.height, alpha)?; + } + return Ok(()); + } + + 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, pixels) = apply_primary_item_transforms_rgba( + decoded.width, + decoded.height, + pixels, + remaining_transforms, + )?; + copy_decoded_rgba8_to_slice( + DecodedRgbaImage { + width, + height, + source_bit_depth, + pixels: DecodedRgbaPixels::U8(pixels), + icc_profile: None, + }, + out, + ) +} + +#[cfg(feature = "image-integration")] +fn decoded_heic_to_rgba16_slice( + mut decoded: DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [u16], +) -> Result<(), DecodeError> { + let mut remaining_transforms = transforms; + if auxiliary_alpha.is_none() { + while let Some(isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture)) = + remaining_transforms.first() + { + decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; + remaining_transforms = &remaining_transforms[1..]; + } + } + + let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; + if source_bit_depth <= 8 { + return Err(DecodeError::Unsupported( + "HEIC storage is RGBA8, not RGBA16".to_string(), + )); + } + + if remaining_transforms.is_empty() { + convert_heic_to_rgba16_slice(&decoded, out)?; + if let Some(alpha) = auxiliary_alpha { + apply_auxiliary_alpha_to_rgba16(out, decoded.width, decoded.height, alpha)?; + } + return Ok(()); + } + + 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, pixels) = apply_primary_item_transforms_rgba( + decoded.width, + decoded.height, + pixels, + remaining_transforms, + )?; + copy_decoded_rgba16_to_slice( + DecodedRgbaImage { + width, + height, + source_bit_depth, + pixels: DecodedRgbaPixels::U16(pixels), + icc_profile: None, + }, + out, + ) +} + +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba8_slice( + input: &[u8], + guardrails: DecodeGuardrails, + out: &mut [u8], +) -> Result<(), DecodeError> { + 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; + let decoded = decoded_uncompressed_to_rgba_image(decoded, &transforms)?; + return copy_decoded_rgba8_to_slice(decoded, 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 mut source: Option<&mut dyn RandomAccessSource> = None; + let primary_with_grid = + isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)?; + + match primary_with_grid { + isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { + guardrails.enforce_pixel_count( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )?; + let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + input, + &mut source, + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + ); + decode_primary_heic_grid_to_rgba8_slice( + &grid_data, + &transforms, + auxiliary_alpha.as_ref(), + out, + ) + } + 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)?; + 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_rgba8_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) + } + } +} + +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba16_slice( + input: &[u8], + guardrails: DecodeGuardrails, + out: &mut [u16], +) -> Result<(), DecodeError> { + 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; + let decoded = decoded_uncompressed_to_rgba_image(decoded, &transforms)?; + return copy_decoded_rgba16_to_slice(decoded, 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 mut source: Option<&mut dyn RandomAccessSource> = None; + let primary_with_grid = + isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)?; + + match primary_with_grid { + isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { + guardrails.enforce_pixel_count( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )?; + let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + input, + &mut source, + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + ); + decode_primary_heic_grid_to_rgba16_slice( + &grid_data, + &transforms, + auxiliary_alpha.as_ref(), + out, + ) + } + 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)?; + 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_rgba16_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) + } + } +} + +#[cfg(feature = "image-integration")] +fn decode_bytes_to_rgba_layout_with_hint_and_guardrails( + input: &[u8], + 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 { + HeifInputFamily::Heif => decode_heif_bytes_to_rgba_layout(input, guardrails), + HeifInputFamily::Avif => Err(DecodeError::Unsupported( + "lazy image adapter does not yet support AVIF preflight".to_string(), + )), + } +} + +#[cfg(feature = "image-integration")] +fn decode_bytes_to_rgba8_slice_with_hint_and_guardrails( + input: &[u8], + hint: Option, + guardrails: DecodeGuardrails, + out: &mut [u8], +) -> 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 { + HeifInputFamily::Heif => decode_heif_bytes_to_rgba8_slice(input, guardrails, out), + HeifInputFamily::Avif => { + let decoded = decode_avif_bytes_to_rgba(input, guardrails)?; + copy_decoded_rgba8_to_slice(decoded, out) + } + } +} + +#[cfg(feature = "image-integration")] +fn decode_bytes_to_rgba16_slice_with_hint_and_guardrails( + input: &[u8], + hint: Option, + guardrails: DecodeGuardrails, + out: &mut [u16], +) -> 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 { + HeifInputFamily::Heif => decode_heif_bytes_to_rgba16_slice(input, guardrails, out), + HeifInputFamily::Avif => { + let decoded = decode_avif_bytes_to_rgba(input, guardrails)?; + copy_decoded_rgba16_to_slice(decoded, out) + } + } +} + +fn decode_avif_bytes_to_png( + input: &[u8], + output_path: &Path, + guardrails: DecodeGuardrails, +) -> Result<(), DecodeError> { + let decoded = decode_avif_bytes_to_rgba(input, guardrails)?; + write_decoded_rgba_image_to_png(&decoded, output_path) +} + +fn decode_heif_bytes_to_png( + input: &[u8], + output_path: &Path, + guardrails: DecodeGuardrails, +) -> Result<(), DecodeError> { + let decoded = decode_heif_bytes_to_rgba(input, guardrails)?; + write_decoded_rgba_image_to_png(&decoded, output_path) +} + +fn extension_family_hint(path: &Path) -> Option { + let extension = path.extension()?.to_str()?; + if extension.eq_ignore_ascii_case("avif") { + return Some(HeifInputFamily::Avif); + } + if extension.eq_ignore_ascii_case("heic") + || extension.eq_ignore_ascii_case("heif") + || extension.eq_ignore_ascii_case("hif") + { + return Some(HeifInputFamily::Heif); + } + None +} + +/// Return `true` when the path extension is `.heif`, `.heic`, or `.hif`. +/// +/// This helper is intended as a cheap caller-side gate before HEIF-specific +/// metadata handling such as EXIF orientation inspection. +pub fn path_extension_is_heif(path: &Path) -> bool { + matches!(extension_family_hint(path), Some(HeifInputFamily::Heif)) +} + +/// Return `true` when the path extension is one of `.heif`, `.heic`, `.hif`, or `.avif`. +pub fn path_extension_is_heif_family(path: &Path) -> bool { + extension_family_hint(path).is_some() +} + +fn has_file_brand(ftyp: &isobmff::FileTypeBox, accepted: &[[u8; 4]]) -> bool { + accepted.contains(&ftyp.major_brand.as_bytes()) + || ftyp + .compatible_brands + .iter() + .any(|brand| accepted.contains(&brand.as_bytes())) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SourceTopLevelBoxHeader { + box_type: [u8; 4], + box_size: u64, + header_size: u8, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SourceTopLevelBox { + offset: u64, + header: SourceTopLevelBoxHeader, + bytes: Vec, +} + +fn read_u32_be_from(bytes: &[u8]) -> u32 { + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +fn read_u64_be_from(bytes: &[u8]) -> u64 { + u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]) +} + +fn parse_source_top_level_box_header( + probe: &[u8], + offset: u64, + available: u64, +) -> Result { + if probe.len() < BASIC_BOX_HEADER_SIZE { + return Err(DecodeError::Unsupported(format!( + "truncated BMFF box header at offset {offset} (available: {} bytes, required: {BASIC_BOX_HEADER_SIZE})", + probe.len() + ))); + } + + // Provenance: mirrors libheif header/range checks in + // libheif/libheif/box.cc:BoxHeader::parse_header and Box::read. + let size32 = read_u32_be_from(&probe[0..4]); + let box_type = [probe[4], probe[5], probe[6], probe[7]]; + + let mut header_size = BASIC_BOX_HEADER_SIZE; + let box_size = if size32 == 1 { + let needed = BASIC_BOX_HEADER_SIZE + LARGE_BOX_SIZE_FIELD_SIZE; + if probe.len() < needed { + return Err(DecodeError::Unsupported(format!( + "truncated BMFF largesize field at offset {offset} (available: {} bytes, required: {needed})", + probe.len() + ))); + } + header_size = needed; + read_u64_be_from(&probe[BASIC_BOX_HEADER_SIZE..needed]) + } else if size32 == 0 { + available + } else { + u64::from(size32) + }; + + if box_type == UUID_BOX_TYPE { + let needed = header_size + UUID_EXTENDED_TYPE_SIZE; + if probe.len() < needed { + return Err(DecodeError::Unsupported(format!( + "truncated BMFF uuid extended type at offset {offset} (available: {} bytes, required: {needed})", + probe.len() + ))); + } + header_size = needed; + } + + let header_size_u8 = u8::try_from(header_size).map_err(|_| { + DecodeError::Unsupported(format!( + "BMFF header size {header_size} at offset {offset} does not fit in u8" + )) + })?; + let header_size_u64 = u64::from(header_size_u8); + if box_size < header_size_u64 { + return Err(DecodeError::Unsupported(format!( + "invalid BMFF box size at offset {offset}: box_size={box_size}, header_size={header_size_u8}" + ))); + } + if box_size > available { + return Err(DecodeError::Unsupported(format!( + "BMFF box at offset {offset} exceeds available bytes: box_size={box_size}, available={available}" + ))); + } + + Ok(SourceTopLevelBoxHeader { + box_type, + box_size, + header_size: header_size_u8, + }) +} + +fn read_selected_top_level_boxes_from_source( + source: &mut S, + selected_types: &[[u8; 4]], +) -> Result, DecodeError> { + if selected_types.is_empty() { + return Ok(Vec::new()); + } + + let mut selected = Vec::new(); + let mut found = vec![false; selected_types.len()]; + let source_len = source.len(); + let mut cursor = 0_u64; + + while cursor < source_len { + let available = source_len - cursor; let probe_len_u64 = available.min(TOP_LEVEL_BOX_HEADER_PROBE_SIZE as u64); let probe_len = usize::try_from(probe_len_u64).map_err(|_| { DecodeError::Unsupported(format!( @@ -8489,31 +9171,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: Vec, 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 @@ -8558,7 +9236,74 @@ fn crop_rgba_by_clean_aperture( }) })?; - if left == 0 && top == 0 && crop_width == width && crop_height == height { + Ok(CleanApertureCropBounds { + left, + right, + top, + bottom, + width: crop_width, + height: crop_height, + }) +} + +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)); } @@ -8569,32 +9314,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 @@ -8641,7 +9386,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)] @@ -9104,6 +9849,27 @@ fn convert_heic_to_rgba8(decoded: &DecodedHeicImage) -> Result, DecodeHe 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| { @@ -9142,7 +9908,14 @@ fn convert_heic_to_rgba8_into( decoded.width, decoded.height ), })?; - out.resize(output_len, 0); + 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); @@ -9209,6 +9982,15 @@ fn convert_heic_to_rgba16(decoded: &DecodedHeicImage) -> Result, Decode 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| { @@ -9247,7 +10029,14 @@ fn convert_heic_to_rgba16_into( decoded.width, decoded.height ), })?; - out.resize(output_len, 0); + 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); From dbf4a0fa92807d64c76977ded703c94a8784c5ec Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 17:08:38 +0530 Subject: [PATCH 07/38] Fix image hook grid buffer initialization --- src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 76b71d4..44d1090 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7156,6 +7156,10 @@ fn decode_primary_heic_grid_to_rgba8_slice( )); } + // Caller-provided ImageDecoder buffers are not guaranteed to be + // pre-cleared. Preserve the owned grid path's zero-filled gaps when + // clipped tiles do not cover the descriptor output exactly. + out.fill(0); let tile_width = first_tile.width; let tile_height = first_tile.height; let reference_layout = first_tile.layout; @@ -7241,6 +7245,10 @@ fn decode_primary_heic_grid_to_rgba16_slice( )); } + // Caller-provided ImageDecoder buffers are not guaranteed to be + // pre-cleared. Preserve the owned grid path's zero-filled gaps when + // clipped tiles do not cover the descriptor output exactly. + out.fill(0); let tile_width = first_tile.width; let tile_height = first_tile.height; let reference_layout = first_tile.layout; @@ -9246,6 +9254,7 @@ fn clean_aperture_crop_bounds( }) } +#[cfg(feature = "image-integration")] fn transformed_rgba_dimensions( width: u32, height: u32, From 942b17f381f7f7da100223f41fc3cb87fcabe088 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 18:21:04 +0530 Subject: [PATCH 08/38] Keep chroma-unaligned clean apertures out of YUV cropping --- src/lib.rs | 276 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 258 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 44d1090..48d2518 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5252,6 +5252,13 @@ fn apply_heic_grid_tile_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 @@ -5272,6 +5279,53 @@ fn apply_heic_grid_tile_transforms( 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, @@ -7291,12 +7345,8 @@ fn decoded_heic_to_rgba8_slice( ) -> Result<(), DecodeError> { let mut remaining_transforms = transforms; if auxiliary_alpha.is_none() { - while let Some(isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture)) = - remaining_transforms.first() - { - decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; - remaining_transforms = &remaining_transforms[1..]; - } + (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)?; @@ -7345,12 +7395,8 @@ fn decoded_heic_to_rgba16_slice( ) -> Result<(), DecodeError> { let mut remaining_transforms = transforms; if auxiliary_alpha.is_none() { - while let Some(isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture)) = - remaining_transforms.first() - { - decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; - remaining_transforms = &remaining_transforms[1..]; - } + (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)?; @@ -8674,12 +8720,8 @@ fn decoded_heic_to_rgba_image( ) -> Result { let mut remaining_transforms = transforms; if auxiliary_alpha.is_none() { - while let Some(isobmff::PrimaryItemTransformProperty::CleanAperture(clean_aperture)) = - remaining_transforms.first() - { - decoded = crop_heic_by_clean_aperture(decoded, *clean_aperture)?; - remaining_transforms = &remaining_transforms[1..]; - } + (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)?; @@ -11283,6 +11325,204 @@ mod tests { assert_eq!(direct, transformed); } + // 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); + } + #[test] fn bt709_family_profiles_are_srgb_identity_in_qcms() { for transfer in [1u16, 6, 14, 15] { From b7a644a590ec56f9f5db95e003b465cab7a7ca17 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Thu, 9 Jul 2026 18:22:53 +0530 Subject: [PATCH 09/38] Reject unaligned grid tile origins and dedupe HEIC decode paths --- src/lib.rs | 591 ++++++++++++++++++++++------------------------------- 1 file changed, 249 insertions(+), 342 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 48d2518..d68d538 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1400,17 +1400,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) } } } @@ -3942,6 +3932,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, @@ -4172,18 +4221,7 @@ fn decode_primary_heic_grid_to_rgba_image( })?; let first_tile = decode_heic_grid_tile_to_image(first_tile_data)?; validate_decoded_heic_grid_first_tile(descriptor, &first_tile)?; - - let tile_width = first_tile.width; - let tile_height = first_tile.height; - let reference_layout = first_tile.layout; - let reference_bit_depth_luma = first_tile.bit_depth_luma; - let reference_bit_depth_chroma = first_tile.bit_depth_chroma; - let reference_ycbcr_range = first_tile.ycbcr_range; - let reference_ycbcr_matrix = first_tile.ycbcr_matrix; - let conversion_ycbcr_range = - ycbcr_range_override_from_primary_colr(&grid_data.colr).unwrap_or(reference_ycbcr_range); - let conversion_ycbcr_matrix = - ycbcr_matrix_override_from_primary_colr(&grid_data.colr).unwrap_or(reference_ycbcr_matrix); + let reference = HeicGridTileReference::from_first_tile(&first_tile, &grid_data.colr); let source_bit_depth = heic_bit_depth_for_png_conversion(&first_tile)?; // Direct orientation is safe for opaque grids. Alpha and clean aperture // stay on the existing source-coordinate transform path. @@ -4209,20 +4247,13 @@ fn decode_primary_heic_grid_to_rgba_image( if source_bit_depth <= 8 { let mut output = vec![0_u8; checked_rgba_sample_count(output_width, output_height)?]; - paste_heic_grid_tiles_to_rgba8( + paste_heic_grid_tiles_to_rgba( grid_data, first_tile, &mut output, - tile_width, - tile_height, - reference_layout, - reference_bit_depth_luma, - reference_bit_depth_chroma, - reference_ycbcr_range, - reference_ycbcr_matrix, - conversion_ycbcr_range, - conversion_ycbcr_matrix, + &reference, direct_orientation_transform.as_ref(), + convert_heic_to_rgba8_into, )?; if direct_orientation_transform.is_some() { return Ok(DecodedRgbaImage { @@ -4257,20 +4288,13 @@ fn decode_primary_heic_grid_to_rgba_image( } let mut output = vec![0_u16; checked_rgba_sample_count(output_width, output_height)?]; - paste_heic_grid_tiles_to_rgba16( + paste_heic_grid_tiles_to_rgba( grid_data, first_tile, &mut output, - tile_width, - tile_height, - reference_layout, - reference_bit_depth_luma, - reference_bit_depth_chroma, - reference_ycbcr_range, - reference_ycbcr_matrix, - conversion_ycbcr_range, - conversion_ycbcr_matrix, + &reference, direct_orientation_transform.as_ref(), + convert_heic_to_rgba16_into, )?; if direct_orientation_transform.is_some() { return Ok(DecodedRgbaImage { @@ -4327,43 +4351,73 @@ fn validate_decoded_heic_grid_first_tile( Ok(()) } -#[allow(clippy::too_many_arguments)] -fn validate_decoded_heic_grid_tile_reference( - tile: &DecodedHeicImage, +/// 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, - reference_layout: HeicPixelLayout, - reference_bit_depth_luma: u8, - reference_bit_depth_chroma: u8, - reference_ycbcr_range: YCbCrRange, - reference_ycbcr_matrix: YCbCrMatrixCoefficients, + 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), + } + } +} + +fn validate_decoded_heic_grid_tile_reference( + tile: &DecodedHeicImage, + reference: &HeicGridTileReference, tile_index: usize, ) -> Result<(), DecodeHeicError> { - if tile.width != tile_width || tile.height != tile_height { + if tile.width != reference.tile_width || tile.height != reference.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 + "grid tiles have mixed dimensions: expected {}x{}, got {}x{} at index {tile_index}", + reference.tile_width, reference.tile_height, tile.width, tile.height ), }); } - if tile.layout != reference_layout { + if tile.layout != reference.layout { return Err(DecodeHeicError::DecodedLayoutMismatch { - expected: reference_layout, + expected: reference.layout, actual: tile.layout, }); } - if tile.bit_depth_luma != reference_bit_depth_luma - || tile.bit_depth_chroma != reference_bit_depth_chroma + 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, + 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 { + if tile.ycbcr_range != reference.ycbcr_range || tile.ycbcr_matrix != reference.ycbcr_matrix { return Err(DecodeHeicError::InvalidDecodedFrame { detail: format!("grid tiles have inconsistent YCbCr metadata at index {tile_index}"), }); @@ -4372,88 +4426,11 @@ fn validate_decoded_heic_grid_tile_reference( Ok(()) } -#[allow(clippy::too_many_arguments)] -fn paste_heic_grid_tiles_to_rgba8( - grid_data: &isobmff::HeicGridPrimaryItemData, - first_tile: DecodedHeicImage, - output: &mut [u8], - tile_width: u32, - tile_height: u32, - reference_layout: HeicPixelLayout, - reference_bit_depth_luma: u8, - reference_bit_depth_chroma: u8, - reference_ycbcr_range: YCbCrRange, - reference_ycbcr_matrix: YCbCrMatrixCoefficients, - conversion_ycbcr_range: YCbCrRange, - conversion_ycbcr_matrix: YCbCrMatrixCoefficients, - orientation_transform: Option<&RgbaOrientationTransform>, -) -> Result<(), DecodeError> { - paste_heic_grid_tiles_to_rgba( - grid_data, - first_tile, - output, - tile_width, - tile_height, - reference_layout, - reference_bit_depth_luma, - reference_bit_depth_chroma, - reference_ycbcr_range, - reference_ycbcr_matrix, - conversion_ycbcr_range, - conversion_ycbcr_matrix, - orientation_transform, - convert_heic_to_rgba8_into, - ) -} - -#[allow(clippy::too_many_arguments)] -fn paste_heic_grid_tiles_to_rgba16( - grid_data: &isobmff::HeicGridPrimaryItemData, - first_tile: DecodedHeicImage, - output: &mut [u16], - tile_width: u32, - tile_height: u32, - reference_layout: HeicPixelLayout, - reference_bit_depth_luma: u8, - reference_bit_depth_chroma: u8, - reference_ycbcr_range: YCbCrRange, - reference_ycbcr_matrix: YCbCrMatrixCoefficients, - conversion_ycbcr_range: YCbCrRange, - conversion_ycbcr_matrix: YCbCrMatrixCoefficients, - orientation_transform: Option<&RgbaOrientationTransform>, -) -> Result<(), DecodeError> { - paste_heic_grid_tiles_to_rgba( - grid_data, - first_tile, - output, - tile_width, - tile_height, - reference_layout, - reference_bit_depth_luma, - reference_bit_depth_chroma, - reference_ycbcr_range, - reference_ycbcr_matrix, - conversion_ycbcr_range, - conversion_ycbcr_matrix, - orientation_transform, - convert_heic_to_rgba16_into, - ) -} - -#[allow(clippy::too_many_arguments)] fn paste_heic_grid_tiles_to_rgba( grid_data: &isobmff::HeicGridPrimaryItemData, first_tile: DecodedHeicImage, output: &mut [T], - tile_width: u32, - tile_height: u32, - reference_layout: HeicPixelLayout, - reference_bit_depth_luma: u8, - reference_bit_depth_chroma: u8, - reference_ycbcr_range: YCbCrRange, - reference_ycbcr_matrix: YCbCrMatrixCoefficients, - conversion_ycbcr_range: YCbCrRange, - conversion_ycbcr_matrix: YCbCrMatrixCoefficients, + reference: &HeicGridTileReference, orientation_transform: Option<&RgbaOrientationTransform>, convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, ) -> Result<(), DecodeError> { @@ -4471,23 +4448,15 @@ fn paste_heic_grid_tiles_to_rgba( } else { decode_heic_grid_tile_to_image(&grid_data.tiles[tile_index])? }; - validate_decoded_heic_grid_tile_reference( - &tile, - tile_width, - tile_height, - reference_layout, - reference_bit_depth_luma, - reference_bit_depth_chroma, - reference_ycbcr_range, - reference_ycbcr_matrix, - tile_index, - )?; - tile.ycbcr_range = conversion_ycbcr_range; - tile.ycbcr_matrix = conversion_ycbcr_matrix; + 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(tile_width, tile_height, row, column)?; + 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)?; if let Some(orientation_transform) = orientation_transform { paste_transformed_rgba_tile_with_clip( &tile_pixels, @@ -4555,6 +4524,27 @@ fn heic_grid_tile_origin( Ok((x_origin, y_origin)) } +/// 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, +) -> Result<(), DecodeHeicError> { + 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" + ), + }); + } + + Ok(()) +} + #[derive(Clone, Debug)] struct RgbaOrientationTransform { source_width: u32, @@ -5186,14 +5176,7 @@ fn paste_decoded_heic_grid_tile( } 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 - ), - }); - } + 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)?; @@ -6937,16 +6920,8 @@ fn decode_primary_heic_to_rgba_from_resolved_input( match primary_with_grid { isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { - guardrails.enforce_pixel_count( - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - )?; - let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( - input, - source, - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - ); + let auxiliary_alpha = + decode_primary_heic_grid_auxiliary_alpha(input, source, &grid_data, &guardrails)?; decode_primary_heic_grid_to_rgba_image( &grid_data, transforms, @@ -6955,23 +6930,8 @@ fn decode_primary_heic_to_rgba_from_resolved_input( ) } 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)?; - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( - input, - source, - decoded.width, - decoded.height, - ); + 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) } } @@ -7165,86 +7125,16 @@ fn decode_primary_heic_grid_to_rgba8_slice( auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u8], ) -> Result<(), DecodeError> { - if auxiliary_alpha.is_none() && grid_transforms_can_write_directly(transforms) { - 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)?; - if source_bit_depth > 8 { - return Err(DecodeError::Unsupported( - "HEIC grid storage is RGBA16, not RGBA8".to_string(), - )); - } - - let direct_orientation_transform = rgba_orientation_transform_from_primary_transforms( - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - transforms, - )?; - let output_width = direct_orientation_transform - .as_ref() - .map_or(grid_data.descriptor.output_width, |transform| { - transform.destination_width - }); - let output_height = direct_orientation_transform - .as_ref() - .map_or(grid_data.descriptor.output_height, |transform| { - transform.destination_height - }); - let expected = checked_rgba_sample_count(output_width, output_height)?; - if out.len() != expected { - return Err(DecodeError::TransformGuard( - TransformGuardError::RgbaSampleCountMismatch { - stage: "HEIC grid RGBA8 image adapter output", - actual: out.len(), - expected, - width: output_width, - height: output_height, - }, - )); - } - - // Caller-provided ImageDecoder buffers are not guaranteed to be - // pre-cleared. Preserve the owned grid path's zero-filled gaps when - // clipped tiles do not cover the descriptor output exactly. - out.fill(0); - let tile_width = first_tile.width; - let tile_height = first_tile.height; - let reference_layout = first_tile.layout; - let reference_bit_depth_luma = first_tile.bit_depth_luma; - let reference_bit_depth_chroma = first_tile.bit_depth_chroma; - let reference_ycbcr_range = first_tile.ycbcr_range; - let reference_ycbcr_matrix = first_tile.ycbcr_matrix; - let conversion_ycbcr_range = ycbcr_range_override_from_primary_colr(&grid_data.colr) - .unwrap_or(reference_ycbcr_range); - let conversion_ycbcr_matrix = ycbcr_matrix_override_from_primary_colr(&grid_data.colr) - .unwrap_or(reference_ycbcr_matrix); - return paste_heic_grid_tiles_to_rgba8( - grid_data, - first_tile, - out, - tile_width, - tile_height, - reference_layout, - reference_bit_depth_luma, - reference_bit_depth_chroma, - reference_ycbcr_range, - reference_ycbcr_matrix, - conversion_ycbcr_range, - conversion_ycbcr_matrix, - direct_orientation_transform.as_ref(), - ); - } - - let decoded = - decode_primary_heic_grid_to_rgba_image(grid_data, transforms, auxiliary_alpha, None)?; - copy_decoded_rgba8_to_slice(decoded, out) + decode_primary_heic_grid_to_rgba_slice( + grid_data, + transforms, + auxiliary_alpha, + out, + 8, + "HEIC grid RGBA8 image adapter output", + convert_heic_to_rgba8_into, + copy_decoded_rgba8_to_slice, + ) } #[cfg(feature = "image-integration")] @@ -7253,6 +7143,30 @@ fn decode_primary_heic_grid_to_rgba16_slice( transforms: &[isobmff::PrimaryItemTransformProperty], auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u16], +) -> Result<(), DecodeError> { + decode_primary_heic_grid_to_rgba_slice( + grid_data, + transforms, + auxiliary_alpha, + out, + 16, + "HEIC grid RGBA16 image adapter output", + convert_heic_to_rgba16_into, + copy_decoded_rgba16_to_slice, + ) +} + +#[cfg(feature = "image-integration")] +#[allow(clippy::too_many_arguments)] +fn decode_primary_heic_grid_to_rgba_slice( + grid_data: &isobmff::HeicGridPrimaryItemData, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [T], + storage_bit_depth: u8, + sample_count_stage: &'static str, + convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, + copy_decoded_to_slice: fn(DecodedRgbaImage, &mut [T]) -> Result<(), DecodeError>, ) -> Result<(), DecodeError> { if auxiliary_alpha.is_none() && grid_transforms_can_write_directly(transforms) { let first_tile_data = @@ -7265,10 +7179,11 @@ fn decode_primary_heic_grid_to_rgba16_slice( 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)?; - if source_bit_depth <= 8 { - return Err(DecodeError::Unsupported( - "HEIC grid storage is RGBA8, not RGBA16".to_string(), - )); + let source_storage_bit_depth: u8 = if source_bit_depth <= 8 { 8 } else { 16 }; + 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}" + ))); } let direct_orientation_transform = rgba_orientation_transform_from_primary_transforms( @@ -7290,7 +7205,7 @@ fn decode_primary_heic_grid_to_rgba16_slice( if out.len() != expected { return Err(DecodeError::TransformGuard( TransformGuardError::RgbaSampleCountMismatch { - stage: "HEIC grid RGBA16 image adapter output", + stage: sample_count_stage, actual: out.len(), expected, width: output_width, @@ -7302,38 +7217,21 @@ fn decode_primary_heic_grid_to_rgba16_slice( // Caller-provided ImageDecoder buffers are not guaranteed to be // pre-cleared. Preserve the owned grid path's zero-filled gaps when // clipped tiles do not cover the descriptor output exactly. - out.fill(0); - let tile_width = first_tile.width; - let tile_height = first_tile.height; - let reference_layout = first_tile.layout; - let reference_bit_depth_luma = first_tile.bit_depth_luma; - let reference_bit_depth_chroma = first_tile.bit_depth_chroma; - let reference_ycbcr_range = first_tile.ycbcr_range; - let reference_ycbcr_matrix = first_tile.ycbcr_matrix; - let conversion_ycbcr_range = ycbcr_range_override_from_primary_colr(&grid_data.colr) - .unwrap_or(reference_ycbcr_range); - let conversion_ycbcr_matrix = ycbcr_matrix_override_from_primary_colr(&grid_data.colr) - .unwrap_or(reference_ycbcr_matrix); - return paste_heic_grid_tiles_to_rgba16( + out.fill(T::default()); + let reference = HeicGridTileReference::from_first_tile(&first_tile, &grid_data.colr); + return paste_heic_grid_tiles_to_rgba( grid_data, first_tile, out, - tile_width, - tile_height, - reference_layout, - reference_bit_depth_luma, - reference_bit_depth_chroma, - reference_ycbcr_range, - reference_ycbcr_matrix, - conversion_ycbcr_range, - conversion_ycbcr_matrix, + &reference, direct_orientation_transform.as_ref(), + convert_tile, ); } let decoded = decode_primary_heic_grid_to_rgba_image(grid_data, transforms, auxiliary_alpha, None)?; - copy_decoded_rgba16_to_slice(decoded, out) + copy_decoded_to_slice(decoded, out) } #[cfg(feature = "image-integration")] @@ -7466,16 +7364,12 @@ fn decode_heif_bytes_to_rgba8_slice( match primary_with_grid { isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { - guardrails.enforce_pixel_count( - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - )?; - let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + let auxiliary_alpha = decode_primary_heic_grid_auxiliary_alpha( input, &mut source, - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - ); + &grid_data, + &guardrails, + )?; decode_primary_heic_grid_to_rgba8_slice( &grid_data, &transforms, @@ -7484,23 +7378,12 @@ fn decode_heif_bytes_to_rgba8_slice( ) } 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)?; - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + let (decoded, auxiliary_alpha) = decode_primary_heic_coded_item_with_alpha( input, &mut source, - decoded.width, - decoded.height, - ); + &item_data, + &guardrails, + )?; decoded_heic_to_rgba8_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) } } @@ -7536,16 +7419,12 @@ fn decode_heif_bytes_to_rgba16_slice( match primary_with_grid { isobmff::HeicPrimaryItemDataWithGrid::Grid(grid_data) => { - guardrails.enforce_pixel_count( - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - )?; - let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + let auxiliary_alpha = decode_primary_heic_grid_auxiliary_alpha( input, &mut source, - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - ); + &grid_data, + &guardrails, + )?; decode_primary_heic_grid_to_rgba16_slice( &grid_data, &transforms, @@ -7554,23 +7433,12 @@ fn decode_heif_bytes_to_rgba16_slice( ) } 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)?; - guardrails.enforce_pixel_count(decoded.width, decoded.height)?; - let auxiliary_alpha = decode_primary_heic_auxiliary_alpha_plane_internal( + let (decoded, auxiliary_alpha) = decode_primary_heic_coded_item_with_alpha( input, &mut source, - decoded.width, - decoded.height, - ); + &item_data, + &guardrails, + )?; decoded_heic_to_rgba16_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) } } @@ -11325,6 +11193,45 @@ mod tests { assert_eq!(direct, transformed); } + #[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 From 7332af816327893931521f18f2e0893eec1ac43e Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 11:01:02 +0530 Subject: [PATCH 10/38] Document image hook input buffering trade-off --- API.md | 6 ++++++ src/image_integration.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/API.md b/API.md index 63cea05..35ff3c0 100644 --- a/API.md +++ b/API.md @@ -242,6 +242,12 @@ 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 intermediate owned RGBA copy. Set `max_input_bytes` in the registration +guardrails to bound that input buffer (the default guardrails leave it +unbounded). + ### 1) Register hooks once at startup ```rust diff --git a/src/image_integration.rs b/src/image_integration.rs index afb0a31..535197a 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -369,6 +369,14 @@ fn decoder_from_seekable_with_hint_and_guardrails( } } +/// 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 +/// (dropping the owned RGBA copy the eager path carries, which dominates +/// 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 only by `guardrails.max_input_bytes`. fn read_seekable_input_to_vec( mut input_reader: R, guardrails: &DecodeGuardrails, @@ -458,11 +466,22 @@ 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 intermediate RGBA copy). The default guardrails leave that +/// input buffer unbounded; production callers should prefer +/// [`register_image_decoder_hooks_with_guardrails`] with +/// [`DecodeGuardrails::max_input_bytes`] set. pub fn register_image_decoder_hooks() -> ImageHookRegistration { register_image_decoder_hooks_with_guardrails(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 { From 505f5fd2da548787f03f8193f7b3e7062d2b7335 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 11:11:00 +0530 Subject: [PATCH 11/38] Lock the lazy image adapter's memory and coverage contract The lazy adapter's slice paths now share one gate that refuses uncompressed HEIF (and AVIF) instead of silently falling back to a full decode plus copy, so a future layout-probe change cannot reintroduce a hidden owned-RGBA fallback; the hook constructor's eager fallback keeps such inputs decodable. A synthetic decodable uncompressed rgb3 fixture pins both halves: eager APIs and the hook fallback decode it, every lazy path rejects it as Unsupported. --- src/image_integration.rs | 49 +++++++++++++ src/isobmff.rs | 130 ++++++++++++++++++++++++++++------- src/lib.rs | 144 ++++++++++++++++++++++++++------------- 3 files changed, 251 insertions(+), 72 deletions(-) diff --git a/src/image_integration.rs b/src/image_integration.rs index 535197a..b932d76 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -330,6 +330,9 @@ impl ImageDecoder for LazyHeifImageDecoder { ) .map_err(decode_error_to_image_error) } else { + // Sole sanctioned decode-then-copy fallback on the lazy + // path: the caller's byte buffer is not u16-aligned, which + // is outside our control and vanishingly rare in practice. let decoded = decode_bytes_to_rgba_with_hint_and_guardrails( &self.input, self.hint, @@ -350,6 +353,19 @@ impl ImageDecoder for LazyHeifImageDecoder { } } +/// Build the hook decoder: lazy when the layout probe supports the input, +/// eager otherwise. +/// +/// Invariant pair the hook relies on: +/// - Coverage: whatever the layout probe rejects as `Unsupported` (today: +/// uncompressed HEIF and AVIF) MUST decode through the eager fallback +/// below, so hooks decode everything the owned decode APIs can. +/// - Memory: once the probe accepts an input, the lazy decoder's slice paths +/// either decode straight into the caller's buffer or fail loudly — they +/// never silently fall back to a full decode plus copy (enforced by +/// `reject_uncompressed_heif_on_lazy_adapter_paths` and the slice +/// dispatchers' AVIF rejection in `lib.rs`). A probe/slice mismatch shows +/// up as hard decode failures in the verify harness's per-file hook check. fn decoder_from_seekable_with_hint_and_guardrails( input_reader: R, hint: Option, @@ -831,3 +847,36 @@ 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, + }; + use std::io::Cursor; + + /// Locks the hook coverage half of the lazy-adapter contract: inputs the + /// layout probe rejects as unsupported (here: uncompressed HEIF) must + /// still decode through the eager fallback, pixel-identical to the + /// direct decode APIs. + #[test] + fn hook_decoder_falls_back_to_eager_for_uncompressed_heif() { + 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 fall back to the eager 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("eager fallback decode should succeed"); + assert_eq!(pixels, expected_rgba); + } +} diff --git a/src/isobmff.rs b/src/isobmff.rs index 8a622bd..68b7643 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,7 +7204,7 @@ mod tests { plain_box(b"iprp", &payload) } - fn iref_dimg_box(from_item_id: u16, to_item_ids: &[u16]) -> Vec { + pub(crate) fn iref_dimg_box(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()); @@ -7203,7 +7214,7 @@ mod tests { full_box(b"iref", 0, 0, &plain_box(b"dimg", &child_payload)) } - 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 +7222,75 @@ 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) + } +} + +#[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 d68d538..7116488 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6996,22 +6996,37 @@ fn decoded_rgba_layout_from_heic_geometry( }) } +/// Gate keeping uncompressed HEIF off every lazy-image-adapter path. +/// +/// The lazy adapter's contract is that `read_image` decodes into the caller's +/// buffer without materializing an owned RGBA copy. The uncompressed decode +/// path has no into-slice implementation yet, so the layout probe and the +/// slice decoders all route uncompressed primaries through this rejection: +/// the probe's `Unsupported` makes the hook constructor fall back to the +/// eager decoder (so hook coverage never regresses), and the slice decoders +/// refuse outright so a future probe change cannot silently reintroduce a +/// hidden full-decode-and-copy. Teaching the lazy adapter uncompressed HEIF +/// means implementing a true slice decode and removing this gate everywhere +/// (the contract tests next to `minimal_uncompressed_rgb3_heif` will insist). #[cfg(feature = "image-integration")] -fn decode_heif_bytes_to_rgba_layout( - input: &[u8], - guardrails: DecodeGuardrails, -) -> Result { +fn reject_uncompressed_heif_on_lazy_adapter_paths(input: &[u8]) -> Result<(), DecodeError> { match isobmff::parse_primary_uncompressed_item_properties(input) { - Ok(_) => { - return Err(DecodeError::Unsupported( - "lazy image adapter does not yet support uncompressed HEIF".to_string(), - )); - } + Ok(_) => Err(DecodeError::Unsupported( + "lazy image adapter does not yet support uncompressed HEIF".to_string(), + )), Err(isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. - }) => {} - Err(err) => return Err(DecodeUncompressedError::ParsePrimaryProperties(err).into()), + }) => Ok(()), + Err(err) => Err(DecodeUncompressedError::ParsePrimaryProperties(err).into()), } +} + +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba_layout( + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + reject_uncompressed_heif_on_lazy_adapter_paths(input)?; let transforms = isobmff::parse_primary_item_transform_properties(input) .map_err(DecodeHeicError::ParsePrimaryTransforms)? @@ -7340,20 +7355,7 @@ fn decode_heif_bytes_to_rgba8_slice( guardrails: DecodeGuardrails, out: &mut [u8], ) -> Result<(), DecodeError> { - 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; - let decoded = decoded_uncompressed_to_rgba_image(decoded, &transforms)?; - return copy_decoded_rgba8_to_slice(decoded, out); - } - Err(DecodeUncompressedError::ParsePrimaryProperties( - isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. }, - )) => {} - Err(err) => return Err(err.into()), - } + reject_uncompressed_heif_on_lazy_adapter_paths(input)?; let transforms = isobmff::parse_primary_item_transform_properties(input) .map_err(DecodeHeicError::ParsePrimaryTransforms)? @@ -7395,20 +7397,7 @@ fn decode_heif_bytes_to_rgba16_slice( guardrails: DecodeGuardrails, out: &mut [u16], ) -> Result<(), DecodeError> { - 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; - let decoded = decoded_uncompressed_to_rgba_image(decoded, &transforms)?; - return copy_decoded_rgba16_to_slice(decoded, out); - } - Err(DecodeUncompressedError::ParsePrimaryProperties( - isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. }, - )) => {} - Err(err) => return Err(err.into()), - } + reject_uncompressed_heif_on_lazy_adapter_paths(input)?; let transforms = isobmff::parse_primary_item_transform_properties(input) .map_err(DecodeHeicError::ParsePrimaryTransforms)? @@ -7485,10 +7474,13 @@ fn decode_bytes_to_rgba8_slice_with_hint_and_guardrails( })?; match family { HeifInputFamily::Heif => decode_heif_bytes_to_rgba8_slice(input, guardrails, out), - HeifInputFamily::Avif => { - let decoded = decode_avif_bytes_to_rgba(input, guardrails)?; - copy_decoded_rgba8_to_slice(decoded, out) - } + // Mirrors the layout probe's AVIF rejection: as long as the probe + // sends AVIF to the eager decoder, the slice path refuses instead of + // silently decoding to an owned copy (see + // reject_uncompressed_heif_on_lazy_adapter_paths for the contract). + HeifInputFamily::Avif => Err(DecodeError::Unsupported( + "lazy image adapter does not yet support AVIF".to_string(), + )), } } @@ -7510,10 +7502,10 @@ fn decode_bytes_to_rgba16_slice_with_hint_and_guardrails( })?; match family { HeifInputFamily::Heif => decode_heif_bytes_to_rgba16_slice(input, guardrails, out), - HeifInputFamily::Avif => { - let decoded = decode_avif_bytes_to_rgba(input, guardrails)?; - copy_decoded_rgba16_to_slice(decoded, out) - } + // Mirrors the layout probe's AVIF rejection; see the RGBA8 variant. + HeifInputFamily::Avif => Err(DecodeError::Unsupported( + "lazy image adapter does not yet support AVIF".to_string(), + )), } } @@ -11582,4 +11574,62 @@ mod tests { assert!(nclx_to_icc_profile(&nclx).is_none()); } + + // Lazy-image-adapter contract tests: uncompressed HEIF must decode + // through the eager APIs (coverage) while every lazy path refuses it + // (memory contract) until a true into-caller-buffer implementation + // exists. See reject_uncompressed_heif_on_lazy_adapter_paths. + + #[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_rejects_uncompressed_heif() { + let (file, _) = isobmff::test_support::minimal_uncompressed_rgb3_heif(); + + let err = super::decode_bytes_to_rgba_layout_with_hint_and_guardrails( + &file, + None, + super::DecodeGuardrails::default(), + ) + .expect_err("layout probe must reject uncompressed HEIF"); + assert!( + matches!(err, super::DecodeError::Unsupported(_)), + "probe rejection must be Unsupported so the hook falls back to \ + the eager decoder, got {err:?}" + ); + } + + #[cfg(feature = "image-integration")] + #[test] + fn lazy_slice_decodes_reject_uncompressed_heif() { + let (file, expected_rgba) = isobmff::test_support::minimal_uncompressed_rgb3_heif(); + + let mut rgba8 = vec![0_u8; expected_rgba.len()]; + let rgba8_err = super::decode_bytes_to_rgba8_slice_with_hint_and_guardrails( + &file, + None, + super::DecodeGuardrails::default(), + &mut rgba8, + ) + .expect_err("RGBA8 slice decode must refuse uncompressed HEIF"); + assert!(matches!(rgba8_err, super::DecodeError::Unsupported(_))); + + let mut rgba16 = vec![0_u16; expected_rgba.len()]; + let rgba16_err = super::decode_bytes_to_rgba16_slice_with_hint_and_guardrails( + &file, + None, + super::DecodeGuardrails::default(), + &mut rgba16, + ) + .expect_err("RGBA16 slice decode must refuse uncompressed HEIF"); + assert!(matches!(rgba16_err, super::DecodeError::Unsupported(_))); + } } From 53dc23a6f25243efe159f64e71da0973787fd5de Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 11:21:02 +0530 Subject: [PATCH 12/38] Deduplicate lazy RGBA decode paths and grid decode plumbing Share one implementation for the 8/16-bit halves of the owned grid RGBA decode, the coded-item slice decodes, the HEIF slice dispatch, the family-detection preamble, the paste buffer-length validation, and the image adapters' storage/byte-count helpers. LazyHeifImageDecoder gets a manual Debug impl so it no longer dumps the entire encoded input. --- src/image_integration.rs | 48 ++-- src/lib.rs | 597 +++++++++++++++++++-------------------- 2 files changed, 315 insertions(+), 330 deletions(-) diff --git a/src/image_integration.rs b/src/image_integration.rs index b932d76..7619b50 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -181,27 +181,15 @@ impl HeifImageDecoder { } 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.decoded.storage_bit_depth()) } fn expected_total_bytes(&self) -> ImageResult { - expected_rgba_byte_count( + expected_rgba_total_bytes( self.decoded.width, self.decoded.height, self.decoded.storage_bit_depth(), ) - .ok_or_else(|| { - parameter_error(format!( - "decoded RGBA buffer size overflow for {}x{} image", - self.decoded.width, self.decoded.height - )) - }) } } @@ -246,7 +234,6 @@ impl ImageDecoder for HeifImageDecoder { } } -#[derive(Clone, Debug, Eq, PartialEq)] struct LazyHeifImageDecoder { input: Vec, hint: Option, @@ -254,6 +241,19 @@ struct LazyHeifImageDecoder { layout: DecodedRgbaLayout, } +// 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() + } +} + impl LazyHeifImageDecoder { fn from_encoded_input( input: Vec, @@ -274,17 +274,11 @@ impl LazyHeifImageDecoder { } fn expected_total_bytes(&self) -> ImageResult { - expected_rgba_byte_count( + 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.layout.width, self.layout.height - )) - }) } } @@ -775,6 +769,16 @@ 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) } +/// `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 validate_decoded_rgba_image(decoded: &DecodedRgbaImage) -> ImageResult<()> { if decoded.storage_bit_depth() != 8 && decoded.storage_bit_depth() != 16 { return Err(ImageError::Unsupported( diff --git a/src/lib.rs b/src/lib.rs index 7116488..25c8674 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4212,6 +4212,56 @@ fn decode_primary_heic_grid_to_rgba_image( .into()); } + 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, + ) + } +} + +/// 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 @@ -4220,93 +4270,76 @@ fn decode_primary_heic_grid_to_rgba_image( 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(descriptor, &first_tile)?; - let reference = HeicGridTileReference::from_first_tile(&first_tile, &grid_data.colr); + validate_decoded_heic_grid_first_tile(&grid_data.descriptor, &first_tile)?; let source_bit_depth = heic_bit_depth_for_png_conversion(&first_tile)?; - // Direct orientation is safe for opaque grids. Alpha and clean aperture - // stay on the existing source-coordinate transform path. - let direct_orientation_transform = if auxiliary_alpha.is_none() { - rgba_orientation_transform_from_primary_transforms( - descriptor.output_width, - descriptor.output_height, - transforms, - )? - } else { - None - }; - let output_width = direct_orientation_transform + Ok((first_tile, source_bit_depth)) +} + +/// 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 = direct_orientation_transform + let output_height = orientation_transform .as_ref() .map_or(descriptor.output_height, |transform| { transform.destination_height }); + Ok((orientation_transform, output_width, output_height)) +} - if source_bit_depth <= 8 { - let mut output = vec![0_u8; checked_rgba_sample_count(output_width, output_height)?]; - paste_heic_grid_tiles_to_rgba( - grid_data, - first_tile, - &mut output, - &reference, - direct_orientation_transform.as_ref(), - convert_heic_to_rgba8_into, - )?; - if direct_orientation_transform.is_some() { - return Ok(DecodedRgbaImage { - width: output_width, - height: output_height, - source_bit_depth, - pixels: DecodedRgbaPixels::U8(output), - icc_profile, - }); - } - if let Some(alpha) = auxiliary_alpha { - apply_auxiliary_alpha_to_rgba8( - &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, - )?; - return Ok(DecodedRgbaImage { - width, - height, - source_bit_depth, - pixels: DecodedRgbaPixels::U8(transformed), - icc_profile, - }); - } - - let mut output = vec![0_u16; checked_rgba_sample_count(output_width, output_height)?]; +/// 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)?]; paste_heic_grid_tiles_to_rgba( grid_data, first_tile, &mut output, - &reference, + reference, direct_orientation_transform.as_ref(), - convert_heic_to_rgba16_into, + convert_tile, )?; if direct_orientation_transform.is_some() { return Ok(DecodedRgbaImage { width: output_width, height: output_height, source_bit_depth, - pixels: DecodedRgbaPixels::U16(output), + pixels: wrap_pixels(output), icc_profile, }); } if let Some(alpha) = auxiliary_alpha { - apply_auxiliary_alpha_to_rgba16( + apply_alpha( &mut output, descriptor.output_width, descriptor.output_height, @@ -4323,7 +4356,7 @@ fn decode_primary_heic_grid_to_rgba_image( width, height, source_bit_depth, - pixels: DecodedRgbaPixels::U16(transformed), + pixels: wrap_pixels(transformed), icc_profile, }) } @@ -4822,6 +4855,32 @@ fn rgba_orientation_transform_from_primary_transforms( })) } +/// 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!("{plane} {role} has {actual_len} samples, expected {expected_samples}"), + }); + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] fn paste_rgba_tile_with_clip( source: &[T], @@ -4865,39 +4924,24 @@ fn paste_rgba_tile_with_clip( detail: format!("{plane} y-origin {y_origin} cannot be represented"), })?; - let source_samples = source_width_usize - .checked_mul(source_height_usize) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} source sample count overflow for {source_width}x{source_height}" - ), - })?; - if source.len() != source_samples { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} source has {} samples, expected {source_samples}", - source.len() - ), - }); - } - - let destination_samples = destination_width_usize - .checked_mul(destination_height_usize) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination sample count overflow for {destination_width}x{destination_height}" - ), - })?; - if destination.len() != destination_samples { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination has {} samples, expected {destination_samples}", - destination.len() - ), - }); - } + 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", + )?; let remaining_width = destination_width_usize - x_origin_usize; let copy_width = source_width_usize.min(remaining_width); @@ -4982,43 +5026,24 @@ fn paste_transformed_rgba_tile_with_clip( detail: format!("{plane} y-origin {y_origin} cannot be represented"), })?; - let source_samples = source_width_usize - .checked_mul(source_height_usize) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} source sample count overflow for {source_width}x{source_height}" - ), - })?; - if source.len() != source_samples { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} source has {} samples, expected {source_samples}", - source.len() - ), - } - .into()); - } - - let destination_samples = orientation_transform - .destination_width_usize - .checked_mul(orientation_transform.destination_height_usize) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination sample count overflow for {}x{}", - orientation_transform.destination_width, orientation_transform.destination_height - ), - })?; - if destination.len() != destination_samples { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "{plane} destination has {} samples, expected {destination_samples}", - destination.len() - ), - } - .into()); - } + 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); @@ -6978,6 +7003,11 @@ fn heic_grid_source_bit_depth_for_png_conversion( heic_bit_depth_for_png_conversion_metadata(&metadata) } +/// RGBA storage width (8 or 16 bits per sample) for a HEIC source bit depth. +fn heic_storage_bit_depth(source_bit_depth: u8) -> u8 { + if source_bit_depth <= 8 { 8 } else { 16 } +} + #[cfg(feature = "image-integration")] fn decoded_rgba_layout_from_heic_geometry( width: u32, @@ -6991,7 +7021,7 @@ fn decoded_rgba_layout_from_heic_geometry( width, height, source_bit_depth, - storage_bit_depth: if source_bit_depth <= 8 { 8 } else { 16 }, + storage_bit_depth: heic_storage_bit_depth(source_bit_depth), icc_profile, }) } @@ -7184,38 +7214,16 @@ fn decode_primary_heic_grid_to_rgba_slice( copy_decoded_to_slice: fn(DecodedRgbaImage, &mut [T]) -> Result<(), DecodeError>, ) -> Result<(), DecodeError> { if auxiliary_alpha.is_none() && grid_transforms_can_write_directly(transforms) { - 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)?; - let source_storage_bit_depth: u8 = if source_bit_depth <= 8 { 8 } else { 16 }; + 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}" ))); } - let direct_orientation_transform = rgba_orientation_transform_from_primary_transforms( - grid_data.descriptor.output_width, - grid_data.descriptor.output_height, - transforms, - )?; - let output_width = direct_orientation_transform - .as_ref() - .map_or(grid_data.descriptor.output_width, |transform| { - transform.destination_width - }); - let output_height = direct_orientation_transform - .as_ref() - .map_or(grid_data.descriptor.output_height, |transform| { - transform.destination_height - }); + let (direct_orientation_transform, output_width, output_height) = + heic_grid_rgba_orientation_and_output_dims(&grid_data.descriptor, transforms)?; let expected = checked_rgba_sample_count(output_width, output_height)?; if out.len() != expected { return Err(DecodeError::TransformGuard( @@ -7251,60 +7259,59 @@ fn decode_primary_heic_grid_to_rgba_slice( #[cfg(feature = "image-integration")] fn decoded_heic_to_rgba8_slice( - mut decoded: DecodedHeicImage, + decoded: DecodedHeicImage, transforms: &[isobmff::PrimaryItemTransformProperty], auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u8], ) -> Result<(), DecodeError> { - 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 { - return Err(DecodeError::Unsupported( - "HEIC storage is RGBA16, not RGBA8".to_string(), - )); - } - - if remaining_transforms.is_empty() { - convert_heic_to_rgba8_slice(&decoded, out)?; - if let Some(alpha) = auxiliary_alpha { - apply_auxiliary_alpha_to_rgba8(out, decoded.width, decoded.height, alpha)?; - } - return Ok(()); - } - - 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, pixels) = apply_primary_item_transforms_rgba( - decoded.width, - decoded.height, - pixels, - remaining_transforms, - )?; - copy_decoded_rgba8_to_slice( - DecodedRgbaImage { - width, - height, - source_bit_depth, - pixels: DecodedRgbaPixels::U8(pixels), - icc_profile: None, - }, + decoded_heic_to_rgba_slice( + decoded, + transforms, + auxiliary_alpha, out, + 8, + convert_heic_to_rgba8_slice, + convert_heic_to_rgba8, + apply_auxiliary_alpha_to_rgba8, + copy_decoded_rgba8_to_slice, + DecodedRgbaPixels::U8, ) } #[cfg(feature = "image-integration")] fn decoded_heic_to_rgba16_slice( - mut decoded: DecodedHeicImage, + decoded: DecodedHeicImage, transforms: &[isobmff::PrimaryItemTransformProperty], auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u16], +) -> Result<(), DecodeError> { + decoded_heic_to_rgba_slice( + decoded, + transforms, + auxiliary_alpha, + out, + 16, + convert_heic_to_rgba16_slice, + convert_heic_to_rgba16, + apply_auxiliary_alpha_to_rgba16, + copy_decoded_rgba16_to_slice, + DecodedRgbaPixels::U16, + ) +} + +#[cfg(feature = "image-integration")] +#[allow(clippy::too_many_arguments)] +fn decoded_heic_to_rgba_slice( + mut decoded: DecodedHeicImage, + transforms: &[isobmff::PrimaryItemTransformProperty], + auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, + out: &mut [T], + storage_bit_depth: u8, + convert_slice: fn(&DecodedHeicImage, &mut [T]) -> Result<(), DecodeHeicError>, + convert_owned: fn(&DecodedHeicImage) -> Result, DecodeHeicError>, + apply_alpha: fn(&mut [T], u32, u32, &HeicAuxiliaryAlphaPlane) -> Result<(), DecodeHeicError>, + copy_decoded_to_slice: fn(DecodedRgbaImage, &mut [T]) -> Result<(), DecodeError>, + wrap_pixels: fn(Vec) -> DecodedRgbaPixels, ) -> Result<(), DecodeError> { let mut remaining_transforms = transforms; if auxiliary_alpha.is_none() { @@ -7313,23 +7320,24 @@ fn decoded_heic_to_rgba16_slice( } let source_bit_depth = heic_bit_depth_for_png_conversion(&decoded)?; - if source_bit_depth <= 8 { - return Err(DecodeError::Unsupported( - "HEIC storage is RGBA8, not RGBA16".to_string(), - )); + 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}" + ))); } if remaining_transforms.is_empty() { - convert_heic_to_rgba16_slice(&decoded, out)?; + convert_slice(&decoded, out)?; if let Some(alpha) = auxiliary_alpha { - apply_auxiliary_alpha_to_rgba16(out, decoded.width, decoded.height, alpha)?; + apply_alpha(out, decoded.width, decoded.height, alpha)?; } return Ok(()); } - let mut pixels = convert_heic_to_rgba16(&decoded)?; + let mut pixels = convert_owned(&decoded)?; if let Some(alpha) = auxiliary_alpha { - apply_auxiliary_alpha_to_rgba16(&mut pixels, decoded.width, decoded.height, alpha)?; + apply_alpha(&mut pixels, decoded.width, decoded.height, alpha)?; } let (width, height, pixels) = apply_primary_item_transforms_rgba( decoded.width, @@ -7337,12 +7345,12 @@ fn decoded_heic_to_rgba16_slice( pixels, remaining_transforms, )?; - copy_decoded_rgba16_to_slice( + copy_decoded_to_slice( DecodedRgbaImage { width, height, source_bit_depth, - pixels: DecodedRgbaPixels::U16(pixels), + pixels: wrap_pixels(pixels), icc_profile: None, }, out, @@ -7355,40 +7363,13 @@ fn decode_heif_bytes_to_rgba8_slice( guardrails: DecodeGuardrails, out: &mut [u8], ) -> Result<(), DecodeError> { - reject_uncompressed_heif_on_lazy_adapter_paths(input)?; - - let transforms = isobmff::parse_primary_item_transform_properties(input) - .map_err(DecodeHeicError::ParsePrimaryTransforms)? - .transforms; - let mut source: Option<&mut dyn RandomAccessSource> = None; - let primary_with_grid = - 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_primary_heic_grid_to_rgba8_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, - )?; - decoded_heic_to_rgba8_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) - } - } + decode_heif_bytes_to_rgba_slice( + input, + guardrails, + out, + decode_primary_heic_grid_to_rgba8_slice, + decoded_heic_to_rgba8_slice, + ) } #[cfg(feature = "image-integration")] @@ -7396,6 +7377,39 @@ fn decode_heif_bytes_to_rgba16_slice( input: &[u8], guardrails: DecodeGuardrails, out: &mut [u16], +) -> Result<(), DecodeError> { + decode_heif_bytes_to_rgba_slice( + input, + guardrails, + out, + decode_primary_heic_grid_to_rgba16_slice, + decoded_heic_to_rgba16_slice, + ) +} + +#[cfg(feature = "image-integration")] +type HeicGridSliceDecode = fn( + &isobmff::HeicGridPrimaryItemData, + &[isobmff::PrimaryItemTransformProperty], + Option<&HeicAuxiliaryAlphaPlane>, + &mut [T], +) -> Result<(), DecodeError>; + +#[cfg(feature = "image-integration")] +type HeicCodedSliceDecode = fn( + DecodedHeicImage, + &[isobmff::PrimaryItemTransformProperty], + Option<&HeicAuxiliaryAlphaPlane>, + &mut [T], +) -> Result<(), DecodeError>; + +#[cfg(feature = "image-integration")] +fn decode_heif_bytes_to_rgba_slice( + input: &[u8], + guardrails: DecodeGuardrails, + out: &mut [T], + decode_grid_slice: HeicGridSliceDecode, + decode_coded_slice: HeicCodedSliceDecode, ) -> Result<(), DecodeError> { reject_uncompressed_heif_on_lazy_adapter_paths(input)?; @@ -7414,12 +7428,7 @@ fn decode_heif_bytes_to_rgba16_slice( &grid_data, &guardrails, )?; - decode_primary_heic_grid_to_rgba16_slice( - &grid_data, - &transforms, - auxiliary_alpha.as_ref(), - out, - ) + 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( @@ -7428,27 +7437,38 @@ fn decode_heif_bytes_to_rgba16_slice( &item_data, &guardrails, )?; - decoded_heic_to_rgba16_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) + decode_coded_slice(decoded, &transforms, auxiliary_alpha.as_ref(), out) } } } +/// 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], + 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 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 { - 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::Heif => decode_heif_bytes_to_rgba_layout(input, guardrails), HeifInputFamily::Avif => Err(DecodeError::Unsupported( "lazy image adapter does not yet support AVIF preflight".to_string(), @@ -7463,16 +7483,7 @@ fn decode_bytes_to_rgba8_slice_with_hint_and_guardrails( guardrails: DecodeGuardrails, out: &mut [u8], ) -> 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::Heif => decode_heif_bytes_to_rgba8_slice(input, guardrails, out), // Mirrors the layout probe's AVIF rejection: as long as the probe // sends AVIF to the eager decoder, the slice path refuses instead of @@ -7491,16 +7502,7 @@ fn decode_bytes_to_rgba16_slice_with_hint_and_guardrails( guardrails: DecodeGuardrails, out: &mut [u16], ) -> 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::Heif => decode_heif_bytes_to_rgba16_slice(input, guardrails, out), // Mirrors the layout probe's AVIF rejection; see the RGBA8 variant. HeifInputFamily::Avif => Err(DecodeError::Unsupported( @@ -7837,16 +7839,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), } @@ -7858,16 +7851,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), } @@ -7914,12 +7898,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), From ef948d61c48ca02b3954e8c7ad530479f9aa43e8 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 11:22:33 +0530 Subject: [PATCH 13/38] Pin the Rust toolchain in CI fmt and clippy verdicts no longer drift with the runner image's preinstalled stable; both jobs install and default to the pinned version (matching the locally verified toolchain). --- .github/workflows/tests.yml | 16 ++++++++++++++++ TESTING.md | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c427307..af0c403 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,6 +56,11 @@ 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 diff --git a/TESTING.md b/TESTING.md index ca0d5fd..46808ae 100644 --- a/TESTING.md +++ b/TESTING.md @@ -29,9 +29,9 @@ 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 From c6552d40b2ec02c1b9bc182a08b68acc2d95a991 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 11:23:48 +0530 Subject: [PATCH 14/38] Mark the 709-family sRGB transfer aliasing as a deliberate divergence --- src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 25c8674..a9c15b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8172,6 +8172,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, From b3552a9ecb9208ee5d89e257458607e545117e87 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 11:24:38 +0530 Subject: [PATCH 15/38] Document that the per-file image hook check is intentional --- scripts/heic_tests.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/heic_tests.sh b/scripts/heic_tests.sh index e327ca5..6a11c36 100755 --- a/scripts/heic_tests.sh +++ b/scripts/heic_tests.sh @@ -1348,6 +1348,12 @@ 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 + # 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 From e53f619b8e8d92bc8da871bf85b94a5852161a21 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 11:26:20 +0530 Subject: [PATCH 16/38] Feature-gate heic_storage_bit_depth to its image-integration users --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index a9c15b7..20b2ffb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7004,6 +7004,7 @@ fn heic_grid_source_bit_depth_for_png_conversion( } /// 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 } } From e4b260a606fba6460c3b037f65d0d7cfb1ebdbcd Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 14:26:41 +0530 Subject: [PATCH 17/38] Decode image hooks directly into caller buffers --- API.md | 7 +- src/image_integration.rs | 111 +- src/lib.rs | 2131 ++++++++++++++++++++++++++++++-------- 3 files changed, 1725 insertions(+), 524 deletions(-) diff --git a/API.md b/API.md index 35ff3c0..57ae4a2 100644 --- a/API.md +++ b/API.md @@ -244,9 +244,10 @@ 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 intermediate owned RGBA copy. Set `max_input_bytes` in the registration -guardrails to bound that input buffer (the default guardrails leave it -unbounded). +an additional full-frame owned RGBA allocation. Codec-native planes and a +single grid-tile scratch buffer may still be allocated. Set `max_input_bytes` +in the registration guardrails to bound the encoded input buffer (the default +guardrails leave it unbounded). ### 1) Register hooks once at startup diff --git a/src/image_integration.rs b/src/image_integration.rs index 7619b50..e430da8 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -11,10 +11,10 @@ use crate::{ DecodeError, DecodeGuardrailError, DecodeGuardrails, DecodedRgbaImage, DecodedRgbaLayout, DecodedRgbaPixels, HeifInputFamily, decode_bufread_to_rgba_with_guardrails, decode_bytes_to_rgba_layout_with_hint_and_guardrails, decode_bytes_to_rgba_with_guardrails, - decode_bytes_to_rgba_with_hint_and_guardrails, decode_bytes_to_rgba8_slice_with_hint_and_guardrails, - decode_bytes_to_rgba16_slice_with_hint_and_guardrails, decode_path_to_rgba_with_guardrails, - decode_read_to_rgba_with_guardrails, decode_seekable_to_rgba_with_hint_and_guardrails, + decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails, + decode_path_to_rgba_with_guardrails, decode_read_to_rgba_with_guardrails, + decode_seekable_to_rgba_with_hint_and_guardrails, }; use image::error::{ DecodingError, ImageFormatHint, ParameterError, ParameterErrorKind, UnsupportedError, @@ -314,28 +314,13 @@ impl ImageDecoder for LazyHeifImageDecoder { buf, ) .map_err(decode_error_to_image_error), - 16 => { - if let Some(samples) = native_endian_bytes_as_u16_slice_mut(buf) { - decode_bytes_to_rgba16_slice_with_hint_and_guardrails( - &self.input, - self.hint, - self.guardrails, - samples, - ) - .map_err(decode_error_to_image_error) - } else { - // Sole sanctioned decode-then-copy fallback on the lazy - // path: the caller's byte buffer is not u16-aligned, which - // is outside our control and vanishingly rare in practice. - let decoded = decode_bytes_to_rgba_with_hint_and_guardrails( - &self.input, - self.hint, - self.guardrails, - ) - .map_err(decode_error_to_image_error)?; - HeifImageDecoder::from_decoded(decoded)?.read_image(buf) - } - } + 16 => decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails( + &self.input, + self.hint, + self.guardrails, + buf, + ) + .map_err(decode_error_to_image_error), other => { unreachable!("validated storage bit depth must be 8 or 16, got {other}") } @@ -347,46 +332,34 @@ impl ImageDecoder for LazyHeifImageDecoder { } } -/// Build the hook decoder: lazy when the layout probe supports the input, -/// eager otherwise. +/// Build a hook decoder that probes metadata up front and defers pixel decode +/// until `read_image` supplies the destination buffer. /// -/// Invariant pair the hook relies on: -/// - Coverage: whatever the layout probe rejects as `Unsupported` (today: -/// uncompressed HEIF and AVIF) MUST decode through the eager fallback -/// below, so hooks decode everything the owned decode APIs can. -/// - Memory: once the probe accepts an input, the lazy decoder's slice paths -/// either decode straight into the caller's buffer or fail loudly — they -/// never silently fall back to a full decode plus copy (enforced by -/// `reject_uncompressed_heif_on_lazy_adapter_paths` and the slice -/// dispatchers' AVIF rejection in `lib.rs`). A probe/slice mismatch shows -/// up as hard decode failures in the verify harness's per-file hook check. +/// 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)?; - match decode_bytes_to_rgba_layout_with_hint_and_guardrails(&input, hint, guardrails.clone()) { - Ok(layout) => Ok(Box::new(LazyHeifImageDecoder::from_encoded_input( - input, hint, guardrails, layout, - ))), - Err(DecodeError::Unsupported(_)) => { - let decoded = decode_bytes_to_rgba_with_hint_and_guardrails(&input, hint, guardrails) - .map_err(decode_error_to_image_error)?; - Ok(Box::new(HeifImageDecoder::from_decoded(decoded)?)) - } - Err(err) => Err(decode_error_to_image_error(err)), - } + let layout = + 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, layout, + ))) } /// 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 -/// (dropping the owned RGBA copy the eager path carries, which dominates -/// 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 only by `guardrails.max_input_bytes`. +/// (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 only by `guardrails.max_input_bytes`. fn read_seekable_input_to_vec( mut input_reader: R, guardrails: &DecodeGuardrails, @@ -434,21 +407,6 @@ fn read_seekable_input_to_vec( Ok(input) } -fn native_endian_bytes_as_u16_slice_mut(buf: &mut [u8]) -> Option<&mut [u16]> { - if !buf.len().is_multiple_of(std::mem::size_of::()) { - return None; - } - - // SAFETY: `u16` accepts every bit pattern. We only return the middle slice - // when the whole byte buffer was properly aligned and exactly covered. - let (prefix, samples, suffix) = unsafe { buf.align_to_mut::() }; - if prefix.is_empty() && suffix.is_empty() { - Some(samples) - } else { - None - } -} - /// Result of attempting to install `image` crate decoder hooks for this crate. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ImageHookRegistration { @@ -479,8 +437,10 @@ impl ImageHookRegistration { /// /// Memory: hook decodes buffer the entire encoded input in memory before /// decoding (in exchange, pixels decode straight into the caller's buffer -/// without an intermediate RGBA copy). The default guardrails leave that -/// input buffer unbounded; production callers should prefer +/// without an additional full-frame RGBA allocation). Codec-native planes +/// and a single grid-tile scratch buffer may still be allocated. The default +/// guardrails leave the encoded input buffer unbounded; production callers +/// should prefer /// [`register_image_decoder_hooks_with_guardrails`] with /// [`DecodeGuardrails::max_input_bytes`] set. pub fn register_image_decoder_hooks() -> ImageHookRegistration { @@ -860,12 +820,11 @@ mod tests { }; use std::io::Cursor; - /// Locks the hook coverage half of the lazy-adapter contract: inputs the - /// layout probe rejects as unsupported (here: uncompressed HEIF) must - /// still decode through the eager fallback, pixel-identical to the - /// direct decode APIs. + /// 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_falls_back_to_eager_for_uncompressed_heif() { + 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( @@ -873,14 +832,14 @@ mod tests { Some(HeifInputFamily::Heif), DecodeGuardrails::default(), ) - .expect("hook construction must fall back to the eager decoder"); + .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("eager fallback decode should succeed"); + .expect("lazy uncompressed decode should succeed"); assert_eq!(pixels, expected_rgba); } } diff --git a/src/lib.rs b/src/lib.rs index 20b2ffb..018dfb6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1690,6 +1690,168 @@ fn decode_primary_uncompressed_to_image_internal( input: &[u8], source: &mut Option<&mut dyn RandomAccessSource>, ) -> Result { + 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]) + } +} + +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} @@ -2279,145 +2441,18 @@ 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 { + Ok(DecodedUncompressedChannels { width, height, - bit_depth: output_bit_depth, - rgba, - icc_profile, + 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), }) } @@ -3400,6 +3435,81 @@ 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 { + if properties.unc_c.full_box.version == 1 { + return match properties.unc_c.profile.as_bytes() { + bytes if bytes == *b"rgb3" || bytes == *b"rgba" || bytes == *b"abgr" => Ok(8), + _ => Err(DecodeUncompressedError::UnsupportedFeature { + detail: format!( + "unsupported uncC v1 profile {} for baseline uncompressed decode", + properties.unc_c.profile + ), + }), + }; + } + + let cmpd = properties + .cmpd + .as_ref() + .ok_or_else(|| DecodeUncompressedError::InvalidInput { + detail: format!( + "primary item_ID {} is missing required cmpd mapping for uncC version {}", + properties.item_id, properties.unc_c.full_box.version + ), + })?; + let mut has_channel = [false; UNCOMPRESSED_CHANNEL_COUNT]; + let mut channel_bit_depths = [0_u8; UNCOMPRESSED_CHANNEL_COUNT]; + for component in &properties.unc_c.components { + let component_def = cmpd + .components + .get(usize::from(component.component_index)) + .ok_or_else(|| DecodeUncompressedError::InvalidInput { + detail: format!( + "uncC component index {} exceeds cmpd component count {}", + component.component_index, + cmpd.components.len() + ), + })?; + if component.component_format != UNCOMPRESSED_COMPONENT_FORMAT_UNSIGNED + || component.component_bit_depth == 0 + || component.component_bit_depth > 16 + { + return Err(DecodeUncompressedError::UnsupportedFeature { + detail: format!( + "unsupported uncompressed component format/depth {}/{}", + component.component_format, component.component_bit_depth + ), + }); + } + let role = uncompressed_role_from_component_type(component_def.component_type)?; + let Some(channel_index) = role.channel_index() else { + continue; + }; + let bit_depth = component.component_bit_depth as u8; + if has_channel[channel_index] { + let duplicate_multi_y = properties.unc_c.interleave_type + == UNCOMPRESSED_INTERLEAVE_MULTI_Y + && channel_index == UNCOMPRESSED_CHANNEL_LUMA + && channel_bit_depths[channel_index] == bit_depth; + if duplicate_multi_y { + 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] = bit_depth; + } + select_uncompressed_output_bit_depth(&has_channel, &channel_bit_depths) +} + fn max_sample_for_bit_depth(bit_depth: u8) -> Result { if bit_depth == 0 || bit_depth > 16 { return Err(DecodeUncompressedError::InvalidInput { @@ -4180,37 +4290,7 @@ fn decode_primary_heic_grid_to_rgba_image( icc_profile: Option>, ) -> Result { 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 - ), - } - .into()); - } - - 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 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() - ), - } - .into()); - } + 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); @@ -4257,15 +4337,52 @@ fn decode_primary_heic_grid_to_rgba_image( } } -/// 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( +fn validate_heic_grid_descriptor_and_tile_count( grid_data: &isobmff::HeicGridPrimaryItemData, -) -> Result<(DecodedHeicImage, u8), DecodeHeicError> { - let first_tile_data = - grid_data - .tiles - .first() +) -> 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 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() + ), + }); + } + + Ok(()) +} + +/// 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(), })?; @@ -4596,6 +4713,301 @@ struct RgbaOrientationTransform { 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 }, + )); + } + + 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, + }) + } + + fn map_destination_pixel( + &self, + destination_x: usize, + destination_y: usize, + ) -> Result<(usize, usize), DecodeError> { + 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, + }) + })?; + 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, + }, + )); + } + + 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> { + 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, + }, + )); + } + + 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(|_| { @@ -7027,37 +7439,31 @@ fn decoded_rgba_layout_from_heic_geometry( }) } -/// Gate keeping uncompressed HEIF off every lazy-image-adapter path. -/// -/// The lazy adapter's contract is that `read_image` decodes into the caller's -/// buffer without materializing an owned RGBA copy. The uncompressed decode -/// path has no into-slice implementation yet, so the layout probe and the -/// slice decoders all route uncompressed primaries through this rejection: -/// the probe's `Unsupported` makes the hook constructor fall back to the -/// eager decoder (so hook coverage never regresses), and the slice decoders -/// refuse outright so a future probe change cannot silently reintroduce a -/// hidden full-decode-and-copy. Teaching the lazy adapter uncompressed HEIF -/// means implementing a true slice decode and removing this gate everywhere -/// (the contract tests next to `minimal_uncompressed_rgb3_heif` will insist). -#[cfg(feature = "image-integration")] -fn reject_uncompressed_heif_on_lazy_adapter_paths(input: &[u8]) -> Result<(), DecodeError> { - match isobmff::parse_primary_uncompressed_item_properties(input) { - Ok(_) => Err(DecodeError::Unsupported( - "lazy image adapter does not yet support uncompressed HEIF".to_string(), - )), - Err(isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { - .. - }) => Ok(()), - Err(err) => Err(DecodeUncompressedError::ParsePrimaryProperties(err).into()), - } -} - #[cfg(feature = "image-integration")] fn decode_heif_bytes_to_rgba_layout( input: &[u8], guardrails: DecodeGuardrails, ) -> Result { - reject_uncompressed_heif_on_lazy_adapter_paths(input)?; + 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), + ); + } + Err(isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { + .. + }) => {} + Err(err) => return Err(DecodeUncompressedError::ParsePrimaryProperties(err).into()), + } let transforms = isobmff::parse_primary_item_transform_properties(input) .map_err(DecodeHeicError::ParsePrimaryTransforms)? @@ -7098,70 +7504,91 @@ fn decode_heif_bytes_to_rgba_layout( } #[cfg(feature = "image-integration")] -fn copy_decoded_rgba8_to_slice( - decoded: DecodedRgbaImage, - out: &mut [u8], -) -> Result<(), DecodeError> { - match decoded.pixels { - DecodedRgbaPixels::U8(pixels) => { - if pixels.len() != out.len() { - return Err(DecodeError::TransformGuard( - TransformGuardError::RgbaSampleCountMismatch { - stage: "RGBA8 image adapter handoff", - actual: out.len(), - expected: pixels.len(), - width: decoded.width, - height: decoded.height, - }, - )); - } - out.copy_from_slice(&pixels); - Ok(()) +fn decode_avif_bytes_to_rgba_layout( + input: &[u8], + guardrails: DecodeGuardrails, +) -> Result { + let (_, 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 = if properties.av1c.twelve_bit { + 12 + } else if properties.av1c.high_bitdepth { + 10 + } else { + 8 + }; + 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), + }) +} + +#[cfg(feature = "image-integration")] +trait RgbaSampleOutput { + fn sample_len(&self) -> usize; + fn write_sample(&mut self, index: usize, sample: T); + + fn fill(&mut self, sample: T) { + for index in 0..self.sample_len() { + self.write_sample(index, sample); } - DecodedRgbaPixels::U16(_) => Err(DecodeError::Unsupported( - "decoded image storage is RGBA16, not RGBA8".to_string(), - )), } } #[cfg(feature = "image-integration")] -fn copy_decoded_rgba16_to_slice( - decoded: DecodedRgbaImage, - out: &mut [u16], -) -> Result<(), DecodeError> { - match decoded.pixels { - DecodedRgbaPixels::U16(pixels) => { - if pixels.len() != out.len() { - return Err(DecodeError::TransformGuard( - TransformGuardError::RgbaSampleCountMismatch { - stage: "RGBA16 image adapter handoff", - actual: out.len(), - expected: pixels.len(), - width: decoded.width, - height: decoded.height, - }, - )); - } - out.copy_from_slice(&pixels); - Ok(()) - } - DecodedRgbaPixels::U8(_) => Err(DecodeError::Unsupported( - "decoded image storage is RGBA8, not RGBA16".to_string(), - )), +struct SliceRgbaOutput<'a, T>(&'a mut [T]); + +#[cfg(feature = "image-integration")] +impl RgbaSampleOutput for SliceRgbaOutput<'_, T> { + fn sample_len(&self) -> usize { + self.0.len() + } + + fn write_sample(&mut self, index: usize, sample: T) { + self.0[index] = sample; + } + + fn fill(&mut self, sample: T) { + self.0.fill(sample); } } #[cfg(feature = "image-integration")] -fn grid_transforms_can_write_directly( - transforms: &[isobmff::PrimaryItemTransformProperty], -) -> bool { - transforms.iter().all(|transform| { - matches!( - transform, - isobmff::PrimaryItemTransformProperty::Rotation(_) - | isobmff::PrimaryItemTransformProperty::Mirror(_) - ) - }) +struct NativeEndianRgba16Output<'a>(&'a mut [u8]); + +#[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()); + } + + fn fill(&mut self, sample: u16) { + let bytes = sample.to_ne_bytes(); + for chunk in self.0.chunks_exact_mut(2) { + chunk.copy_from_slice(&bytes); + } + } } #[cfg(feature = "image-integration")] @@ -7171,191 +7598,844 @@ fn decode_primary_heic_grid_to_rgba8_slice( auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, out: &mut [u8], ) -> Result<(), DecodeError> { - decode_primary_heic_grid_to_rgba_slice( + let mut output = SliceRgbaOutput(out); + decode_primary_heic_grid_to_rgba_output( grid_data, transforms, auxiliary_alpha, - out, + &mut output, 8, "HEIC grid RGBA8 image adapter output", convert_heic_to_rgba8_into, - copy_decoded_rgba8_to_slice, + scale_sample_to_u8, ) } #[cfg(feature = "image-integration")] -fn decode_primary_heic_grid_to_rgba16_slice( +#[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 [u16], + out: &mut [u8], ) -> Result<(), DecodeError> { - decode_primary_heic_grid_to_rgba_slice( + let mut output = NativeEndianRgba16Output(out); + decode_primary_heic_grid_to_rgba_output( grid_data, transforms, auxiliary_alpha, - out, + &mut output, 16, - "HEIC grid RGBA16 image adapter output", + "HEIC grid RGBA16 image adapter byte output", convert_heic_to_rgba16_into, - copy_decoded_rgba16_to_slice, + scale_sample_to_u16, ) } #[cfg(feature = "image-integration")] #[allow(clippy::too_many_arguments)] -fn decode_primary_heic_grid_to_rgba_slice( +fn decode_primary_heic_grid_to_rgba_output>( grid_data: &isobmff::HeicGridPrimaryItemData, transforms: &[isobmff::PrimaryItemTransformProperty], auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, - out: &mut [T], + out: &mut O, storage_bit_depth: u8, sample_count_stage: &'static str, convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, - copy_decoded_to_slice: fn(DecodedRgbaImage, &mut [T]) -> Result<(), DecodeError>, + scale_alpha: fn(u16, u8) -> T, ) -> Result<(), DecodeError> { - if auxiliary_alpha.is_none() && grid_transforms_can_write_directly(transforms) { - 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}" - ))); - } - - let (direct_orientation_transform, output_width, output_height) = - heic_grid_rgba_orientation_and_output_dims(&grid_data.descriptor, transforms)?; - let expected = checked_rgba_sample_count(output_width, output_height)?; - if out.len() != expected { - return Err(DecodeError::TransformGuard( - TransformGuardError::RgbaSampleCountMismatch { - stage: sample_count_stage, - actual: out.len(), - expected, - width: output_width, - height: output_height, - }, - )); - } - - // Caller-provided ImageDecoder buffers are not guaranteed to be - // pre-cleared. Preserve the owned grid path's zero-filled gaps when - // clipped tiles do not cover the descriptor output exactly. - out.fill(T::default()); - let reference = HeicGridTileReference::from_first_tile(&first_tile, &grid_data.colr); - return paste_heic_grid_tiles_to_rgba( - grid_data, - first_tile, - out, - &reference, - direct_orientation_transform.as_ref(), - convert_tile, - ); + 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}" + ))); } - let decoded = - decode_primary_heic_grid_to_rgba_image(grid_data, transforms, auxiliary_alpha, None)?; - copy_decoded_to_slice(decoded, out) -} - -#[cfg(feature = "image-integration")] -fn decoded_heic_to_rgba8_slice( - decoded: DecodedHeicImage, - transforms: &[isobmff::PrimaryItemTransformProperty], - auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, - out: &mut [u8], -) -> Result<(), DecodeError> { - decoded_heic_to_rgba_slice( - decoded, + let transform_plan = RgbaTransformPlan::from_primary_transforms( + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, transforms, - auxiliary_alpha, - out, - 8, - convert_heic_to_rgba8_slice, - convert_heic_to_rgba8, - apply_auxiliary_alpha_to_rgba8, - copy_decoded_rgba8_to_slice, - DecodedRgbaPixels::U8, - ) + )?; + 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, + }, + )); + } + + // Caller-provided ImageDecoder buffers are not guaranteed to be + // pre-cleared. Preserve the owned grid path's zero-filled gaps when + // clipped tiles do not cover the descriptor output exactly. + out.fill(T::default()); + if let Some(alpha) = auxiliary_alpha { + validate_auxiliary_alpha_plane( + alpha, + grid_data.descriptor.output_width, + grid_data.descriptor.output_height, + )?; + 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 zero-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), + ); + } + } + } + let reference = HeicGridTileReference::from_first_tile(&first_tile, &grid_data.colr); + paste_heic_grid_tiles_to_transformed_rgba_slice( + grid_data, + first_tile, + out, + &reference, + &transform_plan, + auxiliary_alpha, + convert_tile, + scale_alpha, + ) +} + +#[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 columns = usize::from(descriptor.columns); + 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 + ), + } + })?; + 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 + ), + } + })?; + 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 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", + )?; + + 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)?; + 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; + }; + let source_pixel = tile_y + .checked_mul(tile_width) + .and_then(|row| row.checked_add(tile_x)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile source pixel index overflow".to_string(), + })?; + let source_sample = source_pixel.checked_mul(4).ok_or_else(|| { + DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile source sample index overflow".to_string(), + } + })?; + let destination_sample = destination_y + .checked_mul(destination_width) + .and_then(|row| row.checked_add(destination_x)) + .and_then(|pixel| pixel.checked_mul(4)) + .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { + detail: "grid tile destination sample index overflow".to_string(), + })?; + 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(()) +} + +#[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, + ) +} + +#[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 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 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 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)?; + let source_index = source_y + .checked_mul(source_width) + .and_then(|row| row.checked_add(source_x)) + .ok_or({ + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "HEIC direct transformed source", + x: source_x, + y: source_y, + width: decoded.width, + height: decoded.height, + }) + })?; + 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 + .checked_mul(destination_width) + .and_then(|row| row.checked_add(destination_x)) + .and_then(|pixel| pixel.checked_mul(4)) + .ok_or({ + DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { + stage: "HEIC direct transformed destination", + x: destination_x, + y: destination_y, + width: transform_plan.destination_width, + height: transform_plan.destination_height, + }) + })?; + 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); + } + } + + Ok(()) +} + +#[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) +} + +#[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) +} + +#[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(), + )); + } + 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.len() != expected { + return Err(DecodeError::TransformGuard( + TransformGuardError::RgbaSampleCountMismatch { + stage: "AVIF direct transformed RGBA8 image adapter output", + actual: out.len(), + expected, + width: transform_plan.destination_width, + height: transform_plan.destination_height, + }, + )); + } + + 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_u8(&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(), + } + .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_u8(decoded)?; + 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, ChromaPlanesU8::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::from(y_samples[source_index]); + let (cb_sample, cr_sample) = match &chroma { + ChromaPlanesU8::Monochrome => (chroma_midpoint, chroma_midpoint), + ChromaPlanesU8::Color { + u_samples, + v_samples, + chroma_width, + layout, + } => { + let chroma_index = + 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 destination_index = (destination_y * destination_width + destination_x) * 4; + out[destination_index] = scale_sample_to_u8(red, decoded.bit_depth); + out[destination_index + 1] = scale_sample_to_u8(green, decoded.bit_depth); + out[destination_index + 2] = scale_sample_to_u8(blue, decoded.bit_depth); + out[destination_index + 3] = alpha + .as_ref() + .map(|plane| avif_auxiliary_alpha_sample_to_u8(plane, source_index)) + .unwrap_or(u8::MAX); + } + } + + Ok(()) } #[cfg(feature = "image-integration")] -fn decoded_heic_to_rgba16_slice( - decoded: DecodedHeicImage, +fn decoded_avif_to_rgba16_output>( + decoded: &DecodedAvifImage, transforms: &[isobmff::PrimaryItemTransformProperty], - auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, - out: &mut [u16], + out: &mut O, ) -> Result<(), DecodeError> { - decoded_heic_to_rgba_slice( - decoded, - transforms, - auxiliary_alpha, - out, - 16, - convert_heic_to_rgba16_slice, - convert_heic_to_rgba16, - apply_auxiliary_alpha_to_rgba16, - copy_decoded_rgba16_to_slice, - DecodedRgbaPixels::U16, - ) + if decoded.bit_depth <= 8 { + return Err(DecodeError::Unsupported( + "AVIF storage is RGBA8, not RGBA16".to_string(), + )); + } + 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: "AVIF direct transformed RGBA16 image adapter output", + actual: out.sample_len(), + expected, + width: transform_plan.destination_width, + height: transform_plan.destination_height, + }, + )); + } + + 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_u16(&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(), + } + .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_u16(decoded)?; + 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, + ); + + 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::from(y_samples[source_index]); + let (cb_sample, cr_sample) = match &chroma { + ChromaPlanesU16::Monochrome => (chroma_midpoint, chroma_midpoint), + ChromaPlanesU16::Color { + u_samples, + v_samples, + chroma_width, + layout, + } => { + let chroma_index = + 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) = 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_to_u16(red, decoded.bit_depth), + ); + out.write_sample( + destination_index + 1, + scale_sample_to_u16(green, decoded.bit_depth), + ); + out.write_sample( + destination_index + 2, + scale_sample_to_u16(blue, decoded.bit_depth), + ); + out.write_sample( + destination_index + 3, + alpha + .as_ref() + .map(|plane| avif_auxiliary_alpha_sample_to_u16(plane, source_index)) + .unwrap_or(u16::MAX), + ); + } + } + + Ok(()) } #[cfg(feature = "image-integration")] -#[allow(clippy::too_many_arguments)] -fn decoded_heic_to_rgba_slice( - mut decoded: DecodedHeicImage, - transforms: &[isobmff::PrimaryItemTransformProperty], - auxiliary_alpha: Option<&HeicAuxiliaryAlphaPlane>, - out: &mut [T], +fn try_decode_uncompressed_heif_to_rgba_output>( + input: &[u8], + guardrails: &DecodeGuardrails, + out: &mut O, storage_bit_depth: u8, - convert_slice: fn(&DecodedHeicImage, &mut [T]) -> Result<(), DecodeHeicError>, - convert_owned: fn(&DecodedHeicImage) -> Result, DecodeHeicError>, - apply_alpha: fn(&mut [T], u32, u32, &HeicAuxiliaryAlphaPlane) -> Result<(), DecodeHeicError>, - copy_decoded_to_slice: fn(DecodedRgbaImage, &mut [T]) -> Result<(), DecodeError>, - wrap_pixels: fn(Vec) -> DecodedRgbaPixels, -) -> Result<(), DecodeError> { - 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)?; - let source_storage_bit_depth = heic_storage_bit_depth(source_bit_depth); + 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!( - "HEIC storage is RGBA{source_storage_bit_depth}, not RGBA{storage_bit_depth}" + "uncompressed HEIF storage is RGBA{source_storage_bit_depth}, not RGBA{storage_bit_depth}" ))); } - if remaining_transforms.is_empty() { - convert_slice(&decoded, out)?; - if let Some(alpha) = auxiliary_alpha { - apply_alpha(out, decoded.width, decoded.height, alpha)?; - } - return Ok(()); + 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, + }, + )); } - let mut pixels = convert_owned(&decoded)?; - if let Some(alpha) = auxiliary_alpha { - apply_alpha(&mut pixels, decoded.width, decoded.height, alpha)?; + 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), + ); + } + } } - let (width, height, pixels) = apply_primary_item_transforms_rgba( - decoded.width, - decoded.height, - pixels, - remaining_transforms, - )?; - copy_decoded_to_slice( - DecodedRgbaImage { - width, - height, - source_bit_depth, - pixels: wrap_pixels(pixels), - icc_profile: None, - }, - out, - ) + + Ok(true) } #[cfg(feature = "image-integration")] @@ -7364,6 +8444,16 @@ fn decode_heif_bytes_to_rgba8_slice( guardrails: DecodeGuardrails, 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, @@ -7374,18 +8464,58 @@ fn decode_heif_bytes_to_rgba8_slice( } #[cfg(feature = "image-integration")] -fn decode_heif_bytes_to_rgba16_slice( +fn decode_heif_bytes_to_rgba16_native_endian_bytes( input: &[u8], guardrails: DecodeGuardrails, - out: &mut [u16], + out: &mut [u8], ) -> Result<(), DecodeError> { - decode_heif_bytes_to_rgba_slice( + let mut output = NativeEndianRgba16Output(out); + if try_decode_uncompressed_heif_to_rgba_output( input, - guardrails, - out, - decode_primary_heic_grid_to_rgba16_slice, - decoded_heic_to_rgba16_slice, - ) + &guardrails, + &mut output, + 16, + scale_sample_to_u16, + )? { + return Ok(()); + } + + let transforms = isobmff::parse_primary_item_transform_properties(input) + .map_err(DecodeHeicError::ParsePrimaryTransforms)? + .transforms; + let mut source: Option<&mut dyn RandomAccessSource> = None; + let primary_with_grid = + 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_primary_heic_grid_to_rgba16_native_endian_bytes( + &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, + )?; + decoded_heic_to_rgba16_native_endian_bytes( + decoded, + &transforms, + auxiliary_alpha.as_ref(), + out, + ) + } + } } #[cfg(feature = "image-integration")] @@ -7412,8 +8542,6 @@ fn decode_heif_bytes_to_rgba_slice( decode_grid_slice: HeicGridSliceDecode, decode_coded_slice: HeicCodedSliceDecode, ) -> Result<(), DecodeError> { - reject_uncompressed_heif_on_lazy_adapter_paths(input)?; - let transforms = isobmff::parse_primary_item_transform_properties(input) .map_err(DecodeHeicError::ParsePrimaryTransforms)? .transforms; @@ -7471,9 +8599,7 @@ fn decode_bytes_to_rgba_layout_with_hint_and_guardrails( ) -> Result { match enforce_and_resolve_input_family(input, hint, &guardrails)? { HeifInputFamily::Heif => decode_heif_bytes_to_rgba_layout(input, guardrails), - HeifInputFamily::Avif => Err(DecodeError::Unsupported( - "lazy image adapter does not yet support AVIF preflight".to_string(), - )), + HeifInputFamily::Avif => decode_avif_bytes_to_rgba_layout(input, guardrails), } } @@ -7486,29 +8612,29 @@ fn decode_bytes_to_rgba8_slice_with_hint_and_guardrails( ) -> Result<(), DecodeError> { match enforce_and_resolve_input_family(input, hint, &guardrails)? { HeifInputFamily::Heif => decode_heif_bytes_to_rgba8_slice(input, guardrails, out), - // Mirrors the layout probe's AVIF rejection: as long as the probe - // sends AVIF to the eager decoder, the slice path refuses instead of - // silently decoding to an owned copy (see - // reject_uncompressed_heif_on_lazy_adapter_paths for the contract). - HeifInputFamily::Avif => Err(DecodeError::Unsupported( - "lazy image adapter does not yet support AVIF".to_string(), - )), + HeifInputFamily::Avif => decode_avif_bytes_to_rgba8_slice(input, guardrails, out), } } #[cfg(feature = "image-integration")] -fn decode_bytes_to_rgba16_slice_with_hint_and_guardrails( +fn decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails( input: &[u8], hint: Option, guardrails: DecodeGuardrails, - out: &mut [u16], + 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_slice(input, guardrails, out), - // Mirrors the layout probe's AVIF rejection; see the RGBA8 variant. - HeifInputFamily::Avif => Err(DecodeError::Unsupported( - "lazy image adapter does not yet support AVIF".to_string(), - )), + HeifInputFamily::Heif => { + decode_heif_bytes_to_rgba16_native_endian_bytes(input, guardrails, out) + } + HeifInputFamily::Avif => { + decode_avif_bytes_to_rgba16_native_endian_bytes(input, guardrails, out) + } } } @@ -8228,7 +9354,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 { @@ -11412,6 +12551,109 @@ mod tests { 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] { @@ -11565,10 +12807,21 @@ mod tests { assert!(nclx_to_icc_profile(&nclx).is_none()); } - // Lazy-image-adapter contract tests: uncompressed HEIF must decode - // through the eager APIs (coverage) while every lazy path refuses it - // (memory contract) until a true into-caller-buffer implementation - // exists. See reject_uncompressed_heif_on_lazy_adapter_paths. + #[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() { @@ -11581,45 +12834,33 @@ mod tests { #[cfg(feature = "image-integration")] #[test] - fn lazy_layout_probe_rejects_uncompressed_heif() { + fn lazy_layout_probe_accepts_uncompressed_heif() { let (file, _) = isobmff::test_support::minimal_uncompressed_rgb3_heif(); - let err = super::decode_bytes_to_rgba_layout_with_hint_and_guardrails( + let layout = super::decode_bytes_to_rgba_layout_with_hint_and_guardrails( &file, None, super::DecodeGuardrails::default(), ) - .expect_err("layout probe must reject uncompressed HEIF"); - assert!( - matches!(err, super::DecodeError::Unsupported(_)), - "probe rejection must be Unsupported so the hook falls back to \ - the eager decoder, got {err:?}" - ); + .expect("layout probe must accept uncompressed HEIF"); + 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_reject_uncompressed_heif() { + 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()]; - let rgba8_err = super::decode_bytes_to_rgba8_slice_with_hint_and_guardrails( + super::decode_bytes_to_rgba8_slice_with_hint_and_guardrails( &file, None, super::DecodeGuardrails::default(), &mut rgba8, ) - .expect_err("RGBA8 slice decode must refuse uncompressed HEIF"); - assert!(matches!(rgba8_err, super::DecodeError::Unsupported(_))); - - let mut rgba16 = vec![0_u16; expected_rgba.len()]; - let rgba16_err = super::decode_bytes_to_rgba16_slice_with_hint_and_guardrails( - &file, - None, - super::DecodeGuardrails::default(), - &mut rgba16, - ) - .expect_err("RGBA16 slice decode must refuse uncompressed HEIF"); - assert!(matches!(rgba16_err, super::DecodeError::Unsupported(_))); + .expect("RGBA8 slice decode must support uncompressed HEIF"); + assert_eq!(rgba8, expected_rgba); } } From 359609ca9c9b0b4f6f3db27ecab9c33ff48e138c Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 14:36:02 +0530 Subject: [PATCH 18/38] Fill uncovered grid pixels with the converted zero-YUV color The RGBA grid decode paths zero-filled their output canvas, so descriptor pixels not covered by any tile decoded as transparent black. libheif (and this crate's plane-canvas grid path) composes grids on a zero-filled YUV canvas and converts the whole canvas afterwards, which renders those gap pixels as the converted all-zero YUV sample: opaque, green-tinted for limited-range color. Match that exactly by converting a single zero sample through the same tile conversion function and filling the canvas with it before pasting, in both the owned grid path and the image-hook slice path. The fill only runs when the uniform tile lattice does not cover the descriptor, so fully-covered grids (the normal case) keep the plain zero fill. --- src/lib.rs | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 018dfb6..3731111 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4438,6 +4438,16 @@ fn finish_heic_grid_rgba_decode( ) -> 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, @@ -4538,6 +4548,59 @@ impl HeicGridTileReference { } } +/// 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, @@ -7674,10 +7737,24 @@ fn decode_primary_heic_grid_to_rgba_output Date: Fri, 10 Jul 2026 14:38:28 +0530 Subject: [PATCH 19/38] Cap the hook input pre-allocation at 64 MiB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_seekable_input_to_vec sized Vec::with_capacity directly from the length reported by seek(SeekFrom::End(0)) before reading a single byte. That length is untrusted: a lying or corrupt reader (or a huge sparse file under the default unbounded guardrails) triggered a multi-gigabyte allocation, a capacity-overflow panic, or an uncatchable allocator abort inside the library. The previous eager hook never allocated from the reported length. Clamp the speculative pre-allocation to 64 MiB — read_to_end still grows the buffer for genuinely larger, guardrail-permitted inputs — and route both input-size checks through the existing enforce_input_bytes helper instead of two hand-built InputTooLarge constructions. --- src/image_integration.rs | 89 ++++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/src/image_integration.rs b/src/image_integration.rs index e430da8..ec2b4aa 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -8,8 +8,8 @@ //! See `API.md` in the crate root for end-to-end examples. use crate::{ - DecodeError, DecodeGuardrailError, DecodeGuardrails, DecodedRgbaImage, DecodedRgbaLayout, - DecodedRgbaPixels, HeifInputFamily, decode_bufread_to_rgba_with_guardrails, + DecodeError, DecodeGuardrails, DecodedRgbaImage, DecodedRgbaLayout, DecodedRgbaPixels, + HeifInputFamily, decode_bufread_to_rgba_with_guardrails, decode_bytes_to_rgba_layout_with_hint_and_guardrails, decode_bytes_to_rgba_with_guardrails, decode_bytes_to_rgba8_slice_with_hint_and_guardrails, decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails, @@ -360,6 +360,14 @@ fn decoder_from_seekable_with_hint_and_guardrails( /// 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 only by `guardrails.max_input_bytes`. +/// 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 = 64 * 1024 * 1024; + fn read_seekable_input_to_vec( mut input_reader: R, guardrails: &DecodeGuardrails, @@ -367,24 +375,17 @@ fn read_seekable_input_to_vec( let input_len = input_reader .seek(SeekFrom::End(0)) .map_err(ImageError::IoError)?; - if let Some(max_input_bytes) = guardrails.max_input_bytes - && input_len > max_input_bytes - { - return Err(decode_error_to_image_error( - DecodeGuardrailError::InputTooLarge { - actual_bytes: input_len, - max_input_bytes, - } - .into(), - )); - } + guardrails + .enforce_input_bytes(input_len) + .map_err(decode_error_to_image_error)?; input_reader .seek(SeekFrom::Start(0)) .map_err(ImageError::IoError)?; - let capacity = usize::try_from(input_len).map_err(|_| { + let prealloc_len = input_len.min(MAX_INPUT_PREALLOCATION_BYTES); + let capacity = usize::try_from(prealloc_len).map_err(|_| { parameter_error(format!( - "input size {input_len} bytes does not fit in memory on this platform" + "input size {prealloc_len} bytes does not fit in memory on this platform" )) })?; let mut input = Vec::with_capacity(capacity); @@ -392,17 +393,11 @@ fn read_seekable_input_to_vec( .read_to_end(&mut input) .map_err(ImageError::IoError)?; - if let Some(max_input_bytes) = guardrails.max_input_bytes - && input.len() as u64 > max_input_bytes - { - return Err(decode_error_to_image_error( - DecodeGuardrailError::InputTooLarge { - actual_bytes: input.len() as u64, - max_input_bytes, - } - .into(), - )); - } + // 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) } @@ -842,4 +837,46 @@ mod tests { .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), + } + } + } + + #[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); + } } From fed350ac3fecc5ac7495abc8e1f964cec59bd4b5 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 14:45:52 +0530 Subject: [PATCH 20/38] Derive the AVIF layout probe's bit depth from the sequence header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy image-hook layout probe took its bit depth from the av1C box's twelve_bit/high_bitdepth flags, while read_image's slice decode validates its output depth against the rav1d-decoded frame and the direct API trusts only the decoded frame. An AVIF whose av1C flags disagree with the coded stream (spec-invalid but craftable) therefore failed through image::ImageReader while decode_bytes_to_rgba succeeded on the same bytes. Parse the actual AV1 sequence header instead, mirroring how the HEIC probe reads the SPS: rav1d's dav1d_parse_sequence_header scans the av1C configOBUs (present in practice, ~100 bytes), with a fallback to the primary item payload, and a new MissingSequenceHeader error when neither carries one — a file the decode path could not decode either. --- src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3731111..58771fe 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, @@ -842,6 +846,7 @@ pub enum DecodeAvifError { UnsupportedMatrixCoefficients { matrix_coefficients: u16, }, + MissingSequenceHeader, } impl DecodeAvifError { @@ -867,9 +872,8 @@ impl DecodeAvifError { | DecodeAvifError::DecodedGeometryMismatch { .. } | DecodeAvifError::PlaneSampleTypeMismatch { .. } | DecodeAvifError::PlaneDimensionsMismatch { .. } - | DecodeAvifError::PlaneSampleCountMismatch { .. } => { - DecodeErrorCategory::MalformedInput - } + | DecodeAvifError::PlaneSampleCountMismatch { .. } + | DecodeAvifError::MissingSequenceHeader => DecodeErrorCategory::MalformedInput, } } } @@ -897,6 +901,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, @@ -7571,7 +7581,7 @@ fn decode_avif_bytes_to_rgba_layout( input: &[u8], guardrails: DecodeGuardrails, ) -> Result { - let (_, resolved) = isobmff::resolve_primary_avif_item_graph(input) + 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) @@ -7581,13 +7591,8 @@ fn decode_avif_bytes_to_rgba_layout( .map_err(DecodeAvifError::ParsePrimaryTransforms)?; guardrails.enforce_pixel_count(properties.ispe.width, properties.ispe.height)?; - let source_bit_depth = if properties.av1c.twelve_bit { - 12 - } else if properties.av1c.high_bitdepth { - 10 - } else { - 8 - }; + 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, @@ -11923,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`. @@ -12390,6 +12453,21 @@ mod tests { assert_eq!(direct, transformed); } + #[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 From 7fce2d7e4f8528ec5c1eaec941279344b01a7a48 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 14:51:47 +0530 Subject: [PATCH 21/38] Expose primary-item EXIF through the image hook decoder The lazy hook decoder implemented only dimensions/color_type/icc_profile, so image-crate callers always saw Orientation::NoTransforms: the image crate derives ImageDecoder::orientation() from exif_metadata(), whose default returns None. Since decode deliberately does not bake EXIF orientation into pixels, an EXIF-only-rotated HEIC rendered sideways for anyone using the standard trait surface, even though the crate parses that orientation internally for its own hint helpers. Override exif_metadata() to return the primary item's cdsc-linked EXIF block, TIFF-aligned as Orientation::from_exif_chunk expects. The cdsc walk is shared with the existing orientation hint path, and the TIFF offset candidates are shared with the orientation parser. --- API.md | 6 ++ src/image_integration.rs | 44 ++++++++++++++ src/isobmff.rs | 73 ++++++++++++++++++++++- src/lib.rs | 126 +++++++++++++++++++++++++++++---------- 4 files changed, 214 insertions(+), 35 deletions(-) diff --git a/API.md b/API.md index 57ae4a2..eab9fbe 100644 --- a/API.md +++ b/API.md @@ -286,6 +286,12 @@ let img = if let Some(orientation) = hint.orientation_to_apply() { }; ``` +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. Pixels +stay unrotated either way; applying orientation remains the caller's choice. + ### 3) Direct adapter usage (optional) `HeifImageDecoder` constructors: diff --git a/src/image_integration.rs b/src/image_integration.rs index ec2b4aa..06c11fc 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -295,6 +295,15 @@ impl ImageDecoder for LazyHeifImageDecoder { 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)) + } + fn read_image(self, buf: &mut [u8]) -> ImageResult<()> where Self: Sized, @@ -862,6 +871,41 @@ mod tests { } } + /// 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); + } + #[test] fn hook_decoder_survives_lying_seek_length() { let (file, expected_rgba) = crate::isobmff::test_support::minimal_uncompressed_rgb3_heif(); diff --git a/src/isobmff.rs b/src/isobmff.rs index 68b7643..d4d1db4 100644 --- a/src/isobmff.rs +++ b/src/isobmff.rs @@ -7204,14 +7204,22 @@ pub(crate) mod test_support { plain_box(b"iprp", &payload) } - pub(crate) 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) } pub(crate) fn meta_file(children: &[Vec]) -> Vec { @@ -7278,6 +7286,67 @@ pub(crate) mod test_support { 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. + pub(crate) fn minimal_uncompressed_rgb3_heif_with_exif_orientation( + orientation: u16, + ) -> (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 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(&[ispe_box(2, 1), uncc_v1_box(b"rgb3")], &[(1, &[1, 2])]), + ]) + }; + // 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)] diff --git a/src/lib.rs b/src/lib.rs index 58771fe..40b2d7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6717,11 +6717,40 @@ fn primary_exif_orientation_from_heif( input: &[u8], source: &mut Option<&mut dyn RandomAccessSource>, ) -> Option { - 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()?; + 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 { @@ -6757,18 +6786,26 @@ fn primary_exif_orientation_from_heif( continue; } - 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); + 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 } @@ -6839,24 +6876,7 @@ fn exif_orientation_to_primary_item_transforms( } 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); - } - } - if !candidates.contains(&0) { - candidates.push(0); - } - - for tiff_start in candidates { + for tiff_start in exif_item_payload_tiff_start_candidates(payload) { let Some(orientation) = parse_exif_orientation_from_tiff(payload, tiff_start) else { continue; }; @@ -6867,6 +6887,40 @@ fn parse_exif_orientation_from_item_payload(payload: &[u8]) -> Option { 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; @@ -6882,7 +6936,9 @@ enum TiffByteOrder { BigEndian, } -fn parse_exif_orientation_from_tiff(payload: &[u8], tiff_start: usize) -> Option { +/// 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, @@ -6894,7 +6950,11 @@ fn parse_exif_orientation_from_tiff(payload: &[u8], tiff_start: usize) -> Option if magic != TIFF_MAGIC_NUMBER { return None; } + Some(byte_order) +} +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)?; From 67e3fe9e6166cd04fcbaf870cb1a4a97153b99cf Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 14:56:33 +0530 Subject: [PATCH 22/38] Add an identity fast path to the RGBA transform-plan mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every lazy hook decode loop (coded HEIC, AVIF 8/16-bit, uncompressed, grid paste, grid alpha seeding) calls map_destination_pixel or map_source_pixel once per pixel. For the common no-irot/imir/clap case each call still performed two usize-to-u32 conversions, range checks, an empty step walk, and Result wrapping — tens of millions of redundant operations per multi-megapixel hook decode that the owned eager paths avoid with linear row loops. Short-circuit both mapping functions when the plan has no steps (which by construction implies destination dimensions equal source dimensions). Also drop the per-pixel checked index chains in the coded-HEIC and grid paste loops in favor of the plain in-bounds indexing their AVIF and uncompressed twins already use: the mapped coordinates are in-bounds by construction and the validated sample counts fit usize, so the checked variants only constructed unreachable error values per pixel. --- src/lib.rs | 77 ++++++++++++++++++++++++------------------------------ 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 40b2d7f..6b929aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4922,11 +4922,26 @@ impl RgbaTransformPlan { }) } + /// 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", @@ -5010,6 +5025,13 @@ impl RgbaTransformPlan { 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", @@ -7985,24 +8007,12 @@ fn paste_heic_grid_tiles_to_transformed_rgba_slice>( 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 - .checked_mul(source_width) - .and_then(|row| row.checked_add(source_x)) - .ok_or({ - DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { - stage: "HEIC direct transformed source", - x: source_x, - y: source_y, - width: decoded.width, - height: decoded.height, - }) - })?; + // 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), @@ -8187,19 +8190,7 @@ fn decoded_heic_to_rgba_output>( let alpha = auxiliary_alpha.map_or(opaque_alpha, |alpha| { scale_sample(alpha.samples[source_index], alpha.bit_depth) }); - let destination_index = destination_y - .checked_mul(destination_width) - .and_then(|row| row.checked_add(destination_x)) - .and_then(|pixel| pixel.checked_mul(4)) - .ok_or({ - DecodeError::TransformGuard(TransformGuardError::PixelIndexOverflow { - stage: "HEIC direct transformed destination", - x: destination_x, - y: destination_y, - width: transform_plan.destination_width, - height: transform_plan.destination_height, - }) - })?; + 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)); From 5b051ef05fe95ddaa79321a38be0392d7c62b608 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 14:57:34 +0530 Subject: [PATCH 23/38] Route the 16-bit HEIF hook decode through the shared dispatch decode_heif_bytes_to_rgba16_native_endian_bytes inlined a step-for-step copy of decode_heif_bytes_to_rgba_slice's grid/coded dispatch (parse transforms, extract primary item, resolve auxiliary alpha, match Grid/Coded) while the RGBA8 twin already called the shared helper. Any future change to the dispatch would have had to be applied twice, with the 16-bit hook path silently keeping the old behavior when missed. The 16-bit slice functions write native-endian u16 samples into a byte slice, so they instantiate the shared dispatch at T = u8 directly. --- src/lib.rs | 47 ++++++++++------------------------------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6b929aa..0dcb4be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8611,43 +8611,16 @@ fn decode_heif_bytes_to_rgba16_native_endian_bytes( )? { return Ok(()); } - - let transforms = isobmff::parse_primary_item_transform_properties(input) - .map_err(DecodeHeicError::ParsePrimaryTransforms)? - .transforms; - let mut source: Option<&mut dyn RandomAccessSource> = None; - let primary_with_grid = - 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_primary_heic_grid_to_rgba16_native_endian_bytes( - &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, - )?; - decoded_heic_to_rgba16_native_endian_bytes( - decoded, - &transforms, - auxiliary_alpha.as_ref(), - out, - ) - } - } + // 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, + out, + decode_primary_heic_grid_to_rgba16_native_endian_bytes, + decoded_heic_to_rgba16_native_endian_bytes, + ) } #[cfg(feature = "image-integration")] From b0fb2bf9471eded8305bc9a6d0757944e160f3ab Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 15:22:39 +0530 Subject: [PATCH 24/38] Raise the hook input pre-allocation cap to 128 MiB The cap only bounds the speculative up-front allocation sized from the untrusted seek-reported length; larger guardrail-permitted inputs still load fully via read_to_end growth. 128 MiB gives headroom for very large camera files while keeping a lying reader's claimed length harmless. --- src/image_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/image_integration.rs b/src/image_integration.rs index 06c11fc..ec41537 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -375,7 +375,7 @@ fn decoder_from_seekable_with_hint_and_guardrails( /// 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 = 64 * 1024 * 1024; +const MAX_INPUT_PREALLOCATION_BYTES: u64 = 128 * 1024 * 1024; fn read_seekable_input_to_vec( mut input_reader: R, From 251d57a8a78c914e5e785fd3b20e34ae812009fa Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 15:46:49 +0530 Subject: [PATCH 25/38] Read probe SPS metadata from hvcC without assembling the stream The lazy layout probe extracted the primary item's full coded payload and re-copied it NAL-by-NAL into a length-prefixed decoder stream just to parse one SPS, then discarded the stream; read_image repeats the extraction and assembly when pixels are actually decoded. The SPS normally sits in the hvcC parameter-set arrays, so the probe can read geometry and bit depth without touching a single payload byte. Probe coded (hvc1/hev1) primaries via the preflight properties parse: take the SPS from the hvcC arrays and keep the existing SPS-vs-ispe geometry validation. hev1 items whose parameter sets live only in-stream fall back to an in-place scan of the length-prefixed payload (same NAL order as the assembled stream, still no copies), and any other miss falls through to the unchanged extraction path so errors stay identical to the decode path's. The grid probe reads the first tile's SPS the same way instead of assembling the tile's stream. The single-SPS parse is shared with the stream scanner via hevc_metadata_from_sps_nal, and the payload NAL walk is shared with stream assembly via walk_length_prefixed_payload_nals, so the probe introduces no parallel SPS interpretation that could drift from decode. Full verify corpus: 272 files, 219 pixel-exact with hook/direct parity, 0 failures. bench-image aggregate adapter/direct ratio 1.12x -> 1.10x; non-grid coded HEIC 1.22x -> 1.04x. --- src/lib.rs | 293 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 246 insertions(+), 47 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0dcb4be..2170c7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6504,56 +6504,116 @@ fn decode_hevc_stream_metadata_from_sps( if nal_unit.nal_unit_type() != Some(NALUnitType::SpsNut) { 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(), - })?; + return hevc_metadata_from_sps_nal(nal_unit.bytes, nal_unit.offset); + } - 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), - }); - } + Err(DecodeHeicError::MissingSpsNalUnit) +} - 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)?; +/// 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(), + })?; - return Ok(DecodedHeicImageMetadata { - width, - height, - bit_depth_luma: sps.bit_depth_y(), - bit_depth_chroma: sps.bit_depth_c(), - layout, + 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), }); } - Err(DecodeHeicError::MissingSpsNalUnit) + 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( @@ -7565,8 +7625,8 @@ fn heic_grid_source_bit_depth_for_png_conversion( .ok_or_else(|| DecodeHeicError::InvalidDecodedFrame { detail: "grid tile list cannot be empty".to_string(), })?; - let stream = assemble_heic_hevc_stream_from_components(&first_tile.hvcc, &first_tile.payload)?; - let metadata = decode_hevc_stream_metadata_from_sps(&stream)?; + let metadata = + decode_hevc_metadata_from_hvcc_or_payload(&first_tile.hvcc, &first_tile.payload)?; heic_bit_depth_for_png_conversion_metadata(&metadata) } @@ -7624,6 +7684,33 @@ fn decode_heif_bytes_to_rgba_layout( .map_err(DecodeHeicError::ParsePrimaryTransforms)? .transforms; let icc_profile = primary_icc_profile_from_heic(input); + + // 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, + ); + } + let primary_with_grid = isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)?; @@ -10674,6 +10761,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() { @@ -10703,7 +10804,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; } @@ -12477,6 +12580,102 @@ mod tests { 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() { From 0ae17c54ccbfd52f99d3ec73628b6e6a11d021b8 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 16:50:20 +0530 Subject: [PATCH 26/38] Suppress hook EXIF orientation when container transforms bake rotation The lazy hook decoder returned the EXIF block unconditionally, so for files carrying both an irot/imir transform (which decode bakes into the pixels) and an EXIF orientation tag, generic image-crate callers using decoder.orientation() + apply_orientation would rotate the already rotated pixels a second time. Override orientation() to report NoTransforms exactly when the primary item has an orientation transform, mirroring ExifOrientationHint::should_apply_exif_orientation; the EXIF block itself stays exposed through exif_metadata. --- API.md | 8 +++-- src/image_integration.rs | 64 ++++++++++++++++++++++++++++++++++++++++ src/isobmff.rs | 23 ++++++++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/API.md b/API.md index eab9fbe..0488ae9 100644 --- a/API.md +++ b/API.md @@ -289,8 +289,12 @@ let img = if let Some(orientation) = hint.orientation_to_apply() { 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. Pixels -stay unrotated either way; applying orientation remains the caller's choice. +`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. ### 3) Direct adapter usage (optional) diff --git a/src/image_integration.rs b/src/image_integration.rs index ec41537..35674d1 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -21,6 +21,7 @@ use image::error::{ 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; @@ -304,6 +305,20 @@ impl ImageDecoder for LazyHeifImageDecoder { 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<()> where Self: Sized, @@ -906,6 +921,55 @@ mod tests { 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(); diff --git a/src/isobmff.rs b/src/isobmff.rs index d4d1db4..4cec754 100644 --- a/src/isobmff.rs +++ b/src/isobmff.rs @@ -7294,6 +7294,23 @@ pub(crate) mod test_support { /// the raw TIFF block `ImageDecoder::exif_metadata` must hand back. 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, &[]) + } + + 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. + 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]; @@ -7316,6 +7333,10 @@ pub(crate) mod test_support { 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(&[ @@ -7326,7 +7347,7 @@ pub(crate) mod test_support { ]), iinf_box(&[infe_box(1, b"unci"), infe_box(2, b"Exif")]), iref_reference_box(b"cdsc", 2, &[1]), - iprp_box(&[ispe_box(2, 1), uncc_v1_box(b"rgb3")], &[(1, &[1, 2])]), + iprp_box(&properties, &[(1, &association_indices)]), ]) }; // The iloc extent offsets are file-absolute and all iloc fields are From 74d68c66a166e5b7bc9d758e843831c866d777db Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 16:51:57 +0530 Subject: [PATCH 27/38] Cap the default hook registration's encoded input buffer at 128 MiB Hook decodes buffer the whole encoded input since the lazy-decoder rework, so the previously harmless unbounded default became a multi-gigabyte allocation risk for oversized inputs (e.g. motion-photo HEICs). register_image_decoder_hooks() now applies DEFAULT_HOOK_MAX_INPUT_BYTES; explicit-guardrail registration keeps full control. Also reunite the read_seekable_input_to_vec doc comment with its function (a const had been inserted between them). --- API.md | 7 ++++--- src/image_integration.rs | 38 +++++++++++++++++++++++++------------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/API.md b/API.md index 0488ae9..51f8c3d 100644 --- a/API.md +++ b/API.md @@ -245,9 +245,10 @@ 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. Set `max_input_bytes` -in the registration guardrails to bound the encoded input buffer (the default -guardrails leave it unbounded). +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 diff --git a/src/image_integration.rs b/src/image_integration.rs index 35674d1..258b12e 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -376,14 +376,6 @@ fn decoder_from_seekable_with_hint_and_guardrails( ))) } -/// 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 only by `guardrails.max_input_bytes`. /// 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 @@ -392,6 +384,23 @@ fn decoder_from_seekable_with_hint_and_guardrails( /// 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, @@ -457,13 +466,16 @@ impl ImageHookRegistration { /// 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 default -/// guardrails leave the encoded input buffer unbounded; production callers -/// should prefer +/// 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. +/// [`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. From 9bc10f4546e1b6d40969588a6cde678dcb2b8f6f Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 16:55:21 +0530 Subject: [PATCH 28/38] Remove the eager HeifImageDecoder adapter The lazy hook decoder supersedes it: it decodes into the caller's buffer instead of holding a full owned RGBA copy, and it exposes EXIF metadata/orientation, which the eager adapter structurally could not (it retained only decoded pixels). Keeping both meant two ImageDecoder impls whose orientation reporting disagreed for the same file. Direct callers keep the decode_*_to_rgba functions; image-crate callers use the registered hooks. --- API.md | 10 -- src/image_integration.rs | 208 +-------------------------------------- src/lib.rs | 13 --- 3 files changed, 2 insertions(+), 229 deletions(-) diff --git a/API.md b/API.md index 51f8c3d..e4fbe13 100644 --- a/API.md +++ b/API.md @@ -297,16 +297,6 @@ 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. -### 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]` - ## Conversion Helpers (image module) Under `image-integration`, `DecodedRgbaImage` provides conversion helpers: diff --git a/src/image_integration.rs b/src/image_integration.rs index 258b12e..f43db35 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -9,12 +9,9 @@ use crate::{ DecodeError, DecodeGuardrails, DecodedRgbaImage, DecodedRgbaLayout, DecodedRgbaPixels, - HeifInputFamily, decode_bufread_to_rgba_with_guardrails, - decode_bytes_to_rgba_layout_with_hint_and_guardrails, decode_bytes_to_rgba_with_guardrails, + 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, - decode_path_to_rgba_with_guardrails, decode_read_to_rgba_with_guardrails, - decode_seekable_to_rgba_with_hint_and_guardrails, }; use image::error::{ DecodingError, ImageFormatHint, ParameterError, ParameterErrorKind, UnsupportedError, @@ -26,8 +23,7 @@ use image::{ColorType, DynamicImage, ImageBuffer, ImageDecoder, ImageError, Imag use std::error::Error; use std::ffi::OsString; use std::fmt::{Display, Formatter}; -use std::io::{BufRead, Read, Seek, SeekFrom}; -use std::path::Path; +use std::io::{Read, Seek, SeekFrom}; use std::sync::Once; const HOOK_EXTENSION_HEIC: &str = "heic"; @@ -75,166 +71,6 @@ 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, -} - -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 - } - - fn from_seekable_with_hint_and_guardrails( - input_reader: R, - 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) - } - - fn storage_color_type(&self) -> ColorType { - storage_color_type_from_bit_depth(self.decoded.storage_bit_depth()) - } - - fn expected_total_bytes(&self) -> ImageResult { - expected_rgba_total_bytes( - self.decoded.width, - self.decoded.height, - self.decoded.storage_bit_depth(), - ) - } -} - -impl ImageDecoder for HeifImageDecoder { - fn dimensions(&self) -> (u32, u32) { - (self.decoded.width, self.decoded.height) - } - - fn color_type(&self) -> ColorType { - self.storage_color_type() - } - - fn icc_profile(&mut self) -> ImageResult>> { - Ok(self.decoded.icc_profile.clone()) - } - - fn read_image(self, buf: &mut [u8]) -> ImageResult<()> - where - Self: Sized, - { - let expected_total_bytes = self.expected_total_bytes()?; - if buf.len() != expected_total_bytes { - return Err(ImageError::Parameter(ParameterError::from_kind( - ParameterErrorKind::DimensionMismatch, - ))); - } - - match self.decoded.pixels { - DecodedRgbaPixels::U8(pixels) => { - buf.copy_from_slice(&pixels); - } - DecodedRgbaPixels::U16(pixels) => { - write_rgba16_native_endian_bytes(&pixels, buf); - } - } - - Ok(()) - } - - fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { - (*self).read_image(buf) - } -} - struct LazyHeifImageDecoder { input: Vec, hint: Option, @@ -770,46 +606,6 @@ fn expected_rgba_total_bytes(width: u32, height: u32, storage_bit_depth: u8) -> }) } -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(()) -} - -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, diff --git a/src/lib.rs b/src/lib.rs index 2170c7f..a97d226 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,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}; @@ -9263,17 +9261,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], From 8a738b3f860e3d14801bf21b02e3746c7f5e658f Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 16:59:45 +0530 Subject: [PATCH 29/38] Reuse the layout probe's payload extraction in read_image For grid primaries (and hev1 items with in-stream parameter sets) the lazy hook's layout probe fell through to extract_primary_heic_item_data_with_grid, copying every tile payload out of the container only to read the grid descriptor and first-tile SPS, and read_image then ran the identical extraction again. The probe now returns the extraction it performed inside an RgbaLayoutProbe; the decoder stores it and read_image consumes it, so all coded bytes are copied out of the container at most once per hook decode. --- src/image_integration.rs | 15 ++++++-- src/lib.rs | 83 +++++++++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/image_integration.rs b/src/image_integration.rs index f43db35..8cf1abd 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -76,6 +76,10 @@ struct LazyHeifImageDecoder { 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, } // Manual impl: a derived Debug would dump the entire encoded input. @@ -96,13 +100,14 @@ impl LazyHeifImageDecoder { input: Vec, hint: Option, guardrails: DecodeGuardrails, - layout: DecodedRgbaLayout, + probe: crate::RgbaLayoutProbe, ) -> Self { Self { input, hint, guardrails, - layout, + layout: probe.layout, + preextracted_heic: probe.preextracted_heic, } } @@ -171,6 +176,7 @@ impl ImageDecoder for LazyHeifImageDecoder { &self.input, self.hint, self.guardrails, + self.preextracted_heic, buf, ) .map_err(decode_error_to_image_error), @@ -178,6 +184,7 @@ impl ImageDecoder for LazyHeifImageDecoder { &self.input, self.hint, self.guardrails, + self.preextracted_heic, buf, ) .map_err(decode_error_to_image_error), @@ -204,11 +211,11 @@ fn decoder_from_seekable_with_hint_and_guardrails( guardrails: DecodeGuardrails, ) -> ImageResult> { let input = read_seekable_input_to_vec(input_reader, &guardrails)?; - let layout = + 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, layout, + input, hint, guardrails, probe, ))) } diff --git a/src/lib.rs b/src/lib.rs index a97d226..4b2e7f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7652,11 +7652,31 @@ fn decoded_rgba_layout_from_heic_geometry( }) } +/// 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, +} + +#[cfg(feature = "image-integration")] +impl RgbaLayoutProbe { + fn without_extraction(layout: DecodedRgbaLayout) -> Self { + Self { + layout, + preextracted_heic: None, + } + } +} + #[cfg(feature = "image-integration")] fn decode_heif_bytes_to_rgba_layout( input: &[u8], guardrails: DecodeGuardrails, -) -> Result { +) -> Result { match isobmff::parse_primary_uncompressed_item_properties(input) { Ok(properties) => { guardrails.enforce_pixel_count(properties.ispe.width, properties.ispe.height)?; @@ -7670,7 +7690,8 @@ fn decode_heif_bytes_to_rgba_layout( source_bit_depth, &transforms, icc_profile_from_color_properties(&properties.colr), - ); + ) + .map(RgbaLayoutProbe::without_extraction); } Err(isobmff::ParsePrimaryUncompressedPropertiesError::UnexpectedPrimaryItemType { .. @@ -7706,30 +7727,31 @@ fn decode_heif_bytes_to_rgba_layout( source_bit_depth, &transforms, icc_profile, - ); + ) + .map(RgbaLayoutProbe::without_extraction); } let primary_with_grid = isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)?; - match primary_with_grid { + 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)?; + 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) => { let (_, metadata, _, _) = - decode_primary_heic_stream_and_metadata_from_coded_item_data(input, &item_data)?; + decode_primary_heic_stream_and_metadata_from_coded_item_data(input, item_data)?; 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( @@ -7738,9 +7760,13 @@ fn decode_heif_bytes_to_rgba_layout( source_bit_depth, &transforms, icc_profile, - ) + )? } - } + }; + Ok(RgbaLayoutProbe { + layout, + preextracted_heic: Some(primary_with_grid), + }) } #[cfg(feature = "image-integration")] @@ -8659,6 +8685,7 @@ fn try_decode_uncompressed_heif_to_rgba_output>( fn decode_heif_bytes_to_rgba8_slice( input: &[u8], guardrails: DecodeGuardrails, + preextracted: Option, out: &mut [u8], ) -> Result<(), DecodeError> { let mut output = SliceRgbaOutput(out); @@ -8674,6 +8701,7 @@ fn decode_heif_bytes_to_rgba8_slice( decode_heif_bytes_to_rgba_slice( input, guardrails, + preextracted, out, decode_primary_heic_grid_to_rgba8_slice, decoded_heic_to_rgba8_slice, @@ -8684,6 +8712,7 @@ fn decode_heif_bytes_to_rgba8_slice( 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); @@ -8702,6 +8731,7 @@ fn decode_heif_bytes_to_rgba16_native_endian_bytes( 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, @@ -8728,6 +8758,7 @@ type HeicCodedSliceDecode = fn( fn decode_heif_bytes_to_rgba_slice( input: &[u8], guardrails: DecodeGuardrails, + preextracted: Option, out: &mut [T], decode_grid_slice: HeicGridSliceDecode, decode_coded_slice: HeicCodedSliceDecode, @@ -8736,8 +8767,14 @@ fn decode_heif_bytes_to_rgba_slice( .map_err(DecodeHeicError::ParsePrimaryTransforms)? .transforms; let mut source: Option<&mut dyn RandomAccessSource> = None; - let primary_with_grid = - isobmff::extract_primary_heic_item_data_with_grid(input).map_err(DecodeHeicError::from)?; + // 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) => { @@ -8786,10 +8823,11 @@ fn decode_bytes_to_rgba_layout_with_hint_and_guardrails( input: &[u8], hint: Option, guardrails: DecodeGuardrails, -) -> Result { +) -> 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), + HeifInputFamily::Avif => decode_avif_bytes_to_rgba_layout(input, guardrails) + .map(RgbaLayoutProbe::without_extraction), } } @@ -8798,10 +8836,13 @@ fn decode_bytes_to_rgba8_slice_with_hint_and_guardrails( input: &[u8], hint: Option, guardrails: DecodeGuardrails, + 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, out), + HeifInputFamily::Heif => { + decode_heif_bytes_to_rgba8_slice(input, guardrails, preextracted_heic, out) + } HeifInputFamily::Avif => decode_avif_bytes_to_rgba8_slice(input, guardrails, out), } } @@ -8811,6 +8852,7 @@ fn decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails( input: &[u8], hint: Option, guardrails: DecodeGuardrails, + preextracted_heic: Option, out: &mut [u8], ) -> Result<(), DecodeError> { if !out.len().is_multiple_of(std::mem::size_of::()) { @@ -8819,9 +8861,12 @@ fn decode_bytes_to_rgba16_native_endian_bytes_with_hint_and_guardrails( )); } match enforce_and_resolve_input_family(input, hint, &guardrails)? { - HeifInputFamily::Heif => { - decode_heif_bytes_to_rgba16_native_endian_bytes(input, guardrails, out) - } + 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) } @@ -13281,7 +13326,8 @@ mod tests { None, super::DecodeGuardrails::default(), ) - .expect("layout probe must accept uncompressed HEIF"); + .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); @@ -13297,6 +13343,7 @@ mod tests { &file, None, super::DecodeGuardrails::default(), + None, &mut rgba8, ) .expect("RGBA8 slice decode must support uncompressed HEIF"); From ebca8be62740bbe6e0ee75edf56abdcdb0ac7463 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 17:01:40 +0530 Subject: [PATCH 30/38] Skip grid canvas seeding when tiles cover the descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller-buffer grid decode cleared the whole output and, with an auxiliary alpha plane, seeded alpha for every source pixel — work the tile paste then rewrote sample-for-sample whenever the tiles cover the grid descriptor (the common case). Both passes now run only in the gap-pixel branch; the alpha-plane validation stays unconditional since the paste indexes the plane directly. The now-unused RgbaSampleOutput::fill default method is removed. --- src/lib.rs | 119 +++++++++++++++++++++++++---------------------------- 1 file changed, 55 insertions(+), 64 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4b2e7f0..3f79878 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7804,12 +7804,6 @@ fn decode_avif_bytes_to_rgba_layout( trait RgbaSampleOutput { fn sample_len(&self) -> usize; fn write_sample(&mut self, index: usize, sample: T); - - fn fill(&mut self, sample: T) { - for index in 0..self.sample_len() { - self.write_sample(index, sample); - } - } } #[cfg(feature = "image-integration")] @@ -7824,10 +7818,6 @@ impl RgbaSampleOutput for SliceRgbaOutput<'_, T> { fn write_sample(&mut self, index: usize, sample: T) { self.0[index] = sample; } - - fn fill(&mut self, sample: T) { - self.0.fill(sample); - } } #[cfg(feature = "image-integration")] @@ -7843,13 +7833,6 @@ impl RgbaSampleOutput for NativeEndianRgba16Output<'_> { let byte_index = index * std::mem::size_of::(); self.0[byte_index..byte_index + 2].copy_from_slice(&sample.to_ne_bytes()); } - - fn fill(&mut self, sample: u16) { - let bytes = sample.to_ne_bytes(); - for chunk in self.0.chunks_exact_mut(2) { - chunk.copy_from_slice(&bytes); - } - } } #[cfg(feature = "image-integration")] @@ -7936,13 +7919,23 @@ fn decode_primary_heic_grid_to_rgba_output Date: Fri, 10 Jul 2026 17:07:12 +0530 Subject: [PATCH 31/38] Share uncC component resolution between decode and layout probe uncompressed_output_bit_depth_from_properties re-implemented the decoder's cmpd/uncC component-mapping loop with slightly different error variants, and skipped its format/depth split messages and multi-Y bit-depth rule. Extract the decoder's spec construction into uncompressed_component_layout_from_properties and its channel folding into resolve_uncompressed_channel_map, and build the probe from those two helpers plus select_uncompressed_output_bit_depth, so probe and decode resolve bit depth from one code path. --- src/lib.rs | 217 ++++++++++++++++++++++++----------------------------- 1 file changed, 99 insertions(+), 118 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3f79878..6dd0640 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1856,18 +1856,15 @@ impl DecodedUncompressedChannels { } } -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)?; - +/// 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(); @@ -2007,6 +2004,87 @@ fn decode_primary_uncompressed_to_channels_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), @@ -2123,43 +2201,11 @@ fn decode_primary_uncompressed_to_channels_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] @@ -3447,75 +3493,10 @@ fn select_uncompressed_output_bit_depth( fn uncompressed_output_bit_depth_from_properties( properties: &isobmff::UncompressedPrimaryItemProperties, ) -> Result { - if properties.unc_c.full_box.version == 1 { - return match properties.unc_c.profile.as_bytes() { - bytes if bytes == *b"rgb3" || bytes == *b"rgba" || bytes == *b"abgr" => Ok(8), - _ => Err(DecodeUncompressedError::UnsupportedFeature { - detail: format!( - "unsupported uncC v1 profile {} for baseline uncompressed decode", - properties.unc_c.profile - ), - }), - }; - } - - let cmpd = properties - .cmpd - .as_ref() - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: format!( - "primary item_ID {} is missing required cmpd mapping for uncC version {}", - properties.item_id, properties.unc_c.full_box.version - ), - })?; - let mut has_channel = [false; UNCOMPRESSED_CHANNEL_COUNT]; - let mut channel_bit_depths = [0_u8; UNCOMPRESSED_CHANNEL_COUNT]; - for component in &properties.unc_c.components { - let component_def = cmpd - .components - .get(usize::from(component.component_index)) - .ok_or_else(|| DecodeUncompressedError::InvalidInput { - detail: format!( - "uncC component index {} exceeds cmpd component count {}", - component.component_index, - cmpd.components.len() - ), - })?; - if component.component_format != UNCOMPRESSED_COMPONENT_FORMAT_UNSIGNED - || component.component_bit_depth == 0 - || component.component_bit_depth > 16 - { - return Err(DecodeUncompressedError::UnsupportedFeature { - detail: format!( - "unsupported uncompressed component format/depth {}/{}", - component.component_format, component.component_bit_depth - ), - }); - } - let role = uncompressed_role_from_component_type(component_def.component_type)?; - let Some(channel_index) = role.channel_index() else { - continue; - }; - let bit_depth = component.component_bit_depth as u8; - if has_channel[channel_index] { - let duplicate_multi_y = properties.unc_c.interleave_type - == UNCOMPRESSED_INTERLEAVE_MULTI_Y - && channel_index == UNCOMPRESSED_CHANNEL_LUMA - && channel_bit_depths[channel_index] == bit_depth; - if duplicate_multi_y { - 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] = bit_depth; - } - select_uncompressed_output_bit_depth(&has_channel, &channel_bit_depths) + 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 { From 15cf8e657e7d4197d287a4b9a6dfbcef25475382 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 17:11:08 +0530 Subject: [PATCH 32/38] Share the per-tile grid driver across owned and slice paste paths The decode/validate/colr-override/convert/origin prologue was copy-pasted between paste_heic_grid_tiles_to_rgba and paste_heic_grid_tiles_to_transformed_rgba_slice, and paste_decoded_heic_grid_tile re-inlined the consistency checks and origin math that validate_decoded_heic_grid_tile_reference and heic_grid_tile_origin already implement. Extract for_each_heic_grid_tile_rgba as the single per-tile driver (both paste paths keep only their paste bodies) and have the plane-canvas path call the shared validators through a from_output_canvas reference. --- src/lib.rs | 315 ++++++++++++++++++++++++----------------------------- 1 file changed, 144 insertions(+), 171 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6dd0640..53fe277 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4535,6 +4535,23 @@ impl HeicGridTileReference { .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, + } + } } /// True when the uniform tile lattice covers every descriptor pixel, so the @@ -4628,16 +4645,19 @@ fn validate_decoded_heic_grid_tile_reference( Ok(()) } -fn paste_heic_grid_tiles_to_rgba( +/// 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, - output: &mut [T], reference: &HeicGridTileReference, - orientation_transform: Option<&RgbaOrientationTransform>, convert_tile: fn(&DecodedHeicImage, &mut Vec) -> Result<(), DecodeHeicError>, + mut paste: impl FnMut(&DecodedHeicImage, &[T], u32, u32) -> Result<(), DecodeError>, ) -> Result<(), DecodeError> { - let descriptor = &grid_data.descriptor; - let columns = usize::from(descriptor.columns); + 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() { @@ -4659,35 +4679,56 @@ fn paste_heic_grid_tiles_to_rgba( 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)?; - 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", - )?; - } + paste(&tile, &tile_pixels, x_origin, y_origin)?; } Ok(()) } +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, @@ -5614,65 +5655,11 @@ fn paste_decoded_heic_grid_tile( column: usize, tile_index: usize, ) -> Result<(), DecodeHeicError> { - 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}"), - }); - } + 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 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 { - detail: format!("grid tile y-origin overflow for row {row} with tile height {tile_height}"), - })?; + let (x_origin, y_origin) = heic_grid_tile_origin(tile_width, tile_height, row, column)?; paste_heic_plane_with_clip( &tile.y_plane, @@ -7993,7 +7980,6 @@ fn paste_heic_grid_tiles_to_transformed_rgba_slice T, ) -> Result<(), DecodeError> { let descriptor = &grid_data.descriptor; - let columns = usize::from(descriptor.columns); let destination_width = usize::try_from(transform_plan.destination_width).map_err(|_| { DecodeHeicError::InvalidDecodedFrame { detail: format!( @@ -8018,99 +8004,86 @@ fn paste_heic_grid_tiles_to_transformed_rgba_slice= source_height { - break; - } - for tile_x in 0..tile_width { - let source_x = x_origin.checked_add(tile_x).ok_or_else(|| { + 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 x-coordinate overflow".to_string(), + detail: "grid tile source y-coordinate overflow".to_string(), } })?; - if source_x >= source_width { + if source_y >= source_height { 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) - }), - ); + 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(()) + Ok(()) + }, + ) } #[cfg(feature = "image-integration")] From 11ffadcb9360c260a9b3f4eca6926e56a63acc2f Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 17:13:22 +0530 Subject: [PATCH 33/38] Compute the YUV clean-aperture crop from the shared bounds helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crop_heic_by_clean_aperture kept an inline copy of the clap rounding/clamp/empty-check math that clean_aperture_crop_bounds implements for the RGBA transform path — and the chroma-phase guard that runs immediately before every crop already calls the shared helper, so the bounds were computed twice per crop from two copies of the same libheif-mirroring semantics. Delegate to the helper and map its error into the tile-specific DecodeHeicError. --- src/lib.rs | 64 ++++++++++++++++-------------------------------------- 1 file changed, 19 insertions(+), 45 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 53fe277..c797e00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5811,55 +5811,29 @@ fn crop_heic_by_clean_aperture( decoded: DecodedHeicImage, clean_aperture: isobmff::ImageCleanApertureProperty, ) -> Result { - if decoded.width == 0 || decoded.height == 0 { - return Err(DecodeHeicError::InvalidDecodedFrame { - detail: format!( - "grid tile clean-aperture input geometry must be non-zero, got {}x{}", - decoded.width, decoded.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 { - return Err(DecodeHeicError::InvalidDecodedFrame { + // 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 empty after clamp (left={left}, right={right}, top={top}, bottom={bottom}, tile={}x{})", + "grid tile clean-aperture crop is invalid for {}x{}: {source}", decoded.width, decoded.height ), - }); - } - - 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_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(top).map_err(|_| DecodeHeicError::InvalidDecodedFrame { - detail: format!("grid tile clean-aperture top bound is out of range: {top}"), + 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 From 629ff24425b9dff27d758b45b68d80d58d09c791 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 17:14:12 +0530 Subject: [PATCH 34/38] Gate the EXIF/irot test builders behind image-integration Their only consumers are the feature-gated lazy-adapter tests, so default-feature --all-targets builds flagged them as dead code. --- src/isobmff.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/isobmff.rs b/src/isobmff.rs index 4cec754..b113a41 100644 --- a/src/isobmff.rs +++ b/src/isobmff.rs @@ -7292,12 +7292,14 @@ pub(crate) mod test_support { /// /// 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]) } @@ -7308,6 +7310,7 @@ pub(crate) mod test_support { /// /// 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], From 191a8c1a53fb10f206bdde45c87b4a486c818f07 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 17:15:43 +0530 Subject: [PATCH 35/38] Probe hev1 SPS metadata in place instead of assembling a stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout probe's coded-item fallback (hvcC without an SPS, i.e. hev1 in-stream parameter sets) called decode_primary_heic_stream_and_metadata_from_coded_item_data and discarded the assembled stream — a full length-prefix-normalized copy of the coded payload built just to parse one SPS. Use decode_hevc_metadata_from_hvcc_or_payload (already used by the grid probe arm) with the same preflight id cross-check and ispe geometry validation, now shared via parse_and_validate_heic_coded_item_preflight. --- src/lib.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index c797e00..7075326 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4006,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 { @@ -4019,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)?; @@ -7692,8 +7703,18 @@ fn decode_heif_bytes_to_rgba_layout( )? } isobmff::HeicPrimaryItemDataWithGrid::Coded(item_data) => { - let (_, metadata, _, _) = - decode_primary_heic_stream_and_metadata_from_coded_item_data(input, 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( From f084cbf02f94f8c3a51332e835645907155912de Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 17:19:15 +0530 Subject: [PATCH 36/38] Merge the AVIF caller-buffer converters into one generic pipeline decoded_avif_to_rgba8_slice and decoded_avif_to_rgba16_output were ~120-line near-copies differing only in source sample type, output sink, and scaling. Collapse them onto decoded_avif_to_rgba_output, generic over the source plane sample type and RgbaSampleOutput sink (the same parameterization the HEIC twin uses), with the bit-depth gates left in two thin wrappers. The mono-verbatim fast path keeps its bit_depth == 8 condition, which is constant-false in the 16-bit instantiation. ChromaPlanesU8/U16 and prepare_chroma_u8/u16 fold into a generic ChromaPlanes + prepare_chroma with aliases and wrappers so the owned conversion paths stay unchanged. --- src/lib.rs | 263 +++++++++++++++++------------------------------------ 1 file changed, 82 insertions(+), 181 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7075326..776764f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8300,112 +8300,17 @@ fn decoded_avif_to_rgba8_slice( "AVIF storage is RGBA16, not RGBA8".to_string(), )); } - 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.len() != expected { - return Err(DecodeError::TransformGuard( - TransformGuardError::RgbaSampleCountMismatch { - stage: "AVIF direct transformed RGBA8 image adapter output", - actual: out.len(), - expected, - width: transform_plan.destination_width, - height: transform_plan.destination_height, - }, - )); - } - - 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_u8(&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(), - } - .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_u8(decoded)?; - 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, ChromaPlanesU8::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::from(y_samples[source_index]); - let (cb_sample, cr_sample) = match &chroma { - ChromaPlanesU8::Monochrome => (chroma_midpoint, chroma_midpoint), - ChromaPlanesU8::Color { - u_samples, - v_samples, - chroma_width, - layout, - } => { - let chroma_index = - 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 destination_index = (destination_y * destination_width + destination_x) * 4; - out[destination_index] = scale_sample_to_u8(red, decoded.bit_depth); - out[destination_index + 1] = scale_sample_to_u8(green, decoded.bit_depth); - out[destination_index + 2] = scale_sample_to_u8(blue, decoded.bit_depth); - out[destination_index + 3] = alpha - .as_ref() - .map(|plane| avif_auxiliary_alpha_sample_to_u8(plane, source_index)) - .unwrap_or(u8::MAX); - } - } - - Ok(()) + 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, + ) } #[cfg(feature = "image-integration")] @@ -8419,6 +8324,39 @@ fn decoded_avif_to_rgba16_output>( "AVIF storage is RGBA8, not RGBA16".to_string(), )); } + 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, + ) +} + +/// 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( @@ -8428,7 +8366,7 @@ fn decoded_avif_to_rgba16_output>( if out.sample_len() != expected { return Err(DecodeError::TransformGuard( TransformGuardError::RgbaSampleCountMismatch { - stage: "AVIF direct transformed RGBA16 image adapter output", + stage: sample_count_stage, actual: out.sample_len(), expected, width: transform_plan.destination_width, @@ -8444,7 +8382,7 @@ fn decoded_avif_to_rgba16_output>( } })?; validate_plane_dimensions(&decoded.y_plane, decoded.width, decoded.height, "Y")?; - let y_samples = plane_samples_u16(&decoded.y_plane, "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 { @@ -8474,7 +8412,7 @@ fn decoded_avif_to_rgba16_output>( height: transform_plan.destination_height, } })?; - let chroma = prepare_chroma_u16(decoded)?; + 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( @@ -8483,16 +8421,17 @@ fn decoded_avif_to_rgba16_output>( 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::from(y_samples[source_index]); + let y_sample: i32 = y_samples[source_index].into(); let (cb_sample, cr_sample) = match &chroma { - ChromaPlanesU16::Monochrome => (chroma_midpoint, chroma_midpoint), - ChromaPlanesU16::Color { + ChromaPlanes::Monochrome => (chroma_midpoint, chroma_midpoint), + ChromaPlanes::Color { u_samples, v_samples, chroma_width, @@ -8501,31 +8440,30 @@ fn decoded_avif_to_rgba16_output>( let chroma_index = chroma_sample_index(source_x, source_y, *chroma_width, *layout); ( - i32::from(u_samples[chroma_index]), - i32::from(v_samples[chroma_index]), + u_samples[chroma_index].into(), + v_samples[chroma_index].into(), ) } }; - let (red, green, blue) = converter.convert(y_sample, cb_sample, cr_sample); + 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_to_u16(red, decoded.bit_depth), - ); + out.write_sample(destination_index, scale_sample(red, decoded.bit_depth)); out.write_sample( destination_index + 1, - scale_sample_to_u16(green, decoded.bit_depth), - ); - out.write_sample( - destination_index + 2, - scale_sample_to_u16(blue, decoded.bit_depth), + 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| avif_auxiliary_alpha_sample_to_u16(plane, source_index)) - .unwrap_or(u16::MAX), + .map(|plane| alpha_sample(plane, source_index)) + .unwrap_or(opaque_alpha), ); } } @@ -11548,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 { @@ -11601,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, @@ -11609,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( From 25a5930afdbb4188908e9baced4a3e044587b588 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 18:09:52 +0530 Subject: [PATCH 37/38] Bound hook input buffering by guardrail --- src/image_integration.rs | 85 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/src/image_integration.rs b/src/image_integration.rs index 8cf1abd..ca40e5f 100644 --- a/src/image_integration.rs +++ b/src/image_integration.rs @@ -265,9 +265,22 @@ fn read_seekable_input_to_vec( )) })?; let mut input = Vec::with_capacity(capacity); - input_reader - .read_to_end(&mut input) - .map_err(ImageError::IoError)?; + 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. @@ -650,9 +663,11 @@ fn decode_error_to_image_error(err: DecodeError) -> ImageError { mod tests { use super::{ ColorType, DecodeGuardrails, HeifInputFamily, ImageDecoder, - decoder_from_seekable_with_hint_and_guardrails, + 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 @@ -701,6 +716,68 @@ mod tests { } } + 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`. From 0f3b729fec3a9df6a18c7ebf83f78bec515f7935 Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Fri, 10 Jul 2026 18:11:14 +0530 Subject: [PATCH 38/38] Install libpng for CI validator --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af0c403..1e8eb63 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -66,7 +66,7 @@ jobs: 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: |