mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-25 18:37:10 -06:00
* 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>
195 lines
8.4 KiB
Rust
195 lines
8.4 KiB
Rust
//! HEIF/HEIC decoding against Apple's own encoder and decoder (features `corpus` and `heif`;
|
||
//! run with `cargo xtask test-corpus`). The fixtures are fetched into `corpus/heif/` by
|
||
//! `cargo xtask corpus --heif` (pins: `xtask/src/corpus_pins.rs`); a missing corpus fails.
|
||
//! Most come from heic-rs (`heic-rs/`, MIT OR Apache-2.0): encoded by macOS `sips` from synthetic
|
||
//! PNGs, each `.ref.png` being Apple's decode of the `.heic` next to it, the ground truth we
|
||
//! compare with. The 10-bit one is pillow-heif's (`pillow-heif/`, BSD-3-Clause), with the source
|
||
//! it was encoded from.
|
||
|
||
#![cfg(all(feature = "corpus", feature = "heif"))]
|
||
|
||
mod common;
|
||
use common::*;
|
||
use photocraft_codecs::*;
|
||
use std::sync::LazyLock;
|
||
|
||
/// A file of `corpus/heif/`.
|
||
fn corpus(rel: &str) -> Vec<u8> {
|
||
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/heif").join(rel);
|
||
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()))
|
||
}
|
||
|
||
static FILES: LazyLock<[Vec<u8>; 4]> =
|
||
LazyLock::new(|| ["heic-rs/checker-64.heic", "heic-rs/rgb-strips-96.heic", "heic-rs/checker-1024.heic", "heic-rs/with-exif.heic"].map(corpus));
|
||
|
||
fn checker_64() -> &'static [u8] {
|
||
&FILES[0]
|
||
}
|
||
fn strips_96() -> &'static [u8] {
|
||
&FILES[1]
|
||
}
|
||
/// iPhone-style grid of 512x512 tiles (2x2).
|
||
fn checker_grid() -> &'static [u8] {
|
||
&FILES[2]
|
||
}
|
||
fn with_exif() -> &'static [u8] {
|
||
&FILES[3]
|
||
}
|
||
|
||
fn reference(png: &[u8]) -> Image {
|
||
decode_as(Format::Png, png).unwrap().convert(ChannelLayout::Rgb, SampleType::U8)
|
||
}
|
||
|
||
/// `bytes` with the first `irot` box set to `angle` × 90° anticlockwise (every fixture has one, at 0).
|
||
fn with_irot(bytes: &[u8], angle: u8) -> Vec<u8> {
|
||
let at = bytes.windows(4).position(|w| w == b"irot").unwrap() + 4;
|
||
let mut b = bytes.to_vec();
|
||
b[at] = angle;
|
||
b
|
||
}
|
||
|
||
/// `bytes` with the big-endian EXIF Orientation entry (SHORT, count 1) set to `o`.
|
||
fn with_exif_orientation(bytes: &[u8], o: u8) -> Vec<u8> {
|
||
let at = bytes.windows(8).position(|w| w == [0x01, 0x12, 0, 3, 0, 0, 0, 1]).unwrap() + 9;
|
||
let mut b = bytes.to_vec();
|
||
b[at] = o;
|
||
b
|
||
}
|
||
|
||
fn keep() -> DecodeOptions {
|
||
DecodeOptions { keep_orientation: true, ..Default::default() }
|
||
}
|
||
|
||
#[test]
|
||
fn heif_is_read_only_and_listed() {
|
||
let c = caps(Format::Heif);
|
||
assert!(c.read && !c.write);
|
||
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Heif));
|
||
for ext in ["heic", "HEIC", "photo.heif", "IMG_0001.HIF"] {
|
||
assert_eq!(from_extension(ext), Some(Format::Heif), "{ext}");
|
||
}
|
||
let img = decode(checker_64()).unwrap();
|
||
assert!(matches!(encode(&img, Format::Heif, &EncodeOptions::default()), Err(CodecError::Unsupported { .. })));
|
||
assert_eq!(fidelity_warnings(&img, Format::Heif), vec![FidelityWarning::WriteUnsupported { format: Format::Heif }]);
|
||
}
|
||
|
||
#[test]
|
||
fn detect_heif_brands() {
|
||
for f in [checker_64(), strips_96(), checker_grid(), with_exif()] {
|
||
assert_eq!(detect(f), Some(Format::Heif));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn single_picture_matches_apple_decode() {
|
||
for (heic, png) in [(checker_64(), "heic-rs/checker-64.ref.png"), (strips_96(), "heic-rs/rgb-strips-96.ref.png")] {
|
||
let img = decode(heic).unwrap();
|
||
let want = reference(&corpus(png));
|
||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U8));
|
||
assert_eq!(img.dimensions(), want.dimensions());
|
||
assert!(max_abs_diff(&img, &want) <= 3.0 / 255.0, "max diff {}", max_abs_diff(&img, &want) * 255.0);
|
||
assert!(psnr(&img, &want) > 45.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn grid_tiled_photo_matches_apple_decode() {
|
||
// iPhone photos are grids of 512x512 HEVC tiles: here 2x2.
|
||
let img = decode(checker_grid()).unwrap();
|
||
let want = reference(&corpus("heic-rs/checker-1024.ref.png"));
|
||
assert_eq!(img.dimensions(), (1024, 1024));
|
||
assert!(max_abs_diff(&img, &want) <= 3.0 / 255.0);
|
||
}
|
||
|
||
#[test]
|
||
fn rgb_strips_keep_their_colours() {
|
||
// Three vertical strips, pure red, green and blue: catches swapped channels or matrices.
|
||
let img = decode(strips_96()).unwrap();
|
||
for (x, c) in [(16, 0), (48, 1), (80, 2)] {
|
||
for ch in 0..3 {
|
||
let v = img.get(x, 16, ch);
|
||
assert!(if ch == c { v > 0.9 } else { v < 0.1 }, "strip at x={x} channel {ch} = {v}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn container_rotation_is_applied_once() {
|
||
// irot 1 = 90° anticlockwise: the red strip (left) ends at the bottom, blue (right) on top.
|
||
let rotated = with_irot(strips_96(), 1);
|
||
let img = decode(&rotated).unwrap();
|
||
assert_eq!(img.dimensions(), (32, 96));
|
||
assert!(img.get(16, 8, 2) > 0.9 && img.get(16, 8, 0) < 0.1, "blue on top");
|
||
assert!(img.get(16, 88, 0) > 0.9 && img.get(16, 88, 2) < 0.1, "red at the bottom");
|
||
// keep_orientation hands back the pixels as coded.
|
||
assert_eq!(decode_with(&rotated, &keep()).unwrap().dimensions(), (96, 32));
|
||
}
|
||
|
||
#[test]
|
||
fn exif_and_xmp_survive_and_exif_orientation_is_not_applied_twice() {
|
||
let img = decode(with_exif()).unwrap();
|
||
assert_eq!(img.dimensions(), (2048, 1536));
|
||
let exif = img.meta.exif.as_deref().unwrap();
|
||
assert!(exif.starts_with(b"MM\0*"), "TIFF-structured EXIF, without the HEIF offset header");
|
||
assert!(img.meta.xmp.as_deref().unwrap().contains("x:xmpmeta"));
|
||
|
||
// HEIF's EXIF Orientation repeats what irot says; the container is the authority.
|
||
let tagged = with_exif_orientation(with_exif(), 6);
|
||
let img = decode(&tagged).unwrap();
|
||
assert_eq!(img.dimensions(), (2048, 1536), "not turned by the EXIF tag");
|
||
assert_eq!(exif_orientation(img.meta.exif.as_deref().unwrap()), 1, "rewritten so exports don't turn it either");
|
||
let raw = decode_with(&tagged, &keep()).unwrap();
|
||
assert_eq!(exif_orientation(raw.meta.exif.as_deref().unwrap()), 6, "kept verbatim when asked");
|
||
}
|
||
|
||
#[test]
|
||
fn limits_are_checked_before_decoding() {
|
||
let tight = |limits| DecodeOptions { limits, ..Default::default() };
|
||
let small = Limits { max_width: 512, ..Default::default() };
|
||
assert!(matches!(decode_with(checker_grid(), &tight(small)), Err(CodecError::LimitExceeded(_))));
|
||
let few = Limits { max_pixels: 1000, ..Default::default() };
|
||
assert!(matches!(decode_with(checker_64(), &tight(few)), Err(CodecError::LimitExceeded(_))));
|
||
let bytes = Limits { max_alloc: 64 * 64 * 3 - 1, ..Default::default() };
|
||
assert!(matches!(decode_with(checker_64(), &tight(bytes)), Err(CodecError::LimitExceeded(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_and_broken_files_are_clear_errors() {
|
||
// An ftyp and nothing else: a HEIF image sequence would have a moov here instead of a meta.
|
||
let r = decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc");
|
||
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("sequence")), "{r:?}");
|
||
assert!(decode_as(Format::Heif, b"").is_err());
|
||
assert!(decode_as(Format::Heif, &checker_grid()[..checker_grid().len() / 2]).is_err(), "half a file");
|
||
}
|
||
|
||
#[test]
|
||
fn every_truncation_errors_or_decodes_never_panics() {
|
||
for bytes in [checker_64(), strips_96(), checker_grid()] {
|
||
for cut in 0..bytes.len() {
|
||
let _ = decode_as(Format::Heif, &bytes[..cut]);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ten_bit_with_alpha_decodes_to_16_bit_rgba() {
|
||
// pillow-heif's fixture: a 16-bit RGBA gradient (the .src.png) encoded as 10-bit HEIF by libheif/x265.
|
||
let img = decode(&corpus("pillow-heif/heif/RGBA_10__29x100.heif")).unwrap();
|
||
let src = decode_as(Format::Png, &corpus("pillow-heif/non_heif/RGBA_16__29x100.png")).unwrap();
|
||
assert_eq!((img.dimensions(), img.layout(), img.sample_type()), ((29, 100), ChannelLayout::Rgba, SampleType::U16));
|
||
// Depth is kept, not 8 bits stretched: most samples are not multiples of 257.
|
||
let samples = img.to_u16_samples().unwrap();
|
||
assert!(samples.iter().filter(|v| *v % 257 != 0).count() > samples.len() / 2);
|
||
// The alpha plane is coded on its own and comes back almost exactly.
|
||
for y in 0..100 {
|
||
for x in 0..29 {
|
||
assert!((img.get(x, y, 3) - src.get(x, y, 3)).abs() < 0.002, "alpha at {x},{y}");
|
||
}
|
||
}
|
||
// Colour went through 4:2:0 chroma on a 29-pixel-wide saturated gradient, which costs about
|
||
// 26 dB at any depth (the 8-bit encode of the same source scores the same, as does a second
|
||
// decoder); a decoder bug (range, matrix, bit shift) lands far below.
|
||
let rgb = |i: &Image| i.convert(ChannelLayout::Rgb, SampleType::U16);
|
||
assert!(psnr(&rgb(&img), &rgb(&src)) > 24.0, "{}", psnr(&rgb(&img), &rgb(&src)));
|
||
}
|