Open HEIC/HEIF photos (#355) (#372)

* Open HEIC/HEIF photos (#355)

iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no
HEIF code, so .heic was missing from File > Open and failed to decode.

Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust,
forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and
grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha,
ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF
Orientation is rewritten to 1, so photos are never turned twice. Limits
are checked against the declared size before decoding. Read-only (no
pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS.

File > Open (desktop, web) and the batch list accept heic/heif/hif.
Tests compare against Apple's own decode and a 16-bit source, cover
orientation, metadata, limits and every truncation; fixtures from
heic-rs and pillow-heif are attributed in ATTRIBUTION.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HEIF: pin heic-rs exactly and add a decode_heif fuzz target

Review follow-up: heic-rs is young and parses untrusted files, so pin it
(=0.1.1) and fuzz the HEIF path. The target covers both orientation
paths and is meant to run with -timeout so a hang is reported, not
only a panic. It is added to the CI fuzz matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HEIF: a heic-rs panic is a malformed-file error, not a crash

Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on
malformed files:
- in its box parser (boxes.rs:130), reachable from a 72-byte file, even
  through the metadata-only probe;
- during HEVC reconstruction (hevc/decode/recon.rs:70).

The background job path catches panics, but the synchronous ones (jobs
off, the CLI) did not. The HEIF codec now runs the decoder under
catch_unwind and reports a panic as CodecError::Malformed, as
gpu/health.rs already does around wgpu.

The fuzz target also calls heic-rs directly, so its panics still reach
the fuzzer. The 72-byte input is a regression test, which fails
without the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HEIF: move the decoder into an optional photocraft-heif crate

The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends
only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit,
ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded,
Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and
photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the
feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't
included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif
between standalone crates.

The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs
(tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against
xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with
`--features corpus,heif` (`cargo xtask test-corpus` enables both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build CI and official binaries with HEIC support (--features heif)

CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and
web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so
the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with
`--features heif`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: HEIC open is an optional build feature, on in official builds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
ljkx
2026-10-07 10:20:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 7055f0c51b
commit 47f9306fd0
43 changed files with 960 additions and 50 deletions
+12 -1
View File
@@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
"Native" means the data is stored and read back without conversion. Anything else is converted by
the encode plan, and `fidelity_warnings` reports the conversion when it loses information:
@@ -65,7 +66,17 @@ the same.
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there.
* **AVIF (the only asymmetric format).** Encoding uses `ravif`, which is pure Rust. Decoding
* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate
behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose).
Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF
support isn't included in this build"). iPhone and Mac photos decode with `heic-rs` (pure Rust, no `unsafe`):
single pictures and grid-tiled photos, 8 to 12 bits, auxiliary alpha, ICC, EXIF and XMP. The
container's `irot`/`imir`/`clap` are applied and EXIF Orientation is rewritten to 1 (HEIF's EXIF
tag only repeats the container's, and applying both would turn the photo twice). Grayscale
decodes to RGB. Image sequences, overlays and identity derivations, multilayer HEVC and some
4:2:2/4:4:4 streams return `CodecError::Unsupported` or `Malformed`, never wrong pixels. Writing
needs an HEVC encoder and every mature one is C, so HEIF is listed in `ASYMMETRIC_EXCEPTIONS`.
* **AVIF.** Encoding uses `ravif`, which is pure Rust. Decoding
needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated
behind the non-default `avif` feature. In a default build it is neither readable nor writable,
so the symmetric guarantee holds. It is listed in `ASYMMETRIC_EXCEPTIONS`.