Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
0aba843
Reduce HEIC decode peak RSS
laurenspriem Jul 9, 2026
7dc3604
Avoid copying no-op clean aperture transforms
laurenspriem Jul 9, 2026
49fd799
Apply HEIC clean aperture before RGBA conversion
laurenspriem Jul 9, 2026
7fa78cb
Reuse grid tile RGBA scratch buffer
laurenspriem Jul 9, 2026
9eef4d7
Write oriented HEIC grids directly to RGBA
laurenspriem Jul 9, 2026
8e21d51
Decode image hooks into caller buffers
laurenspriem Jul 9, 2026
dbf4a0f
Fix image hook grid buffer initialization
laurenspriem Jul 9, 2026
942b17f
Keep chroma-unaligned clean apertures out of YUV cropping
laurenspriem Jul 9, 2026
b7a644a
Reject unaligned grid tile origins and dedupe HEIC decode paths
laurenspriem Jul 9, 2026
7332af8
Document image hook input buffering trade-off
laurenspriem Jul 10, 2026
505f5fd
Lock the lazy image adapter's memory and coverage contract
laurenspriem Jul 10, 2026
53dc23a
Deduplicate lazy RGBA decode paths and grid decode plumbing
laurenspriem Jul 10, 2026
ef948d6
Pin the Rust toolchain in CI
laurenspriem Jul 10, 2026
c6552d4
Mark the 709-family sRGB transfer aliasing as a deliberate divergence
laurenspriem Jul 10, 2026
b3552a9
Document that the per-file image hook check is intentional
laurenspriem Jul 10, 2026
e53f619
Feature-gate heic_storage_bit_depth to its image-integration users
laurenspriem Jul 10, 2026
e4b260a
Decode image hooks directly into caller buffers
laurenspriem Jul 10, 2026
359609c
Fill uncovered grid pixels with the converted zero-YUV color
laurenspriem Jul 10, 2026
009c3ef
Cap the hook input pre-allocation at 64 MiB
laurenspriem Jul 10, 2026
fed350a
Derive the AVIF layout probe's bit depth from the sequence header
laurenspriem Jul 10, 2026
7fce2d7
Expose primary-item EXIF through the image hook decoder
laurenspriem Jul 10, 2026
67e3fe9
Add an identity fast path to the RGBA transform-plan mapping
laurenspriem Jul 10, 2026
5b051ef
Route the 16-bit HEIF hook decode through the shared dispatch
laurenspriem Jul 10, 2026
b0fb2bf
Raise the hook input pre-allocation cap to 128 MiB
laurenspriem Jul 10, 2026
251d57a
Read probe SPS metadata from hvcC without assembling the stream
laurenspriem Jul 10, 2026
0ae17c5
Suppress hook EXIF orientation when container transforms bake rotation
laurenspriem Jul 10, 2026
74d68c6
Cap the default hook registration's encoded input buffer at 128 MiB
laurenspriem Jul 10, 2026
9bc10f4
Remove the eager HeifImageDecoder adapter
laurenspriem Jul 10, 2026
8a738b3
Reuse the layout probe's payload extraction in read_image
laurenspriem Jul 10, 2026
ebca8be
Skip grid canvas seeding when tiles cover the descriptor
laurenspriem Jul 10, 2026
e6ddc32
Share uncC component resolution between decode and layout probe
laurenspriem Jul 10, 2026
15cf8e6
Share the per-tile grid driver across owned and slice paste paths
laurenspriem Jul 10, 2026
11ffadc
Compute the YUV clean-aperture crop from the shared bounds helper
laurenspriem Jul 10, 2026
629ff24
Gate the EXIF/irot test builders behind image-integration
laurenspriem Jul 10, 2026
191a8c1
Probe hev1 SPS metadata in place instead of assembling a stream
laurenspriem Jul 10, 2026
f084cbf
Merge the AVIF caller-buffer converters into one generic pipeline
laurenspriem Jul 10, 2026
25a5930
Bound hook input buffering by guardrail
laurenspriem Jul 10, 2026
0f3b729
Install libpng for CI validator
laurenspriem Jul 10, 2026
1d4fff4
Fix CI validator PNG generation
laurenspriem Jul 13, 2026
9b14b11
Fold the hook input pre-allocation ceiling into the default cap
laurenspriem Jul 13, 2026
2ed9855
Share an identity-rotation helper and document the grid coverage guard
laurenspriem Jul 13, 2026
a81dded
Lock EXIF orientation exposure for identity irot transforms
laurenspriem Jul 13, 2026
3e38f5a
Bump version to 0.2.0 for the removed public adapter
laurenspriem Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -38,20 +49,49 @@ jobs:
# the expected-failure allowlists match against all come from this
# checkout; bump the pin deliberately.
LIBHEIF_PIN: 78638f4fe417e49f2b129f42ae691a4954560bbd
# Ubuntu 24.04's libpng 1.6.43 aborts writes for legacy ICC
# profiles in two pinned HEIC fixtures. Newer libpng reports those
# profile issues without leaving heif-dec's oracle PNG empty.
LIBPNG_PIN: 3061454d980de7d53608f594194cfac722721d2a
# HEIC fixture corpus (media/heic/v1/files); bump to pick up
# fixtures added upstream.
ENTE_FIXTURES_PIN: 13c7cf83717140be20b32e6fbb178d5f15ea09ea
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Pin Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake pkg-config ffmpeg \
libde265-dev libx265-dev libaom-dev libdav1d-dev \
libopenjp2-7-dev libjpeg-dev libbrotli-dev zlib1g-dev

- name: Build pinned libpng for validator output
run: |
git init --quiet .heic-test-assets/libpng
git -C .heic-test-assets/libpng remote add origin https://github.com/pnggroup/libpng.git
git -C .heic-test-assets/libpng fetch --quiet --depth 1 origin "$LIBPNG_PIN"
git -C .heic-test-assets/libpng checkout --quiet FETCH_HEAD

prefix="$PWD/.heic-test-assets/libpng-install"
cmake -S .heic-test-assets/libpng -B .heic-test-assets/libpng-build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DPNG_SHARED=OFF \
-DPNG_STATIC=ON \
-DPNG_TESTS=OFF \
-DPNG_TOOLS=OFF
cmake --build .heic-test-assets/libpng-build --parallel
cmake --install .heic-test-assets/libpng-build

echo "LIBHEIF_CMAKE_ARGS=-DPNG_PNG_INCLUDE_DIR=$prefix/include -DPNG_LIBRARY=$prefix/lib/libpng16.a" >> "$GITHUB_ENV"

- name: Fetch pinned libheif (validator source and corpus)
run: |
git init --quiet .heic-test-assets/libheif
Expand Down
26 changes: 17 additions & 9 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,14 @@ fn orientation_to_apply(path: &Path) -> Result<Option<u8>, heic_decoder::DecodeE

Requires `image-integration` feature.

Hook decodes buffer the entire encoded input in memory before decoding; in
exchange, pixels decode straight into the buffer `image` hands over, without
an additional full-frame owned RGBA allocation. Codec-native planes and a
single grid-tile scratch buffer may still be allocated.
`register_image_decoder_hooks()` caps the encoded input buffer at
`DEFAULT_HOOK_MAX_INPUT_BYTES` (128 MiB); register with explicit guardrails
to choose a different `max_input_bytes` bound (or `None` for unbounded).

### 1) Register hooks once at startup

```rust
Expand Down Expand Up @@ -279,15 +287,15 @@ let img = if let Some(orientation) = hint.orientation_to_apply() {
};
```

### 3) Direct adapter usage (optional)

`HeifImageDecoder` constructors:

- `from_bytes[_with_guardrails]`
- `from_read[_with_guardrails]`
- `from_bufread[_with_guardrails]`
- `from_seekable[_with_guardrails]`
- `from_path[_with_guardrails]`
The registered hook decoder also exposes the primary item's EXIF block through
the standard `ImageDecoder::exif_metadata`/`ImageDecoder::orientation` methods,
so generic `image` code (`decoder.orientation()` +
`DynamicImage::apply_orientation`) works without crate-specific helpers. EXIF
orientation stays unapplied in the pixels; applying it remains the caller's
choice. When the primary item carries `irot`/`imir` container transforms —
which decode does bake into the pixels — `orientation()` reports
`NoTransforms` (matching `ExifOrientationHint::should_apply_exif_orientation`)
so callers do not rotate twice; `exif_metadata` still returns the full block.

## Conversion Helpers (image module)

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "heic_decoder"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
description = "Pure Rust HEIF/HEIC/AVIF decoder with image crate integration hooks"
readme = "README.md"
Expand Down
10 changes: 7 additions & 3 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ into this repository.
CI runs this on every pull request (`.github/workflows/tests.yml`): a `lint`
job (`cargo fmt --check`, clippy, `cargo test`) and a `verify` job that
performs the full default-corpus correctness pass, including stress-corpus
generation. The workflow pins the libheif and ente test-fixtures commits it
fetches; bump those pins in the workflow to move the CI validator or corpus
forward deliberately.
generation. The workflow pins the Rust toolchain plus the libpng, libheif, and
ente test-fixtures commits it fetches; bump those pins in the workflow to move
the CI toolchain, validator, or corpus forward deliberately.

- pixel-for-pixel PNG comparison against an external `heif-dec` validator
- pixel-for-pixel comparison of the `image` crate integration hook output
(`ImageReader`/`DynamicImage::from_decoder`) against the direct Rust decode
for every comparable verifier file, including exact ICC-profile equality
through the hook decoder's `ImageDecoder::icc_profile`
- embedded ICC colour-profile comparison against the validator's PNG output:
when `heif-dec` embeds a profile, the Rust PNG must carry byte-identical
profile data; a Rust-only profile is allowed (the Rust decoder synthesizes
Expand Down
145 changes: 135 additions & 10 deletions scripts/heic_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,74 @@ fn main() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
if args.len() != 2 {
return Err("Usage: heif-image-hook-check <input.heic|.heif|.avif>".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'
Expand Down Expand Up @@ -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/ $//'
}
Expand Down Expand Up @@ -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=$?
Expand All @@ -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
Expand Down Expand Up @@ -1140,12 +1213,14 @@ 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
local failures=()

local input_file rel_path id ref_png rust_png ref_raw rust_raw ref_actual rust_actual validator_log
local validator_reason rust_reason ref_dim rust_dim
for input_file in "${files[@]}"; do
total=$((total + 1))
rel_path="$(display_path "$input_file")"
Expand All @@ -1160,7 +1235,7 @@ EOF
if "$LIBHEIF_DEC_BIN" --quiet "$input_file" "$ref_png" >/dev/null 2>"$validator_log"; then
:
else
local validator_reason expected_kind
local expected_kind
validator_reason="$(failure_reason_from_log "$validator_log")"
if expected_kind="$(expected_validator_failure_kind "$input_file" "$validator_reason")"; then
local rust_log rust_status rust_category rust_reason
Expand Down Expand Up @@ -1209,6 +1284,21 @@ EOF
echo "FAIL $rel_path (validator output file not found)" >> "$report_file"
continue
fi
if [[ ! -s "$ref_actual" ]]; then
failed=$((failed + 1))
validator_reason="$(failure_reason_from_log "$validator_log")"
failures+=("$rel_path :: validator produced empty output: ${validator_reason:-no stderr}")
echo "FAIL $rel_path (validator produced empty output: ${validator_reason:-no stderr})" >> "$report_file"
continue
fi
ref_dim="$(image_dim "$ref_actual" 2>>"$validator_log" || true)"
if [[ -z "$ref_dim" ]]; then
failed=$((failed + 1))
validator_reason="$(failure_reason_from_log "$validator_log")"
failures+=("$rel_path :: validator produced unreadable output: ${validator_reason:-no stderr}")
echo "FAIL $rel_path (validator produced unreadable output: ${validator_reason:-no stderr})" >> "$report_file"
continue
fi

case "${input_file##*.}" in
heif|HEIF) comparable_heif=$((comparable_heif + 1)) ;;
Expand Down Expand Up @@ -1247,11 +1337,23 @@ EOF
echo "FAIL $rel_path (rust output file not found)" >> "$report_file"
continue
fi
if [[ ! -s "$rust_actual" ]]; then
failed=$((failed + 1))
rust_reason="$(failure_reason_from_log "$rust_log")"
failures+=("$rel_path :: rust decoder produced empty output: ${rust_reason:-no stderr}")
echo "FAIL $rel_path (rust decoder produced empty output: ${rust_reason:-no stderr})" >> "$report_file"
continue
fi

local ref_dim rust_dim
ref_dim="$(image_dim "$ref_actual" || true)"
rust_dim="$(image_dim "$rust_actual" || true)"
if [[ -z "$ref_dim" || -z "$rust_dim" || "$ref_dim" != "$rust_dim" ]]; then
rust_dim="$(image_dim "$rust_actual" 2>>"$rust_log" || true)"
if [[ -z "$rust_dim" ]]; then
failed=$((failed + 1))
rust_reason="$(failure_reason_from_log "$rust_log")"
failures+=("$rel_path :: rust decoder produced unreadable output: ${rust_reason:-no stderr}")
echo "FAIL $rel_path (rust decoder produced unreadable output: ${rust_reason:-no stderr})" >> "$report_file"
continue
fi
if [[ "$ref_dim" != "$rust_dim" ]]; then
failed=$((failed + 1))
failures+=("$rel_path :: dimension mismatch ref=$ref_dim rust=$rust_dim")
echo "FAIL $rel_path (dimension mismatch ref=$ref_dim rust=$rust_dim)" >> "$report_file"
Expand All @@ -1274,8 +1376,31 @@ EOF
if cmp -s "$ref_raw" "$rust_raw"; then
local icc_failure
if icc_failure="$(compare_png_icc "$ref_actual" "$rust_actual" "$tmp_dir/$id")"; then
passed=$((passed + 1))
echo "PASS $rel_path" >> "$report_file"
# Deliberately run for EVERY passing file even though it re-decodes
# the file twice more (direct + hook): the image-crate hook is a
# separate decode path (lazy adapter, caller-buffer slices) whose
# parity with the direct decode is a hard correctness requirement,
# so it gets the same per-file coverage as the validator comparison.
# Do not sample or gate this to save CI time.
local hook_log hook_status
hook_log="$tmp_dir/$id.image-hook.check.stderr.log"
if check_with_image_hook_helper "$input_file" >/dev/null 2>"$hook_log"; then
hook_status=0
else
hook_status=$?
fi

if [[ "$hook_status" -ne 0 ]]; then
local hook_reason
hook_reason="$(failure_reason_from_log "$hook_log")"
failed=$((failed + 1))
failures+=("$rel_path :: image hook check failed status=$hook_status: ${hook_reason:-no stderr}")
echo "FAIL $rel_path (image hook check failed status=$hook_status)" >> "$report_file"
else
image_hook_passed=$((image_hook_passed + 1))
passed=$((passed + 1))
echo "PASS $rel_path (direct+image-hook)" >> "$report_file"
fi
else
failed=$((failed + 1))
failures+=("$rel_path :: $icc_failure")
Expand All @@ -1296,7 +1421,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
Expand Down
Loading