mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-IMP-FORMATS: HEIC/HEIF decode behind the optional heif feature
New crate `lightcraft-heif` (a port of PhotoCraft's photocraft-heif): a thin, panic-guarded wrapper around heic-rs (pure Rust, pinned =0.1.1) - 8/10-bit, alpha, grid tiles, container irot/imir/clap, ICC/EXIF/XMP. `lightcraft-codecs` decodes HEIF behind its non-default `heif` feature (HEVC patents are the distributor's call, the same policy as PhotoCraft); the apps forward it. HEIC files stay recognised without the feature: opening one is a clear "isn't included in this build" error. Fuzz-found heic-rs panics arrive as regression fixtures (inline bytes, no media committed). Verified end-to-end on real iPhone HEICs (lightcraft-cli render, rotation and metadata correct). Note for reviewers: measured here, the engine currently ignores the decoded image's source-space matrix on export for ANY format (a true Display-P3 PNG round-trips unchanged); the ICC and space are carried for all codecs, so this inherits - and does not worsen - existing behaviour. Co-Authored-By: opencode <[email protected]>
This commit is contained in:
1 parent
2472021091
commit
853ada7754
16 files changed
+459
-5
No files matched your search
Generated
+17
@@ -2296,6 +2296,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2837,6 +2846,7 @@ dependencies = [
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-raster",
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
@@ -2935,6 +2945,13 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-heif"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.4.0"
|
||||
|
||||
@@ -30,6 +30,7 @@ lightcraft-engine = { path = "crates/engine" }
|
||||
lightcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
|
||||
# foundation
|
||||
|
||||
@@ -260,7 +260,8 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
|
||||
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
|
||||
PSD composites and JPEG XL open today.
|
||||
PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
|
||||
as in PhotoCraft).
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
|
||||
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
|
||||
|
||||
@@ -29,3 +29,7 @@ dhat = { workspace = true, optional = true }
|
||||
# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json
|
||||
# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work.
|
||||
dhat-heap = ["dep:dhat"]
|
||||
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
@@ -71,3 +71,8 @@ web-sys = { workspace = true, features = [
|
||||
"WorkerType",
|
||||
"WritableStream",
|
||||
] }
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
@@ -29,3 +29,8 @@ rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { workspace = true }
|
||||
|
||||
[features]
|
||||
# Open HEIC/HEIF photos (the optional lightcraft-heif crate). Not default so distributors choose
|
||||
# it; official builds (CI, packaging/, release) build with `--features heif`.
|
||||
heif = ["lightcraft-codecs/heif"]
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
lightcraft-heif = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -34,6 +35,10 @@ ravif = { workspace = true, optional = true }
|
||||
default = ["parallel", "jxl", "avif"]
|
||||
parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"]
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
# HEIC/HEIF decode via the optional lightcraft-heif crate (heic-rs, pure Rust). Off by default so
|
||||
# distributors choose it at build time (HEVC patents are a distributor's call, same policy as
|
||||
# PhotoCraft); official builds and CI enable it (`--features heif` on the apps).
|
||||
heif = ["dep:lightcraft-heif"]
|
||||
## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32.
|
||||
avif = ["dep:ravif"]
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! HEIF / HEIC (the iPhone and Mac photo format), read-only.
|
||||
//!
|
||||
//! Decoding lives in the optional `lightcraft-heif` crate (heic-rs, a pure-Rust HEVC
|
||||
//! still-picture decoder), enabled by this crate's `heif` feature — off by default because HEVC
|
||||
//! is patent-encumbered and whether a build carries an HEVC decoder is the distributor's call
|
||||
//! (the same policy as the sibling PhotoCraft). Without the feature, HEIF files are still
|
||||
//! detected and opening one is an [`Error::Unsupported`] error, never a panic.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
|
||||
//! so the decoder applies them and `orientation` is reported as 1: the EXIF Orientation tag only
|
||||
//! mirrors what the container says, and applying it as well would turn the photo twice.
|
||||
//! The samples come back in the container's own primaries with its ICC profile (if any); like
|
||||
//! every decoder here, the colour interpretation happens in [`crate::convert::finish`].
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
use crate::convert::{Buf, Meta, Model, Raw, finish};
|
||||
use crate::{DecodeOptions, Decoded, Error, Format, Result};
|
||||
|
||||
const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
|
||||
|
||||
/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills
|
||||
/// only; the wrapper names the exact construct in its own errors).
|
||||
#[cfg(feature = "heif")]
|
||||
const NOT_DECODED: &str = "the image is not a decodable HEVC still (image sequence, overlay, alpha-less grid or AVIF-coded item)";
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) fn decode(_bytes: &[u8], _opts: &DecodeOptions) -> Result<Decoded> {
|
||||
Err(Error::Unsupported(F, NOT_IN_BUILD))
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
fn err(e: lightcraft_heif::Error) -> Error {
|
||||
match e {
|
||||
// This crate's Unsupported takes a fixed reason; the dynamic wording lives in the
|
||||
// lightcraft-heif error itself.
|
||||
lightcraft_heif::Error::Unsupported(_) => Error::Unsupported(F, NOT_DECODED),
|
||||
lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
||||
// The container alone: the declared size is checked before any pixel is decoded.
|
||||
let info = lightcraft_heif::probe(bytes).map_err(err)?;
|
||||
if (info.width as u64).saturating_mul(info.height as u64) > opts.max_pixels {
|
||||
return Err(Error::TooLarge(info.width as u64, info.height as u64));
|
||||
}
|
||||
let options = lightcraft_heif::Options { max_pixels: opts.max_pixels, apply_transforms: true };
|
||||
let decoded = lightcraft_heif::decode(bytes, &options).map_err(err)?;
|
||||
// The decoder re-reads the container; trust its output's shape, not the probe's.
|
||||
let buf = if decoded.sixteen_bit {
|
||||
let mut v = Vec::with_capacity(decoded.data.len() / 2);
|
||||
for pair in decoded.data.chunks_exact(2).filter_map(|p| <[u8; 2]>::try_from(p).ok()) {
|
||||
v.push(u16::from_ne_bytes(pair));
|
||||
}
|
||||
Buf::U16(v)
|
||||
} else {
|
||||
Buf::U8(decoded.data)
|
||||
};
|
||||
let raw = Raw {
|
||||
width: decoded.width as usize,
|
||||
height: decoded.height as usize,
|
||||
model: Model::Rgb,
|
||||
alpha: decoded.has_alpha,
|
||||
premultiplied: false,
|
||||
buf,
|
||||
bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
|
||||
};
|
||||
let meta = Meta { icc: decoded.icc, exif: decoded.exif, xmp: decoded.xmp, orientation: Some(1), ..Default::default() };
|
||||
finish(F, raw, meta, (decoded.width, decoded.height), opts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Format, sniff};
|
||||
|
||||
/// The container header both builds recognise, whatever the feature says.
|
||||
const HEIC_HEADER: &[u8] = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
|
||||
|
||||
#[test]
|
||||
fn heif_is_always_recognised() {
|
||||
assert_eq!(sniff(HEIC_HEADER), Some(Format::Heif));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_decode_follows_the_feature() {
|
||||
assert_eq!(Format::Heif.can_decode(), cfg!(feature = "heif"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "heif"))]
|
||||
#[test]
|
||||
fn without_the_feature_heif_is_a_clear_unsupported_error() {
|
||||
let r = crate::decode(HEIC_HEADER, crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{r:?}");
|
||||
}
|
||||
|
||||
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
|
||||
/// out of range (`boxes.rs:130`). It must be an error, not a crash.
|
||||
#[cfg(feature = "heif")]
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50,
|
||||
0x72, 0x6d, 0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70,
|
||||
0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn a_heic_rs_panic_is_a_malformed_error() {
|
||||
let r = crate::decode(&HEIC_RS_BOX_PANIC, crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Malformed(Format::Heif, _))), "{r:?}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
#[test]
|
||||
fn an_image_sequence_is_unsupported() {
|
||||
// An ftyp with no meta box: a video track, not a still.
|
||||
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc", crate::DecodeOptions::default());
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, _))), "{r:?}");
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
mod convert;
|
||||
pub mod encode;
|
||||
pub mod exif;
|
||||
mod heif;
|
||||
pub mod icc;
|
||||
mod jpeg;
|
||||
pub mod jpeg_par;
|
||||
@@ -171,8 +172,8 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
|
||||
Format::Jxl => jxl::decode(bytes, &opts),
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
|
||||
Format::Heif => heif::decode(bytes, &opts),
|
||||
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
|
||||
Format::Heif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed HEVC decoder yet")),
|
||||
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ impl Format {
|
||||
pub fn can_decode(self) -> bool {
|
||||
match self {
|
||||
Format::Jxl => cfg!(feature = "jxl"),
|
||||
Format::Avif | Format::Heif | Format::RawTiffLike | Format::RawOther => false,
|
||||
Format::Heif => cfg!(feature = "heif"),
|
||||
Format::Avif | Format::RawTiffLike | Format::RawOther => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "lightcraft-heif"
|
||||
description = "Optional HEIF/HEIC still-image decoder for LightCraft (a thin, panic-guarded wrapper around heic-rs)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files;
|
||||
# moving the pin is a reviewed change (re-run the heif tests and the codecs corpus tests).
|
||||
# Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the
|
||||
# other permissive candidates (heif-oxide lacks 4:2:2; heifer was published the same week).
|
||||
heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
|
||||
|
||||
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,33 @@
|
||||
# lightcraft-heif
|
||||
|
||||
The optional HEIF / HEIC decoder of LightCraft: iPhone and Mac photos. A thin, panic-guarded wrapper
|
||||
around [`heic-rs`](https://github.com/tbraun96/heic-rs), a pure-Rust HEVC still-picture decoder
|
||||
(no `unsafe`, MIT OR Apache-2.0): single pictures and grid-tiled photos, 8- and 10-bit (10-bit
|
||||
decodes to 16-bit), alpha auxiliary images, the container's rotation/mirror/crop, ICC, EXIF and XMP.
|
||||
Read-only: writing would need an HEVC encoder.
|
||||
|
||||
```rust
|
||||
let info = lightcraft_heif::probe(&bytes)?; // size, depth, alpha: check limits first
|
||||
let img = lightcraft_heif::decode(&bytes, &lightcraft_heif::Options::default())?;
|
||||
// img.width, img.height, img.has_alpha, img.sixteen_bit, img.data (RGB/RGBA), img.icc, img.exif, img.xmp
|
||||
```
|
||||
|
||||
Errors are `Error::Unsupported` (image sequences, overlays, …), `Error::Limit` or
|
||||
`Error::Malformed`. It never panics: every call into heic-rs runs under `catch_unwind`, and a panic
|
||||
inside it (PhotoCraft's fuzzing found two in 0.1.1) becomes `Error::Malformed`. The crate is a port
|
||||
of PhotoCraft's `photocraft-heif` — the sibling apps deliberately share one decoder, one pin and
|
||||
one set of regression fixtures.
|
||||
|
||||
## Why it is a separate, optional crate
|
||||
|
||||
- **A young decoder.** heic-rs is new and has a single maintainer. It is pinned exactly
|
||||
(`=0.1.1`); moving the pin is a reviewed change (re-run the crate tests and
|
||||
`cargo test -p lightcraft-codecs --features heif`).
|
||||
- **HEVC patents are a distributor's call.** HEVC is patent-encumbered in some jurisdictions, so
|
||||
whether a build includes an HEVC decoder is a build-time choice. `lightcraft-codecs` uses this
|
||||
crate only behind its non-default `heif` feature; the apps forward it
|
||||
(`cargo build -p lightcraft --features heif`). Without it, HEIC files are still recognised and
|
||||
opening one is a clear "HEIC/HEIF support isn't included in this build" error — the same policy
|
||||
as PhotoCraft.
|
||||
|
||||
The crate depends on no other LightCraft crate; `codecs` → `heif` is the only edge between them.
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `lightcraft-heif`: the optional HEIF / HEIC (iPhone and Mac photo) decoder.
|
||||
//!
|
||||
//! A thin wrapper around `heic-rs`, a pure-Rust HEVC still-picture decoder: single pictures and
|
||||
//! grid-tiled photos, 8- and 10-bit (10-bit decodes to 16-bit), alpha auxiliary images, ICC,
|
||||
//! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows
|
||||
//! nothing about the rest of LightCraft; `lightcraft-codecs` adapts it behind its `heif` feature.
|
||||
//! A port of PhotoCraft's `photocraft-heif` (same team, same licence) so the sibling apps share
|
||||
//! one decision, one dependency pin and one set of regression fixtures.
|
||||
//!
|
||||
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF.
|
||||
//! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded.
|
||||
//!
|
||||
//! Never panics: heic-rs is young, so every call into it runs under `catch_unwind` and a panic
|
||||
//! inside it becomes [`Error::Malformed`].
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use heic_rs::PixelLayout;
|
||||
|
||||
/// What a HEIF file declares, read from the container alone (no pixel is decoded).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Info {
|
||||
/// Size after the container transforms (rotation, mirror, crop): what a viewer shows.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Size as coded, before the transforms.
|
||||
pub coded_width: u32,
|
||||
pub coded_height: u32,
|
||||
/// The primary image has an alpha auxiliary image.
|
||||
pub has_alpha: bool,
|
||||
/// More than 8 bits per sample: it decodes to 16-bit.
|
||||
pub sixteen_bit: bool,
|
||||
}
|
||||
|
||||
/// Decode settings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Options {
|
||||
/// Refuse images with more pixels than this (checked before decoding).
|
||||
pub max_pixels: u64,
|
||||
/// Apply the container's rotation, mirror and crop (`irot`/`imir`/`clap`).
|
||||
pub apply_transforms: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Options { max_pixels: 1 << 28, apply_transforms: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded image: interleaved RGB or RGBA samples, row-major, no padding. 16-bit samples are
|
||||
/// native-endian `u16`s.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Decoded {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Four channels (RGBA) instead of three (RGB).
|
||||
pub has_alpha: bool,
|
||||
/// Two bytes per sample instead of one.
|
||||
pub sixteen_bit: bool,
|
||||
pub data: Vec<u8>,
|
||||
/// The primary image's ICC profile.
|
||||
pub icc: Option<Vec<u8>>,
|
||||
/// EXIF as a TIFF structure (without HEIF's offset header). Its Orientation tag only mirrors
|
||||
/// what the container says; it is returned verbatim.
|
||||
pub exif: Option<Vec<u8>>,
|
||||
/// The XMP packet.
|
||||
pub xmp: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a file could not be decoded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// A valid file using something this decoder does not handle (image sequences, overlays…).
|
||||
Unsupported(String),
|
||||
/// The image is larger than [`Options::max_pixels`] or holds an oversized box.
|
||||
Limit(String),
|
||||
/// Broken or truncated data (or a decoder bug, reported the same way).
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Unsupported(m) => write!(f, "unsupported HEIF: {m}"),
|
||||
Error::Limit(m) => write!(f, "limit exceeded: {m}"),
|
||||
Error::Malformed(m) => write!(f, "malformed HEIF: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
const SEQUENCE: &str = "this file holds an image sequence; only HEIF still images can be opened";
|
||||
|
||||
fn err(e: heic_rs::Error) -> Error {
|
||||
match e {
|
||||
heic_rs::Error::Unsupported(what) => Error::Unsupported(what.to_string()),
|
||||
// The primary item is neither an HEVC picture nor a grid of them.
|
||||
heic_rs::Error::MissingBox("hvcC") => Error::Unsupported("the image is an overlay, an identity derivation or not HEVC-coded".into()),
|
||||
heic_rs::Error::PixelLimit { .. } | heic_rs::Error::BoxTooLarge { .. } => Error::Limit(e.to_string()),
|
||||
e => Error::Malformed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f`, turning a heic-rs panic into an error. Fuzzing in the sibling app found two
|
||||
/// out-of-range slices in heic-rs 0.1.1 on malformed files: in its box parser (`boxes.rs:130`)
|
||||
/// and during reconstruction (`hevc/decode/recon.rs:70`).
|
||||
fn guarded<T>(f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Err(Error::Malformed("the HEIF decoder failed on this file".into())))
|
||||
}
|
||||
|
||||
/// Reads the container's declared size, depth and alpha without decoding pixels, so callers can
|
||||
/// check their limits first.
|
||||
pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
|
||||
guarded(|| probe_unguarded(bytes))
|
||||
}
|
||||
|
||||
fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
|
||||
let info = heic_rs::probe(bytes).map_err(|e| match e {
|
||||
// No `meta` box: no still image, only a `moov` image sequence (a video track).
|
||||
heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE.into()),
|
||||
e => err(e),
|
||||
})?;
|
||||
Ok(Info {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
coded_width: info.coded_width,
|
||||
coded_height: info.coded_height,
|
||||
has_alpha: info.has_alpha,
|
||||
sixteen_bit: info.bit_depth > 8,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decodes the primary image, with its metadata.
|
||||
pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
guarded(|| decode_unguarded(bytes, options))
|
||||
}
|
||||
|
||||
fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
let info = probe_unguarded(bytes)?;
|
||||
let pixel_layout = match (info.has_alpha, info.sixteen_bit) {
|
||||
(true, true) => PixelLayout::Rgba16,
|
||||
(true, false) => PixelLayout::Rgba8,
|
||||
(false, true) => PixelLayout::Rgb16,
|
||||
(false, false) => PixelLayout::Rgb8,
|
||||
};
|
||||
let raw = heic_rs::DecodeOptions::default()
|
||||
.with_layout(pixel_layout)
|
||||
.with_max_pixels(Some(options.max_pixels))
|
||||
.with_transforms(options.apply_transforms)
|
||||
.with_alpha(info.has_alpha);
|
||||
let decoded = heic_rs::decode(bytes, &raw).map_err(err)?;
|
||||
let (icc, exif, xmp) = metadata(bytes);
|
||||
Ok(Decoded {
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
has_alpha: info.has_alpha,
|
||||
sixteen_bit: info.sixteen_bit,
|
||||
data: decoded.data,
|
||||
icc,
|
||||
exif,
|
||||
xmp,
|
||||
})
|
||||
}
|
||||
|
||||
/// The primary image's ICC profile, EXIF (TIFF structure) and XMP packet. Metadata is a
|
||||
/// courtesy: a broken metadata item never fails a decode whose pixels came out fine.
|
||||
fn metadata(bytes: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, Option<String>) {
|
||||
let Ok(ctx) = heic_rs::context::Context::open(bytes) else {
|
||||
return (None, None, None);
|
||||
};
|
||||
let id = ctx.meta.primary;
|
||||
let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec));
|
||||
let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec);
|
||||
let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
|
||||
Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(),
|
||||
Cow::Owned(b) => String::from_utf8(b).ok(),
|
||||
});
|
||||
(icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
|
||||
/// out of range (`boxes.rs:130`). It must be an error, not a crash.
|
||||
const HEIC_RS_BOX_PANIC: [u8; 72] = [
|
||||
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50,
|
||||
0x72, 0x6d, 0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70,
|
||||
0x00, 0xa8, 0x00,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn a_heic_rs_panic_is_a_malformed_error() {
|
||||
for apply_transforms in [true, false] {
|
||||
let r = decode(&HEIC_RS_BOX_PANIC, &Options { apply_transforms, ..Default::default() });
|
||||
assert!(matches!(r, Err(Error::Malformed(_))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sequence_is_unsupported_with_a_clear_message() {
|
||||
// An ftyp and nothing else: a HEIF image sequence would have a moov here instead of a meta.
|
||||
let bytes = b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc";
|
||||
for r in [probe(bytes).map(|_| ()), decode(bytes, &Options::default()).map(|_| ())] {
|
||||
assert!(matches!(&r, Err(Error::Unsupported(m)) if m.contains("sequence")), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error() {
|
||||
for bytes in [&b""[..], b"\0\0\0\x18ftypheic\0\0\0\0mif1heic", &[0xFF; 64]] {
|
||||
assert!(probe(bytes).is_err());
|
||||
assert!(decode(bytes, &Options::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_display_their_kind() {
|
||||
assert!(Error::Unsupported("x".into()).to_string().contains("unsupported"));
|
||||
assert!(Error::Limit("x".into()).to_string().contains("limit"));
|
||||
assert!(Error::Malformed("x".into()).to_string().contains("malformed"));
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -61,7 +61,8 @@ Take the first one nobody is working on.
|
||||
|
||||
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
|
||||
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon
|
||||
"lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see
|
||||
"lossy after split" NEF, Canon sRAW; AVIF decode (HEIC decodes behind the codecs' non-default `heif` feature:
|
||||
the optional `lightcraft-heif` crate on heic-rs, the same decoder and policy as PhotoCraft). Clean-room, from prose descriptions only (see
|
||||
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
|
||||
3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2
|
||||
colour-filter layout, fixed in #85 by reading the file's own tag).
|
||||
@@ -124,7 +125,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
|
||||
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
|
||||
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC decodes behind the non-default `heif` feature (`crates/heif`, heic-rs, same policy as PhotoCraft: 8/10-bit, alpha, container irot/imir/clap, ICC/EXIF/XMP); AVIF decode |
|
||||
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
|
||||
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
|
||||
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
- Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,
|
||||
preserving RAW editing & full-resolution export instead of using embedded JPEG previews.
|
||||
|
||||
### Formats
|
||||
- HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind
|
||||
codecs' `heif` feature — 8- and 10-bit, alpha, grid tiles, the container's rotation/mirror/crop, ICC, EXIF and XMP.
|
||||
Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`.
|
||||
|
||||
### Presets and profiles
|
||||
- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip`
|
||||
bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.
|
||||
|
||||
@@ -39,6 +39,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("tiff", Class::Layer(0)),
|
||||
("sysmem", Class::Layer(0)),
|
||||
("fetch", Class::Layer(0)),
|
||||
("heif", Class::Layer(0)),
|
||||
("raw", Class::Layer(1)),
|
||||
("codecs", Class::Layer(1)),
|
||||
("meta", Class::Layer(1)),
|
||||
|
||||
Reference in new issue
Block a user