Merge PR #609
# Conflicts: # crates/engine/src/tests_import.rs # docs/parity.md
This commit is contained in:
commit
78550e5de9
|
|
@ -3119,6 +3119,7 @@ dependencies = [
|
|||
"lightcraft-fetch",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-gpu",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
|
|
@ -3194,6 +3195,7 @@ name = "lightcraft-heif"
|
|||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -44,3 +44,5 @@ avif = ["dep:ravif"]
|
|||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
# Lossless synthetic HEIC files for the `heif` feature's tests.
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d
|
|||
| PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported |
|
||||
| JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) |
|
||||
| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer |
|
||||
| HEIC/HEIF | **no** (sniff only) | — | see gaps |
|
||||
| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported |
|
||||
|
||||
## Colour
|
||||
|
||||
|
|
@ -100,9 +100,11 @@ Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocap
|
|||
|
||||
## Gaps / limitations
|
||||
|
||||
- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today
|
||||
(dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with
|
||||
`Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails.
|
||||
- **AVIF decode**: no permissively licensed, pure-Rust AV1 decoder exists today (dav1d is C;
|
||||
rav1d-safe is AGPL). Files are sniffed and rejected with `Error::Unsupported`.
|
||||
- **HEIC** needs the `heif` feature (off by default: HEVC patents are the distributor's call);
|
||||
without it, `Error::Unsupported` says the build has no HEIC support. 4:2:2 and 4:4:4 HEIF
|
||||
(Canon/Sony HIF) is refused: heic-rs 0.1.1 loses sync on those bitstreams.
|
||||
- JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read
|
||||
nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved
|
||||
baseline) for maximum compatibility.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
//! 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.
|
||||
//! still-picture decoder, plus a libheif-matching colour conversion), 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
|
||||
//! saying so, 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`].
|
||||
//! The samples come back as 16-bit RGB in the container's own primaries, with its ICC profile
|
||||
//! (iPhones: Display P3) or, without one, its `nclx` / VUI colour description; like every decoder
|
||||
//! here, the colour interpretation happens in [`crate::convert::finish`].
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
use crate::convert::{Buf, Meta, Model, Raw, finish};
|
||||
|
|
@ -22,26 +24,32 @@ const F: Format = Format::Heif;
|
|||
#[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(not(feature = "heif"))]
|
||||
pub(crate) fn header(_bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
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::Unsupported(why) => Error::Unsupported(F, why),
|
||||
lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m),
|
||||
}
|
||||
}
|
||||
|
||||
/// The displayed size (container transforms applied, so orientation 1), from the container
|
||||
/// alone: no picture is decoded.
|
||||
#[cfg(feature = "heif")]
|
||||
pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> {
|
||||
let info = lightcraft_heif::probe(bytes).map_err(err)?;
|
||||
Ok((info.width, info.height, 1))
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
|
@ -50,16 +58,22 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
|||
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.as_chunks::<2>().0 {
|
||||
v.push(u16::from_ne_bytes(*pair));
|
||||
// A small decode (a thumbnail or a cataloguing probe) uses the file's own thumbnail image when
|
||||
// it is at least as large as asked: iPhones store one of 320 × 240, and decoding it is a
|
||||
// hundredth of the work of the 12 MP photo.
|
||||
let thumb = match opts.max_size {
|
||||
Some((mw, mh)) if mw > 0 && mh > 0 && mw.max(mh) < info.width.max(info.height) => {
|
||||
lightcraft_heif::decode_thumbnail(bytes, mw.max(mh), &options).ok().flatten().filter(|t| {
|
||||
// Only a thumbnail of the same picture: same orientation and aspect within 1 %.
|
||||
let aspect = |w: u32, h: u32| w as f64 / h.max(1) as f64;
|
||||
(aspect(t.width, t.height) / aspect(info.width, info.height) - 1.0).abs() < 0.01
|
||||
})
|
||||
}
|
||||
Buf::U16(v)
|
||||
} else {
|
||||
Buf::U8(decoded.data)
|
||||
_ => None,
|
||||
};
|
||||
let mut decoded = match thumb {
|
||||
Some(t) => t,
|
||||
None => lightcraft_heif::decode(bytes, &options).map_err(err)?,
|
||||
};
|
||||
let raw = Raw {
|
||||
width: decoded.width as usize,
|
||||
|
|
@ -67,11 +81,20 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> {
|
|||
model: Model::Rgb,
|
||||
alpha: decoded.has_alpha,
|
||||
premultiplied: false,
|
||||
buf,
|
||||
bit_depth: if decoded.sixteen_bit { 16 } else { 8 },
|
||||
buf: Buf::U16(std::mem::take(&mut decoded.data)),
|
||||
bit_depth: decoded.bit_depth,
|
||||
};
|
||||
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)
|
||||
let c = decoded.colour;
|
||||
let meta = Meta {
|
||||
icc: decoded.icc,
|
||||
exif: decoded.exif,
|
||||
xmp: decoded.xmp,
|
||||
orientation: Some(1),
|
||||
// Used when there is no (usable) ICC profile.
|
||||
hint: crate::space::SourceSpace::from_cicp(c.primaries, c.transfer),
|
||||
..Default::default()
|
||||
};
|
||||
finish(F, raw, meta, (info.width, info.height), opts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -96,6 +119,8 @@ mod tests {
|
|||
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:?}");
|
||||
let h = crate::read_header(HEIC_HEADER);
|
||||
assert!(matches!(&h, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{h:?}");
|
||||
}
|
||||
|
||||
/// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice
|
||||
|
|
@ -113,6 +138,7 @@ mod tests {
|
|||
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:?}");
|
||||
assert!(crate::read_header(&HEIC_RS_BOX_PANIC).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "heif")]
|
||||
|
|
@ -120,6 +146,6 @@ mod tests {
|
|||
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:?}");
|
||||
assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("sequence")), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,9 +172,9 @@ pub struct Header {
|
|||
}
|
||||
|
||||
/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with
|
||||
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from
|
||||
/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small
|
||||
/// decode.
|
||||
/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD and HEIF are read
|
||||
/// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to
|
||||
/// a small decode.
|
||||
///
|
||||
/// The header readers also refuse what a decode would refuse before reaching the pixels (an
|
||||
/// unsupported layout, compression or frame type) and files whose image data is cut short
|
||||
|
|
@ -197,6 +197,7 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
|
|||
Format::Tiff => tiff_codec::header(bytes)?,
|
||||
Format::WebP => webp::header(bytes)?,
|
||||
Format::Psd => psd::header(bytes)?,
|
||||
Format::Heif => heif::header(bytes)?,
|
||||
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
|
||||
_ => {
|
||||
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
|
||||
|
|
@ -218,6 +219,8 @@ pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: Na
|
|||
#[doc(hidden)]
|
||||
pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
|
||||
let opts = *opts;
|
||||
// The reason a format can't be decoded lives in one place, `Format::not_decodable`.
|
||||
let unsupported = || Err(Error::Unsupported(format, format.not_decodable().unwrap_or("not decodable in this build")));
|
||||
match format {
|
||||
Format::Jpeg => jpeg::decode(bytes, &opts),
|
||||
Format::Png => png_codec::decode(bytes, &opts),
|
||||
|
|
@ -228,10 +231,9 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R
|
|||
#[cfg(feature = "jxl")]
|
||||
Format::Jxl => jxl::decode(bytes, &opts),
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")),
|
||||
Format::Jxl => unsupported(),
|
||||
Format::Heif => heif::decode(bytes, &opts),
|
||||
Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")),
|
||||
Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")),
|
||||
Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,25 +127,10 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> {
|
|||
}
|
||||
|
||||
fn container_hint(info: &png::Info) -> Option<SourceSpace> {
|
||||
if let Some(c) = &info.coding_independent_code_points {
|
||||
let named = match c.color_primaries {
|
||||
1 => Some(NamedSpace::Srgb),
|
||||
9 => Some(NamedSpace::Rec2020),
|
||||
12 => Some(NamedSpace::DisplayP3),
|
||||
_ => None,
|
||||
};
|
||||
let trc = match c.transfer_function {
|
||||
13 => Some(Trc::Srgb),
|
||||
1 | 6 | 14 | 15 => Some(Trc::Rec709),
|
||||
8 => Some(Trc::Linear),
|
||||
4 => Some(Trc::Gamma(2.2)),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(n), Some(t)) = (named, trc) {
|
||||
let mut s = SourceSpace::named(n, SpaceOrigin::Container);
|
||||
s.trc = Some([t.clone(), t.clone(), t]);
|
||||
return Some(s);
|
||||
}
|
||||
if let Some(s) =
|
||||
info.coding_independent_code_points.as_ref().and_then(|c| SourceSpace::from_cicp(c.color_primaries.into(), c.transfer_function.into()))
|
||||
{
|
||||
return Some(s);
|
||||
}
|
||||
if info.srgb.is_some() {
|
||||
return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container));
|
||||
|
|
|
|||
|
|
@ -64,11 +64,20 @@ impl Format {
|
|||
|
||||
/// Whether [`crate::decode`] can decode this format in this build.
|
||||
pub fn can_decode(self) -> bool {
|
||||
self.not_decodable().is_none()
|
||||
}
|
||||
|
||||
/// Why [`crate::decode`] can't decode this format in this build (`None`: it can). The
|
||||
/// [`crate::Error::Unsupported`] a decode returns carries the same reason.
|
||||
pub fn not_decodable(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Format::Jxl => cfg!(feature = "jxl"),
|
||||
Format::Heif => cfg!(feature = "heif"),
|
||||
Format::Avif | Format::RawTiffLike | Format::RawOther => false,
|
||||
_ => true,
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
Format::Jxl => Some("built without the `jxl` feature"),
|
||||
#[cfg(not(feature = "heif"))]
|
||||
Format::Heif => Some(crate::heif::NOT_IN_BUILD),
|
||||
Format::Avif => Some("no pure-Rust, permissively licensed AV1 decoder yet"),
|
||||
Format::RawTiffLike | Format::RawOther => Some("camera raw: decode with lightcraft-raw"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -255,6 +255,27 @@ impl SourceSpace {
|
|||
SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin }
|
||||
}
|
||||
|
||||
/// The space named by ITU-T H.273 code points (PNG `cICP`, HEIF `nclx`, HEVC VUI), when
|
||||
/// both the primaries and the transfer are ones this crate models.
|
||||
pub(crate) fn from_cicp(primaries: u16, transfer: u16) -> Option<SourceSpace> {
|
||||
let named = match primaries {
|
||||
1 => NamedSpace::Srgb,
|
||||
9 => NamedSpace::Rec2020,
|
||||
12 => NamedSpace::DisplayP3,
|
||||
_ => return None,
|
||||
};
|
||||
let trc = match transfer {
|
||||
13 => Trc::Srgb,
|
||||
1 | 6 | 14 | 15 => Trc::Rec709,
|
||||
8 => Trc::Linear,
|
||||
4 => Trc::Gamma(2.2),
|
||||
_ => return None,
|
||||
};
|
||||
let mut s = SourceSpace::named(named, SpaceOrigin::Container);
|
||||
s.trc = Some([trc.clone(), trc.clone(), trc]);
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white).
|
||||
pub fn to_space(&self, dst: &RgbSpace) -> Mat3 {
|
||||
if let Some(n) = self.named {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
//! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from
|
||||
//! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a
|
||||
//! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input.
|
||||
#![cfg(feature = "heif")]
|
||||
|
||||
use lightcraft_codecs::*;
|
||||
use lightcraft_heif::testdata::{Prop, Spec, build};
|
||||
|
||||
fn grey(x: u32, y: u32) -> [u16; 3] {
|
||||
[((x * 7 + y * 13) % 220 + 20) as u16, 128, 128]
|
||||
}
|
||||
|
||||
/// EXIF whose Orientation (6) only mirrors the container's `irot`: it must not turn the photo again.
|
||||
fn exif_orientation_6() -> Vec<u8> {
|
||||
let mut t = b"MM\0\x2a\0\0\0\x08\0\x01".to_vec();
|
||||
t.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 6, 0, 0]);
|
||||
t.extend_from_slice(&[0, 0, 0, 0]);
|
||||
t
|
||||
}
|
||||
|
||||
/// The thumbnail is flat mid grey so a test can tell it from the photo.
|
||||
fn iphone_like() -> Vec<u8> {
|
||||
build(&Spec {
|
||||
props: vec![Prop::Icc(icc::write_named(NamedSpace::DisplayP3)), Prop::Irot(3)],
|
||||
exif: Some(exif_orientation_6()),
|
||||
thumbnail: Some(&|_, _| [126, 128, 128]),
|
||||
ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap"],
|
||||
..Spec::new(96, 64, &grey)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_iphone_like_heic_decodes_upright_in_display_p3() {
|
||||
let bytes = iphone_like();
|
||||
assert_eq!(sniff(&bytes), Some(Format::Heif));
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
// irot 3 = 90° clockwise, applied by the decoder; the EXIF Orientation is not applied again.
|
||||
assert_eq!((d.width, d.height, d.source_width, d.source_height, d.orientation), (64, 96, 64, 96, 1));
|
||||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||||
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
|
||||
assert_eq!((d.bit_depth, d.has_alpha, d.float), (8, false, false));
|
||||
assert!(d.exif.is_some() && d.icc.is_some());
|
||||
// No nclx and no VUI: limited range, BT.601; neutral chroma stays neutral.
|
||||
let srgb = d.to_srgb8();
|
||||
let px = srgb.data[0];
|
||||
assert!(px[0].abs_diff(px[1]) <= 1 && px[1].abs_diff(px[2]) <= 1, "{px:?}");
|
||||
|
||||
let h = read_header(&bytes).unwrap();
|
||||
assert_eq!((h.format, h.width, h.height, h.orientation), (Format::Heif, 64, 96, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_decode_uses_the_embedded_thumbnail() {
|
||||
let bytes = iphone_like();
|
||||
// The 48 × 32 thumbnail, turned like the photo, covers a 32-pixel request.
|
||||
let d = decode(&bytes, DecodeOptions::fit(32, 32)).unwrap();
|
||||
assert!(d.width.max(d.height) <= 32 && d.height > d.width, "{}x{}", d.width, d.height);
|
||||
assert_eq!((d.source_width, d.source_height), (64, 96));
|
||||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||||
let flat = d.to_srgb8();
|
||||
assert!(flat.data.iter().all(|p| *p == flat.data[0]), "the flat thumbnail, not the photo");
|
||||
// A thumbnail too small for the request: the photo is decoded and resized.
|
||||
let d = decode(&bytes, DecodeOptions::fit(60, 60)).unwrap();
|
||||
assert_eq!((d.width.max(d.height), d.source_width), (60, 64));
|
||||
let photo = d.to_srgb8();
|
||||
assert!(photo.data.iter().any(|p| *p != photo.data[0]));
|
||||
let t = decode_thumbnail(&bytes, 32).unwrap();
|
||||
assert_eq!((t.source_width, t.source_height), (64, 96));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nclx_colour_and_ten_bit() {
|
||||
// Display P3 primaries, sRGB transfer, BT.709 matrix, full range: the nclx alone names the space.
|
||||
let bytes = build(&Spec { bit_depth: 10, props: vec![Prop::Nclx(12, 13, 1, true)], ..Spec::new(64, 32, &|x, _| [(x * 16) as u16, 512, 512]) });
|
||||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||||
assert_eq!(d.space.origin, SpaceOrigin::Container);
|
||||
assert_eq!(d.bit_depth, 10);
|
||||
// Full-range 10-bit neutral steps of 16 code values: linear values rise monotonically.
|
||||
let row: Vec<f32> = (0..64).map(|x| d.image.row(0)[x][1]).collect();
|
||||
assert!(row.windows(2).all(|w| w[1] > w[0]), "{row:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_heic_is_an_error_never_a_panic() {
|
||||
let bytes = iphone_like();
|
||||
let full = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
for cut in (12..bytes.len()).step_by(11) {
|
||||
let b = &bytes[..cut];
|
||||
// Cut inside the photo: an error. Cut in a trailing item (gain map, thumbnail): the photo.
|
||||
if let (Ok(_), Ok(d)) = (read_header(b), decode(b, DecodeOptions::default())) {
|
||||
assert_eq!(d.image.data, full.image.data, "cut at {cut}");
|
||||
}
|
||||
let _ = decode_thumbnail(b, 16);
|
||||
}
|
||||
let r = decode(&bytes, DecodeOptions { max_pixels: 64 * 96 - 1, ..Default::default() });
|
||||
assert!(matches!(r, Err(Error::TooLarge(64, 96))), "{r:?}");
|
||||
}
|
||||
|
|
@ -49,5 +49,7 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
|||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
lightcraft-heif = { workspace = true, features = ["testdata"] }
|
||||
libc = { workspace = true }
|
||||
lightcraft-tiff = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -152,8 +152,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
|||
});
|
||||
}
|
||||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||||
if !fmt.can_decode() {
|
||||
return Err(format!("{fmt:?} files are not supported yet"));
|
||||
if let Some(why) = fmt.not_decodable() {
|
||||
// e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft"
|
||||
return Err(format!("{fmt:?} files can't be opened: {why}"));
|
||||
}
|
||||
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
|
||||
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ use crate::media::ProbeInfo;
|
|||
/// File extensions LightCraft imports (lower case).
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
|
||||
"jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
||||
"jxl", "gif", "bmp",
|
||||
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
|
||||
// failed with the reason ("HEIC/HEIF support isn't included in this build").
|
||||
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
|
||||
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1138,3 +1138,40 @@ fn a_file_gone_since_the_import_review_is_not_imported() {
|
|||
assert_eq!(s.catalog.len(), 1, "no photo for a file that isn't there");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A HEIC laid out like an iPhone's (a grid of HEVC pictures, a Display P3 profile, a quarter turn
|
||||
/// in the container, a thumbnail) imports as a normal photo that develops and exports upright like
|
||||
/// a JPEG. In a build without the codecs' `heif` feature it is reported as failed, saying why.
|
||||
#[test]
|
||||
fn heic_imports_as_a_normal_photo_or_says_why_not() {
|
||||
use lightcraft_heif::testdata::{Prop, Spec, build};
|
||||
let src = temp_dir("heic");
|
||||
let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150];
|
||||
let p3 = lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3);
|
||||
let bytes = build(&Spec { props: vec![Prop::Icc(p3), Prop::Irot(1)], thumbnail: Some(&photo), ..Spec::new(96, 64, &photo) });
|
||||
std::fs::write(src.join("IMG_0001.HEIC"), &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
if !lightcraft_codecs::Format::Heif.can_decode() {
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (0, 1), "{r}");
|
||||
assert!(r["failed"].to_string().contains("isn't included in this build"), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
return;
|
||||
}
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}");
|
||||
let p = s.catalog.photos().next().unwrap();
|
||||
let id = p.id;
|
||||
// Upright (the container's turn applied) and not preview-only.
|
||||
assert_eq!((p.kind, p.preview_only.clone(), p.width, p.height), (lightcraft_catalog::MediaKind::Image, None, 64, 96));
|
||||
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
|
||||
let plain = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap();
|
||||
assert_eq!((plain.width, plain.height), (64, 96));
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||||
let brighter = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap();
|
||||
let mean = |png: &[u8]| {
|
||||
let d = lightcraft_codecs::decode(png, Default::default()).unwrap();
|
||||
d.image.data.iter().map(|p| p[1]).sum::<f32>() / d.image.data.len() as f32
|
||||
};
|
||||
assert!(mean(&brighter.bytes) > mean(&plain.bytes) * 1.3, "exposure +1 brightens the HEIC");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "lightcraft-heif"
|
||||
description = "Optional HEIF/HEIC still-image decoder for LightCraft (a thin, panic-guarded wrapper around heic-rs)"
|
||||
description = "Optional HEIF/HEIC still-image decoder for LightCraft (heic-rs for the container and HEVC, libheif-matching colour conversion, panic-guarded)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -9,12 +9,18 @@ license.workspace = true
|
|||
# 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).
|
||||
# other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL)
|
||||
# code structure; gamut-heic has no HEVC decoder of its own).
|
||||
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"] }
|
||||
rayon = { workspace = true }
|
||||
|
||||
[features]
|
||||
# `lightcraft_heif::testdata`: lossless synthetic HEIF files, for the tests of the crates above.
|
||||
testdata = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,33 +1,47 @@
|
|||
# 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.
|
||||
The optional HEIF / HEIC decoder of LightCraft: iPhone and Mac photos. [`heic-rs`](https://github.com/tbraun96/heic-rs)
|
||||
(pure Rust, no `unsafe`, MIT OR Apache-2.0, written from the H.265 / HEIF specifications) reads the
|
||||
container and reconstructs the HEVC pictures; this crate turns them into 16-bit RGB itself so the
|
||||
result matches libheif to within one code value:
|
||||
|
||||
- the colour description is the item's `colr nclx` (or its first tile's), else the HEVC VUI
|
||||
(iPhones: full range), else the H.265 defaults; an unspecified matrix is BT.601. heic-rs alone
|
||||
assumes BT.709 limited range, about 8 code values off on every iPhone photo;
|
||||
- chroma is upsampled by nearest neighbour, or centred bilinear when a `clap` starts on an odd
|
||||
pixel, as libheif does;
|
||||
- `imir` axis 0 flips top and bottom, as libheif writes and reads it (heic-rs names it the other way);
|
||||
- a grid's ICC profile may sit on its tiles.
|
||||
|
||||
Single pictures and grid-tiled photos, 4:2:0 and monochrome, 8- to 10-bit, alpha auxiliary images
|
||||
(other auxiliary images — HDR gain maps, depth — are ignored), the container's rotation/mirror/crop,
|
||||
ICC, EXIF and XMP, and the file's thumbnail item. 4:2:2 and 4:4:4 (Canon/Sony "HIF") are refused
|
||||
with `Error::Unsupported`: heic-rs 0.1.1 loses sync on them. Read-only.
|
||||
|
||||
```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
|
||||
// img.width, img.height, img.has_alpha, img.bit_depth, img.data (RGB/RGBA u16), img.colour, img.icc, img.exif, img.xmp
|
||||
let thumb = lightcraft_heif::decode_thumbnail(&bytes, 256, &Default::default())?; // Option<Decoded>
|
||||
```
|
||||
|
||||
Errors are `Error::Unsupported` (image sequences, overlays, …), `Error::Limit` or
|
||||
Errors are `Error::Unsupported` (image sequences, overlays, 4:2:2/4:4:4, …), `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.
|
||||
inside it (PhotoCraft's fuzzing found two in 0.1.1) becomes `Error::Malformed`.
|
||||
|
||||
Feature `testdata` exposes `lightcraft_heif::testdata`: lossless synthetic HEIF files (PCM-coded
|
||||
32 × 32 HEVC pictures, grids, every property above) for the tests of the crates above.
|
||||
|
||||
## 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.
|
||||
`cargo test -p lightcraft-codecs --features heif`). The other permissive candidates were
|
||||
rejected: heif-oxide's HEVC decoder (rust_h265) follows FFmpeg's (LGPL) code structure, and
|
||||
gamut-heic has no HEVC decoder of its own.
|
||||
- **HEVC patents are a distributor's call.** `lightcraft-codecs` uses this crate only behind its
|
||||
non-default `heif` feature; the apps forward it and the release packages enable it. 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.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
//! `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.
|
||||
//! `heic-rs`, a pure-Rust HEIF container and HEVC still-picture decoder, reads the container and
|
||||
//! reconstructs the coded pictures (bit-exact against libde265 on iPhone photos); this crate turns
|
||||
//! them into RGB itself ([`ycc`]) so the colours match libheif: it honours the HEVC VUI's range
|
||||
//! and matrix ([`vui`]) where heic-rs assumes BT.709 limited range, and upsamples chroma like
|
||||
//! libheif. Single pictures and grid-tiled photos, 8- to 16-bit, monochrome, alpha auxiliary
|
||||
//! images, ICC, EXIF and XMP. The 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. Shares heic-rs and its regression fixtures with PhotoCraft's `photocraft-heif`.
|
||||
//!
|
||||
//! 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.
|
||||
//! Auxiliary images other than alpha (HDR gain maps, depth maps) are ignored.
|
||||
//!
|
||||
//! Never panics: heic-rs is young, so every call into it runs under `catch_unwind` and a panic
|
||||
//! inside it becomes [`Error::Malformed`].
|
||||
|
|
@ -16,9 +19,20 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod orient;
|
||||
/// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`).
|
||||
#[cfg(any(test, feature = "testdata"))]
|
||||
#[doc(hidden)]
|
||||
pub mod testdata;
|
||||
mod vui;
|
||||
mod ycc;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use heic_rs::PixelLayout;
|
||||
use heic_rs::context::Context;
|
||||
use heic_rs::hevc::Frame;
|
||||
use heic_rs::props::ItemProps;
|
||||
use heic_rs::props::colr::Range;
|
||||
|
||||
/// What a HEIF file declares, read from the container alone (no pixel is decoded).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -31,8 +45,8 @@ pub struct Info {
|
|||
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,
|
||||
/// Bits per sample as coded (8 for most photos, 10 for HDR-capable ones).
|
||||
pub bit_depth: u8,
|
||||
}
|
||||
|
||||
/// Decode settings.
|
||||
|
|
@ -50,17 +64,28 @@ impl Default for Options {
|
|||
}
|
||||
}
|
||||
|
||||
/// A decoded image: interleaved RGB or RGBA samples, row-major, no padding. 16-bit samples are
|
||||
/// native-endian `u16`s.
|
||||
/// How the file describes its colour: the code points of ITU-T H.273, from the `colr` `nclx`
|
||||
/// box, else the HEVC VUI, else the standard defaults (2 = unspecified, limited range).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Colour {
|
||||
pub primaries: u16,
|
||||
pub transfer: u16,
|
||||
pub matrix: u16,
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
/// A decoded image: interleaved RGB or RGBA, 16 bits per sample whatever the coded depth,
|
||||
/// row-major, no padding.
|
||||
#[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>,
|
||||
/// Bits per sample as coded.
|
||||
pub bit_depth: u8,
|
||||
pub data: Vec<u16>,
|
||||
pub colour: Colour,
|
||||
/// 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
|
||||
|
|
@ -74,7 +99,7 @@ pub struct Decoded {
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// A valid file using something this decoder does not handle (image sequences, overlays…).
|
||||
Unsupported(String),
|
||||
Unsupported(&'static str),
|
||||
/// 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).
|
||||
|
|
@ -95,11 +120,15 @@ 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 {
|
||||
/// heic-rs 0.1.1 reconstructs 4:2:0 and monochrome pictures exactly but fails on the 4:2:2 and
|
||||
/// 4:4:4 ones some cameras write (Canon and Sony "HIF"); those failures are reported as this.
|
||||
const CHROMA_422_444: &str = "4:2:2 and 4:4:4 HEIF images (as some Canon and Sony cameras write) can't be decoded yet";
|
||||
|
||||
pub(crate) fn err(e: heic_rs::Error) -> Error {
|
||||
match e {
|
||||
heic_rs::Error::Unsupported(what) => Error::Unsupported(what.to_string()),
|
||||
heic_rs::Error::Unsupported(what) => Error::Unsupported(what),
|
||||
// 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::MissingBox("hvcC") => Error::Unsupported("the image is an overlay, an identity derivation or not HEVC-coded"),
|
||||
heic_rs::Error::PixelLimit { .. } | heic_rs::Error::BoxTooLarge { .. } => Error::Limit(e.to_string()),
|
||||
e => Error::Malformed(e.to_string()),
|
||||
}
|
||||
|
|
@ -113,7 +142,8 @@ fn guarded<T>(f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
|
|||
}
|
||||
|
||||
/// Reads the container's declared size, depth and alpha without decoding pixels, so callers can
|
||||
/// check their limits first.
|
||||
/// check their limits first. Also refuses a file cut short: every coded picture of the primary
|
||||
/// image must lie within `bytes`.
|
||||
pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
|
||||
guarded(|| probe_unguarded(bytes))
|
||||
}
|
||||
|
|
@ -121,16 +151,26 @@ pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
|
|||
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()),
|
||||
heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE),
|
||||
e => err(e),
|
||||
})?;
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
let id = ctx.meta.primary;
|
||||
let coded = match ctx.grid(id).map_err(err)? {
|
||||
Some((_, tiles)) => tiles,
|
||||
None => vec![id],
|
||||
};
|
||||
for item in coded {
|
||||
// Borrowed from `bytes` unless split across extents: cheap.
|
||||
ctx.item_data(item).map_err(err)?;
|
||||
}
|
||||
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,
|
||||
bit_depth: info.bit_depth,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -139,49 +179,186 @@ 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,
|
||||
/// Decodes the smallest thumbnail image the file carries whose long edge is at least
|
||||
/// `min_edge`, if any (iPhones store one of about 320 × 240). Transforms are applied as for the
|
||||
/// primary image; the metadata (and, when the thumbnail has no profile of its own, the ICC
|
||||
/// profile) are the primary image's.
|
||||
pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Result<Option<Decoded>, Error> {
|
||||
guarded(|| {
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
let primary = ctx.meta.primary;
|
||||
let mut best: Option<(u32, u32)> = None;
|
||||
for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) {
|
||||
let Ok(p) = ctx.props(r.from) else { continue };
|
||||
let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue };
|
||||
let edge = w.max(h);
|
||||
if edge >= min_edge && best.is_none_or(|(_, e)| edge < e) {
|
||||
best = Some((r.from, edge));
|
||||
}
|
||||
}
|
||||
let Some((id, _)) = best else { return Ok(None) };
|
||||
let mut decoded = decode_item(&ctx, id, options, false)?;
|
||||
if decoded.icc.is_none() {
|
||||
decoded.icc = item_icc(&ctx, primary);
|
||||
}
|
||||
(decoded.exif, decoded.xmp) = metadata(&ctx);
|
||||
Ok(Some(decoded))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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);
|
||||
};
|
||||
/// An item's ICC profile; a grid whose own properties carry none takes its first tile's (as on
|
||||
/// some iPhones).
|
||||
fn item_icc(ctx: &Context<'_>, id: u32) -> Option<Vec<u8>> {
|
||||
let p = ctx.props(id).ok()?;
|
||||
let icc = p.icc.or_else(|| {
|
||||
let (_, tiles) = ctx.grid(id).ok()??;
|
||||
ctx.props(*tiles.first()?).ok()?.icc
|
||||
});
|
||||
icc.map(<[u8]>::to_vec)
|
||||
}
|
||||
|
||||
fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
// Refuses sequences with a clear message before anything else is read.
|
||||
probe_unguarded(bytes)?;
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
let mut decoded = decode_item(&ctx, ctx.meta.primary, options, true)?;
|
||||
let (exif, xmp) = metadata(&ctx);
|
||||
decoded.exif = exif;
|
||||
decoded.xmp = xmp;
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// Decodes one image item (a coded picture or a grid of them) to RGB(A), alpha included when
|
||||
/// `with_alpha` and the item has an alpha auxiliary image.
|
||||
fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result<Decoded, Error> {
|
||||
let p = ctx.props(id).map_err(err)?;
|
||||
let limit = options.max_pixels;
|
||||
// The declared sizes are refused before anything is decoded or allocated.
|
||||
let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?;
|
||||
heic_rs::image::check_pixels(cw, ch, limit).map_err(err)?;
|
||||
let (tw, th) = heic_rs::transform::transformed_size(cw, ch, &p.transforms).map_err(err)?;
|
||||
heic_rs::image::check_pixels(tw, th, limit).map_err(err)?;
|
||||
|
||||
let coded = Coded::decode(ctx, id, &p, limit)?;
|
||||
let planes = coded.planes()?;
|
||||
let colour = colour(&p, &coded.first_props);
|
||||
let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range };
|
||||
let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None };
|
||||
let (w, h) = planes.size();
|
||||
let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest };
|
||||
let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode)
|
||||
.ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?;
|
||||
if let Some(aux) = alpha_id {
|
||||
let ap = ctx.props(aux).map_err(err)?;
|
||||
let alpha = Coded::decode(ctx, aux, &ap, limit)?;
|
||||
let alpha_planes = alpha.planes()?;
|
||||
if alpha_planes.size() != (w, h) {
|
||||
return Err(Error::Unsupported("an alpha image of a different size than the picture"));
|
||||
}
|
||||
ycc::put_alpha(&alpha_planes, &mut data).ok_or_else(|| Error::Malformed("the alpha image is incomplete".into()))?;
|
||||
}
|
||||
let bit_depth = planes.bit_depth();
|
||||
let has_alpha = alpha_id.is_some();
|
||||
let mut img = orient::Pixels { width: w, height: h, ch: if has_alpha { 4 } else { 3 }, data };
|
||||
if options.apply_transforms {
|
||||
img = orient::apply(img, &p.transforms)?;
|
||||
}
|
||||
Ok(Decoded {
|
||||
width: u32::try_from(img.width).map_err(|_| Error::Limit("image too wide".into()))?,
|
||||
height: u32::try_from(img.height).map_err(|_| Error::Limit("image too tall".into()))?,
|
||||
has_alpha,
|
||||
bit_depth,
|
||||
data: img.data,
|
||||
colour,
|
||||
// A grid whose own properties carry no profile takes its tiles' (as on some iPhones).
|
||||
icc: p.icc.or(coded.first_props.icc).map(<[u8]>::to_vec),
|
||||
exif: None,
|
||||
xmp: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
|
||||
struct Coded<'a> {
|
||||
frames: Vec<Frame>,
|
||||
grid: Option<heic_rs::grid::Grid>,
|
||||
/// The properties of the first coded picture (the item itself, or the first tile): where the
|
||||
/// HEVC configuration and its VUI live.
|
||||
first_props: ItemProps<'a>,
|
||||
/// A failed 4:2:2 / 4:4:4 picture, for the error message.
|
||||
chroma_format: u8,
|
||||
}
|
||||
|
||||
impl<'a> Coded<'a> {
|
||||
fn decode(ctx: &Context<'a>, id: u32, p: &ItemProps<'a>, limit: u64) -> Result<Coded<'a>, Error> {
|
||||
let (grid, ids) = match ctx.grid(id).map_err(err)? {
|
||||
Some((g, tiles)) => (Some(g), tiles),
|
||||
None => (None, vec![id]),
|
||||
};
|
||||
let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?;
|
||||
let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? };
|
||||
let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format);
|
||||
let decode_one = |t: &u32| -> Result<Frame, Error> {
|
||||
let tp = ctx.props(*t).map_err(err)?;
|
||||
let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?;
|
||||
let data = ctx.item_data(*t).map_err(err)?;
|
||||
let nals = hvcc.split_nals(&data).map_err(err)?;
|
||||
let params = hvcc.parameter_sets();
|
||||
if params.is_empty() {
|
||||
return Err(Error::Malformed("hvcC carries no parameter sets".into()));
|
||||
}
|
||||
let frame = heic_rs::hevc::decode_still(¶ms, &nals).map_err(err)?;
|
||||
frame.validate().map_err(err)?;
|
||||
heic_rs::image::check_pixels(frame.width, frame.height, limit).map_err(err)?;
|
||||
Ok(frame)
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let frames: Result<Vec<Frame>, Error> = {
|
||||
use rayon::prelude::*;
|
||||
ids.par_iter().map(decode_one).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let frames: Result<Vec<Frame>, Error> = ids.iter().map(decode_one).collect();
|
||||
let frames = frames.map_err(|e| match e {
|
||||
Error::Malformed(_) if chroma_format >= 2 => Error::Unsupported(CHROMA_422_444),
|
||||
e => e,
|
||||
})?;
|
||||
Ok(Coded { frames, grid, first_props, chroma_format })
|
||||
}
|
||||
|
||||
fn planes(&self) -> Result<ycc::Planes<'_>, Error> {
|
||||
match (&self.grid, self.frames.as_slice()) {
|
||||
(Some(g), tiles) => heic_rs::grid::Mosaic::new(g, tiles).map(ycc::Planes::Grid).map_err(err),
|
||||
(None, [frame]) => Ok(ycc::Planes::Picture(frame)),
|
||||
(None, _) => Err(Error::Malformed(format!("expected one coded picture (chroma format {})", self.chroma_format))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour description in force: `nclx` on the item (or, for a grid without one, on its
|
||||
/// first tile), else the VUI of its (first) coded picture, else the H.265 defaults.
|
||||
fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour {
|
||||
if let Some(n) = p.nclx.or(coded.nclx) {
|
||||
return Colour { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix_code, full_range: n.range == Range::Full };
|
||||
}
|
||||
let s = coded.hvcc.as_ref().and_then(|c| vui::signal(&c.parameter_sets())).unwrap_or_default();
|
||||
Colour { primaries: s.primaries.into(), transfer: s.transfer.into(), matrix: s.matrix.into(), full_range: s.full_range }
|
||||
}
|
||||
|
||||
/// The primary image's 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(ctx: &Context<'_>) -> (Option<Vec<u8>>, Option<String>) {
|
||||
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()))
|
||||
(exif, xmp.map(|x| x.trim_end_matches('\0').to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_synthetic;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -217,12 +394,13 @@ mod tests {
|
|||
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());
|
||||
assert!(decode_thumbnail(bytes, 1, &Options::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_display_their_kind() {
|
||||
assert!(Error::Unsupported("x".into()).to_string().contains("unsupported"));
|
||||
assert!(Error::Unsupported("x").to_string().contains("unsupported"));
|
||||
assert!(Error::Limit("x".into()).to_string().contains("limit"));
|
||||
assert!(Error::Malformed("x".into()).to_string().contains("malformed"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
//! The container's transformative properties (`clap` crop, `irot` rotation, `imir` mirror),
|
||||
//! applied to interleaved 16-bit pixels in the order the file associates them.
|
||||
//!
|
||||
//! `imir` follows libheif, which writes and reads `axis = 0` as a flip about the horizontal axis
|
||||
//! (top and bottom exchanged) and `axis = 1` as a left-right flip. heic-rs 0.1.1 names the coded
|
||||
//! values the other way round (`Mirror::LeftRight` is `axis = 0`), so they are swapped here.
|
||||
|
||||
use heic_rs::props::Transform;
|
||||
use heic_rs::props::simple::{Clap, Mirror, Rotation};
|
||||
|
||||
use crate::Error;
|
||||
|
||||
/// Interleaved samples, `ch` per pixel, row-major.
|
||||
pub struct Pixels {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub ch: usize,
|
||||
pub data: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Pixels {
|
||||
fn remap(&self, width: usize, height: usize, src: impl Fn(usize, usize) -> (usize, usize)) -> Pixels {
|
||||
let ch = self.ch;
|
||||
let mut data = vec![0u16; width * height * ch];
|
||||
for (y, row) in data.chunks_exact_mut(width * ch).enumerate() {
|
||||
for (x, px) in row.chunks_exact_mut(ch).enumerate() {
|
||||
let (sx, sy) = src(x, y);
|
||||
let i = (sy * self.width + sx) * ch;
|
||||
if let Some(s) = self.data.get(i..i + ch) {
|
||||
px.copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
Pixels { width, height, ch, data }
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangle `(left, top, width, height)` a `clap` selects from a `w` × `h` image. Its centre
|
||||
/// is `((w - 1) / 2 + horizOff, (h - 1) / 2 + vertOff)`; a corner that falls between two pixels
|
||||
/// is rounded as libheif does: left down, top up.
|
||||
pub fn crop_rect(w: usize, h: usize, c: Clap) -> Result<(usize, usize, usize, usize), Error> {
|
||||
let bad = || Error::Malformed("clap selects an impossible rectangle".into());
|
||||
let size = |(n, d): (u32, u32)| (d != 0).then(|| n / d).ok_or_else(bad);
|
||||
let (cw, ch) = (size(c.width)? as usize, size(c.height)? as usize);
|
||||
if cw == 0 || ch == 0 || cw > w || ch > h {
|
||||
return Err(bad());
|
||||
}
|
||||
// Twice the corner, so the half pixels of odd sizes stay exact.
|
||||
let twice = |full: usize, cropped: usize, (n, d): (i32, u32)| -> Result<i64, Error> {
|
||||
if d == 0 {
|
||||
return Err(bad());
|
||||
}
|
||||
Ok((full - cropped) as i64 + (2 * i64::from(n)).div_euclid(i64::from(d)))
|
||||
};
|
||||
let left = twice(w, cw, c.horiz_off)?.div_euclid(2);
|
||||
let top = (twice(h, ch, c.vert_off)? + 1).div_euclid(2);
|
||||
let left = usize::try_from(left).map_err(|_| bad())?;
|
||||
let top = usize::try_from(top).map_err(|_| bad())?;
|
||||
if left + cw > w || top + ch > h {
|
||||
return Err(bad());
|
||||
}
|
||||
Ok((left, top, cw, ch))
|
||||
}
|
||||
|
||||
/// Whether a `clap` among `transforms` (applied to a `w` × `h` image) starts at an odd column
|
||||
/// or row. libheif crops such images after upsampling their chroma bilinearly instead of with
|
||||
/// its default nearest neighbour; [`crate::ycc`] follows it so the pixels match.
|
||||
pub fn odd_crop(mut w: usize, mut h: usize, transforms: &[Transform]) -> Result<bool, Error> {
|
||||
for t in transforms {
|
||||
match *t {
|
||||
Transform::Crop(c) => {
|
||||
let (left, top, cw, ch) = crop_rect(w, h, c)?;
|
||||
if left % 2 == 1 || top % 2 == 1 {
|
||||
return Ok(true);
|
||||
}
|
||||
(w, h) = (cw, ch);
|
||||
}
|
||||
Transform::Rotate(Rotation::Ccw90 | Rotation::Ccw270) => (w, h) = (h, w),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn apply(mut img: Pixels, transforms: &[Transform]) -> Result<Pixels, Error> {
|
||||
for t in transforms {
|
||||
let (w, h) = (img.width, img.height);
|
||||
img = match *t {
|
||||
Transform::Crop(c) => {
|
||||
let (left, top, cw, ch) = crop_rect(w, h, c)?;
|
||||
img.remap(cw, ch, |x, y| (x + left, y + top))
|
||||
}
|
||||
// Counter-clockwise: the right column becomes the top row.
|
||||
Transform::Rotate(Rotation::Ccw90) => img.remap(h, w, |x, y| (w - 1 - y, x)),
|
||||
Transform::Rotate(Rotation::Ccw180) => img.remap(w, h, |x, y| (w - 1 - x, h - 1 - y)),
|
||||
Transform::Rotate(Rotation::Ccw270) => img.remap(h, w, |x, y| (y, h - 1 - x)),
|
||||
Transform::Rotate(Rotation::None) => img,
|
||||
// `axis = 0` (heic-rs's `LeftRight`): top and bottom exchanged, as libheif reads it.
|
||||
Transform::Mirror(Mirror::LeftRight) => img.remap(w, h, |x, y| (x, h - 1 - y)),
|
||||
// `axis = 1`: left and right exchanged.
|
||||
Transform::Mirror(Mirror::TopBottom) => img.remap(w, h, |x, y| (w - 1 - x, y)),
|
||||
};
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 3×2, one channel: 1 2 3 / 4 5 6.
|
||||
fn img() -> Pixels {
|
||||
Pixels { width: 3, height: 2, ch: 1, data: vec![1, 2, 3, 4, 5, 6] }
|
||||
}
|
||||
|
||||
fn run(t: Transform) -> (usize, usize, Vec<u16>) {
|
||||
let p = apply(img(), &[t]).unwrap();
|
||||
(p.width, p.height, p.data)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotations_are_counter_clockwise() {
|
||||
assert_eq!(run(Transform::Rotate(Rotation::Ccw90)), (2, 3, vec![3, 6, 2, 5, 1, 4]));
|
||||
assert_eq!(run(Transform::Rotate(Rotation::Ccw180)), (3, 2, vec![6, 5, 4, 3, 2, 1]));
|
||||
assert_eq!(run(Transform::Rotate(Rotation::Ccw270)), (2, 3, vec![4, 1, 5, 2, 6, 3]));
|
||||
assert_eq!(run(Transform::Rotate(Rotation::None)), (3, 2, vec![1, 2, 3, 4, 5, 6]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrors_follow_libheif() {
|
||||
// axis 0 (heic-rs `LeftRight`) exchanges top and bottom; axis 1 left and right.
|
||||
assert_eq!(run(Transform::Mirror(Mirror::LeftRight)), (3, 2, vec![4, 5, 6, 1, 2, 3]));
|
||||
assert_eq!(run(Transform::Mirror(Mirror::TopBottom)), (3, 2, vec![3, 2, 1, 6, 5, 4]));
|
||||
}
|
||||
|
||||
fn clap(w: u32, h: u32, ho: i32, vo: i32) -> Clap {
|
||||
Clap { width: (w, 1), height: (h, 1), horiz_off: (ho, 1), vert_off: (vo, 1) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_corners_round_like_libheif() {
|
||||
// Measured against libheif 1.23 on a 512 × 512 picture.
|
||||
assert_eq!(crop_rect(512, 512, clap(301, 201, 0, 0)).unwrap(), (105, 156, 301, 201));
|
||||
assert_eq!(crop_rect(512, 512, clap(300, 200, 0, 0)).unwrap(), (106, 156, 300, 200));
|
||||
assert_eq!(crop_rect(512, 512, clap(301, 201, 37, -21)).unwrap(), (142, 135, 301, 201));
|
||||
assert_eq!(crop_rect(512, 512, clap(301, 201, -3, 5)).unwrap(), (102, 161, 301, 201));
|
||||
assert_eq!(crop_rect(512, 512, clap(300, 201, 0, 0)).unwrap(), (106, 156, 300, 201));
|
||||
assert_eq!(crop_rect(1500, 1000, clap(1001, 701, 37, -21)).unwrap(), (286, 129, 1001, 701));
|
||||
let p = apply(img(), &[Transform::Crop(clap(2, 1, 1, 0))]).unwrap();
|
||||
// The half-pixel top rounds down a row.
|
||||
assert_eq!((p.width, p.height, p.data), (2, 1, vec![5, 6]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_claps_are_errors() {
|
||||
for c in [
|
||||
clap(0, 1, 0, 0),
|
||||
clap(4, 1, 0, 0),
|
||||
clap(3, 2, 1, 0),
|
||||
clap(3, 2, 0, -1),
|
||||
Clap { width: (1, 0), ..clap(1, 1, 0, 0) },
|
||||
Clap { horiz_off: (0, 0), ..clap(1, 1, 0, 0) },
|
||||
] {
|
||||
assert!(crop_rect(3, 2, c).is_err(), "{c:?}");
|
||||
}
|
||||
assert!(apply(img(), &[Transform::Crop(clap(9, 9, 0, 0))]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_crops_are_detected_through_rotations() {
|
||||
assert!(!odd_crop(512, 512, &[Transform::Crop(clap(300, 200, 0, 0))]).unwrap());
|
||||
assert!(odd_crop(512, 512, &[Transform::Crop(clap(301, 200, 0, 0))]).unwrap());
|
||||
// After a quarter turn the crop applies to a 200 × 300 image.
|
||||
let t = [Transform::Crop(clap(300, 200, 0, 0)), Transform::Rotate(Rotation::Ccw90), Transform::Crop(clap(197, 300, 0, 0))];
|
||||
assert!(odd_crop(512, 512, &t).unwrap());
|
||||
assert!(!odd_crop(512, 512, &[Transform::Rotate(Rotation::Ccw90)]).unwrap());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
//! Synthetic HEIF files for tests (feature `testdata`): lossless, so every decoded sample is known.
|
||||
//!
|
||||
//! Each coded picture is one 32 × 32 coding tree unit stored as PCM samples (H.265 7.3.8.7):
|
||||
//! the bitstream carries the YCbCr values verbatim, so the decoder's HEVC stage reproduces them
|
||||
//! exactly and a test checks everything after it (grid composition, colour, transforms,
|
||||
//! metadata) against values it chose. The few CABAC bins around the PCM data are written with a
|
||||
//! minimal arithmetic encoder (9.3.4.4). Larger images are grids of such pictures, which is how
|
||||
//! iPhones store photos too (with 512 × 512 tiles).
|
||||
//!
|
||||
//! Not a general HEIF or HEVC writer: 4:2:0 or monochrome, 8 or 10 bits, a CTU per picture.
|
||||
|
||||
/// Side of every coded picture.
|
||||
pub const TILE: u32 = 32;
|
||||
|
||||
/// YCbCr samples at a luma position (chroma is taken at even positions, 4:2:0).
|
||||
pub type Sampler<'a> = &'a dyn Fn(u32, u32) -> [u16; 3];
|
||||
|
||||
/// A property of the primary image.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Prop {
|
||||
/// `colr` of type `prof`.
|
||||
Icc(Vec<u8>),
|
||||
/// `colr` of type `nclx`: primaries, transfer, matrix, full range.
|
||||
Nclx(u16, u16, u16, bool),
|
||||
/// `irot`: quarter turns counter-clockwise.
|
||||
Irot(u8),
|
||||
/// `imir` with this axis.
|
||||
Imir(u8),
|
||||
/// `clap`: width, height, horizontal and vertical offset (whole pixels).
|
||||
Clap(u32, u32, i32, i32),
|
||||
}
|
||||
|
||||
/// What [`build`] writes.
|
||||
pub struct Spec<'a> {
|
||||
/// Displayed (grid output) size, before transforms.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub bit_depth: u8,
|
||||
pub sample: Sampler<'a>,
|
||||
/// Properties of the primary image, in association order.
|
||||
pub props: Vec<Prop>,
|
||||
/// EXIF as a TIFF structure (written with HEIF's 4-byte offset header).
|
||||
pub exif: Option<Vec<u8>>,
|
||||
/// An alpha auxiliary image (monochrome, full range).
|
||||
pub alpha: Option<&'a dyn Fn(u32, u32) -> u16>,
|
||||
/// A thumbnail item of half the size with these samples (in its own coordinates).
|
||||
pub thumbnail: Option<Sampler<'a>>,
|
||||
/// Auxiliary images of these types the decoder must ignore (HDR gain map, depth…).
|
||||
pub ignored_aux: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl<'a> Spec<'a> {
|
||||
pub fn new(width: u32, height: u32, sample: Sampler<'a>) -> Spec<'a> {
|
||||
Spec { width, height, bit_depth: 8, sample, props: Vec::new(), exif: None, alpha: None, thumbnail: None, ignored_aux: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Bits {
|
||||
bytes: Vec<u8>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Bits {
|
||||
fn put(&mut self, v: u32, len: u32) {
|
||||
for i in (0..len).rev() {
|
||||
if self.n.is_multiple_of(8) {
|
||||
self.bytes.push(0);
|
||||
}
|
||||
if (v >> i) & 1 == 1
|
||||
&& let Some(b) = self.bytes.last_mut()
|
||||
{
|
||||
*b |= 0x80 >> (self.n % 8);
|
||||
}
|
||||
self.n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn ue(&mut self, v: u32) {
|
||||
let x = u64::from(v) + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
self.put(0, len - 1);
|
||||
self.put((x & 0xffff_ffff) as u32, len);
|
||||
}
|
||||
|
||||
fn align_zero(&mut self) {
|
||||
while !self.n.is_multiple_of(8) {
|
||||
self.put(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn trailing(&mut self) {
|
||||
self.put(1, 1);
|
||||
self.align_zero();
|
||||
}
|
||||
}
|
||||
|
||||
/// The CABAC arithmetic encoder of H.265 9.3.4.4, for the bins these pictures need.
|
||||
struct Cabac {
|
||||
low: u32,
|
||||
range: u32,
|
||||
first: bool,
|
||||
outstanding: u32,
|
||||
}
|
||||
|
||||
impl Cabac {
|
||||
fn new() -> Cabac {
|
||||
Cabac { low: 0, range: 510, first: true, outstanding: 0 }
|
||||
}
|
||||
|
||||
fn put_bit(&mut self, out: &mut Bits, b: u32) {
|
||||
if self.first {
|
||||
self.first = false;
|
||||
} else {
|
||||
out.put(b, 1);
|
||||
}
|
||||
while self.outstanding > 0 {
|
||||
out.put(1 - b, 1);
|
||||
self.outstanding -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn renorm(&mut self, out: &mut Bits) {
|
||||
while self.range < 256 {
|
||||
if self.low < 256 {
|
||||
self.put_bit(out, 0);
|
||||
} else if self.low >= 512 {
|
||||
self.low -= 512;
|
||||
self.put_bit(out, 1);
|
||||
} else {
|
||||
self.low -= 256;
|
||||
self.outstanding += 1;
|
||||
}
|
||||
self.range <<= 1;
|
||||
self.low <<= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `part_mode` = PART_2Nx2N: the most probable symbol of a fresh context (initValue 184 at
|
||||
/// QP 26 gives pStateIdx 0, valMps 1), so the LPS range is `rangeTabLps[0][3]` = 240.
|
||||
fn part_mode_2nx2n(&mut self, out: &mut Bits) {
|
||||
let lps = [128, 176, 208, 240][((self.range >> 6) & 3) as usize];
|
||||
self.range -= lps;
|
||||
self.renorm(out);
|
||||
}
|
||||
|
||||
/// A terminating bin equal to 1 (`pcm_flag`, `end_of_slice_segment_flag`), with the flush.
|
||||
fn terminate_one(&mut self, out: &mut Bits) {
|
||||
self.range -= 2;
|
||||
self.low += self.range;
|
||||
self.range = 2;
|
||||
self.renorm(out);
|
||||
self.put_bit(out, (self.low >> 9) & 1);
|
||||
out.put(((self.low >> 7) & 3) | 1, 2);
|
||||
}
|
||||
}
|
||||
|
||||
fn nal(kind: u8, rbsp: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![kind << 1, 1];
|
||||
let mut zeros = 0;
|
||||
for &b in rbsp {
|
||||
if zeros >= 2 && b <= 3 {
|
||||
out.push(3);
|
||||
zeros = 0;
|
||||
}
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
out.push(b);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn profile_idc(bit_depth: u8, mono: bool) -> u32 {
|
||||
match (bit_depth, mono) {
|
||||
(_, true) => 4, // range extensions
|
||||
(8, false) => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn ptl(w: &mut Bits, idc: u32) {
|
||||
w.put(0, 2);
|
||||
w.put(0, 1);
|
||||
w.put(idc, 5);
|
||||
w.put(1 << (31 - idc), 32); // compatible with itself
|
||||
w.put(0b1001, 4); // progressive, frame only
|
||||
w.put(0, 22);
|
||||
w.put(0, 21);
|
||||
w.put(0, 1);
|
||||
w.put(93, 8); // level 3.1
|
||||
}
|
||||
|
||||
fn parameter_sets(bit_depth: u8, mono: bool) -> [Vec<u8>; 3] {
|
||||
let idc = profile_idc(bit_depth, mono);
|
||||
let bd = u32::from(bit_depth);
|
||||
let mut v = Bits::default();
|
||||
v.put(0, 4);
|
||||
v.put(3, 2);
|
||||
v.put(0, 6);
|
||||
v.put(0, 3);
|
||||
v.put(1, 1);
|
||||
v.put(0xffff, 16);
|
||||
ptl(&mut v, idc);
|
||||
v.put(0, 1); // vps_sub_layer_ordering_info_present_flag
|
||||
v.ue(0);
|
||||
v.ue(0);
|
||||
v.ue(0);
|
||||
v.put(0, 6); // vps_max_layer_id
|
||||
v.ue(0); // vps_num_layer_sets_minus1
|
||||
v.put(0, 1); // vps_timing_info_present_flag
|
||||
v.put(0, 1); // vps_extension_flag
|
||||
v.trailing();
|
||||
|
||||
let mut s = Bits::default();
|
||||
s.put(0, 4);
|
||||
s.put(0, 3);
|
||||
s.put(1, 1);
|
||||
ptl(&mut s, idc);
|
||||
s.ue(0);
|
||||
s.ue(if mono { 0 } else { 1 });
|
||||
s.ue(TILE);
|
||||
s.ue(TILE);
|
||||
s.put(0, 1); // conformance_window_flag
|
||||
s.ue(bd - 8);
|
||||
s.ue(bd - 8);
|
||||
s.ue(4);
|
||||
s.put(1, 1);
|
||||
s.ue(0);
|
||||
s.ue(0);
|
||||
s.ue(0);
|
||||
s.ue(2); // log2_min_luma_coding_block_size_minus3: 32
|
||||
s.ue(0); // log2_diff_max_min_luma_coding_block_size: CTB 32
|
||||
s.ue(0); // log2_min_luma_transform_block_size_minus2: 4
|
||||
s.ue(3); // log2_diff_max_min_luma_transform_block_size: 32
|
||||
s.ue(0);
|
||||
s.ue(0);
|
||||
s.put(0, 1); // scaling_list_enabled_flag
|
||||
s.put(0, 1); // amp_enabled_flag
|
||||
s.put(0, 1); // sample_adaptive_offset_enabled_flag
|
||||
s.put(1, 1); // pcm_enabled_flag
|
||||
s.put(bd - 1, 4); // pcm_sample_bit_depth_luma_minus1
|
||||
s.put(bd - 1, 4); // pcm_sample_bit_depth_chroma_minus1
|
||||
s.ue(2); // log2_min_pcm_luma_coding_block_size_minus3: 32
|
||||
s.ue(0); // log2_diff_max_min_pcm_luma_coding_block_size
|
||||
s.put(1, 1); // pcm_loop_filter_disabled_flag
|
||||
s.ue(0); // num_short_term_ref_pic_sets
|
||||
s.put(0, 1); // long_term_ref_pics_present_flag
|
||||
s.put(0, 1); // sps_temporal_mvp_enabled_flag
|
||||
s.put(0, 1); // strong_intra_smoothing_enabled_flag
|
||||
s.put(0, 1); // vui_parameters_present_flag
|
||||
s.put(0, 1); // sps_extension_present_flag
|
||||
s.trailing();
|
||||
|
||||
let mut p = Bits::default();
|
||||
p.ue(0);
|
||||
p.ue(0);
|
||||
p.put(0, 6); // dependent slices, output flag, 3 extra header bits, sign hiding
|
||||
p.put(0, 1); // cabac_init_present_flag
|
||||
p.ue(0);
|
||||
p.ue(0);
|
||||
p.ue(0); // init_qp_minus26 (se(0) is the same bits)
|
||||
p.put(0, 3); // constrained intra, transform skip, cu_qp_delta
|
||||
p.ue(0);
|
||||
p.ue(0);
|
||||
p.put(0, 1);
|
||||
p.put(0, 2);
|
||||
p.put(0, 1); // transquant_bypass_enabled_flag
|
||||
p.put(0, 1); // tiles_enabled_flag
|
||||
p.put(0, 1); // entropy_coding_sync_enabled_flag
|
||||
p.put(0, 1); // pps_loop_filter_across_slices_enabled_flag
|
||||
p.put(1, 1); // deblocking_filter_control_present_flag
|
||||
p.put(0, 1); // deblocking_filter_override_enabled_flag
|
||||
p.put(1, 1); // pps_deblocking_filter_disabled_flag
|
||||
p.put(0, 1);
|
||||
p.put(0, 1);
|
||||
p.ue(0);
|
||||
p.put(0, 1);
|
||||
p.put(0, 1);
|
||||
p.trailing();
|
||||
[nal(32, &v.bytes), nal(33, &s.bytes), nal(34, &p.bytes)]
|
||||
}
|
||||
|
||||
/// One coded 32 × 32 picture: the `hvcC` payload and the item data (length-prefixed NAL).
|
||||
struct Coded {
|
||||
hvcc: Vec<u8>,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
fn picture(bit_depth: u8, mono: bool, sample: &dyn Fn(u32, u32) -> [u16; 3]) -> Coded {
|
||||
let bd = u32::from(bit_depth);
|
||||
let mut w = Bits::default();
|
||||
w.put(1, 1); // first_slice_segment_in_pic_flag
|
||||
w.put(0, 1); // no_output_of_prior_pics_flag
|
||||
w.ue(0);
|
||||
w.ue(2); // I slice
|
||||
w.ue(0); // slice_qp_delta, se(0)
|
||||
w.trailing(); // byte_alignment()
|
||||
let mut c = Cabac::new();
|
||||
c.part_mode_2nx2n(&mut w);
|
||||
c.terminate_one(&mut w); // pcm_flag
|
||||
w.align_zero(); // pcm_alignment_zero_bit
|
||||
for y in 0..TILE {
|
||||
for x in 0..TILE {
|
||||
w.put(u32::from(sample(x, y)[0]), bd);
|
||||
}
|
||||
}
|
||||
if !mono {
|
||||
for k in 1..3 {
|
||||
for y in 0..TILE / 2 {
|
||||
for x in 0..TILE / 2 {
|
||||
w.put(u32::from(sample(2 * x, 2 * y)[k]), bd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut c = Cabac::new();
|
||||
c.terminate_one(&mut w); // end_of_slice_segment_flag (its flush ends in the stop bit)
|
||||
w.align_zero();
|
||||
let slice = nal(19, &w.bytes);
|
||||
|
||||
let sets = parameter_sets(bit_depth, mono);
|
||||
let mut h = vec![1u8, profile_idc(bit_depth, mono) as u8];
|
||||
h.extend_from_slice(&(1u32 << (31 - profile_idc(bit_depth, mono))).to_be_bytes());
|
||||
h.extend_from_slice(&[
|
||||
0x90,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
93,
|
||||
0xf0,
|
||||
0,
|
||||
0xfc,
|
||||
0xfc | u8::from(!mono),
|
||||
0xf8 | (bit_depth - 8),
|
||||
0xf8 | (bit_depth - 8),
|
||||
0,
|
||||
0,
|
||||
0x0f,
|
||||
3,
|
||||
]);
|
||||
for (kind, set) in [32u8, 33, 34].into_iter().zip(&sets) {
|
||||
h.push(0x80 | kind);
|
||||
h.extend_from_slice(&1u16.to_be_bytes());
|
||||
h.extend_from_slice(&(set.len() as u16).to_be_bytes());
|
||||
h.extend_from_slice(set);
|
||||
}
|
||||
let mut data = (slice.len() as u32).to_be_bytes().to_vec();
|
||||
data.extend_from_slice(&slice);
|
||||
Coded { hvcc: h, data }
|
||||
}
|
||||
|
||||
fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
|
||||
v.extend_from_slice(kind);
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
fn full_box(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut p = vec![version, 0, 0, 0];
|
||||
p.extend_from_slice(payload);
|
||||
bx(kind, &p)
|
||||
}
|
||||
|
||||
struct Item {
|
||||
id: u16,
|
||||
kind: &'static [u8; 4],
|
||||
data: Vec<u8>,
|
||||
/// Indices (1-based) into the property list, with the essential flag.
|
||||
props: Vec<(u8, bool)>,
|
||||
/// Not an image of its own (a grid tile).
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct File {
|
||||
items: Vec<Item>,
|
||||
props: Vec<Vec<u8>>,
|
||||
refs: Vec<(&'static [u8; 4], u16, Vec<u16>)>,
|
||||
}
|
||||
|
||||
impl File {
|
||||
fn prop(&mut self, b: Vec<u8>) -> u8 {
|
||||
self.props.push(b);
|
||||
self.props.len() as u8
|
||||
}
|
||||
|
||||
fn add(&mut self, kind: &'static [u8; 4], data: Vec<u8>, props: Vec<(u8, bool)>) -> u16 {
|
||||
let id = self.items.len() as u16 + 1;
|
||||
self.items.push(Item { id, kind, data, props, hidden: false });
|
||||
id
|
||||
}
|
||||
|
||||
/// A coded image of `w` × `h` (a grid when larger than a tile); returns its item id.
|
||||
fn image(&mut self, w: u32, h: u32, bit_depth: u8, mono: bool, sample: &dyn Fn(u32, u32) -> [u16; 3]) -> u16 {
|
||||
let (cols, rows) = (w.div_ceil(TILE), h.div_ceil(TILE));
|
||||
let ispe = |w: u32, h: u32| {
|
||||
let mut p = w.to_be_bytes().to_vec();
|
||||
p.extend_from_slice(&h.to_be_bytes());
|
||||
full_box(b"ispe", 0, &p)
|
||||
};
|
||||
let mut hvcc_prop = None;
|
||||
let tile_ispe = self.prop(ispe(TILE, TILE));
|
||||
let mut tiles = Vec::new();
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let coded = picture(bit_depth, mono, &|x, y| sample((c * TILE + x).min(w - 1), (r * TILE + y).min(h - 1)));
|
||||
let hv = *hvcc_prop.get_or_insert_with(|| self.prop(bx(b"hvcC", &coded.hvcc)));
|
||||
tiles.push(self.add(b"hvc1", coded.data, vec![(hv, true), (tile_ispe, false)]));
|
||||
}
|
||||
}
|
||||
if let ([only], true) = (tiles.as_slice(), w == TILE && h == TILE) {
|
||||
return *only;
|
||||
}
|
||||
for it in self.items.iter_mut().filter(|i| tiles.contains(&i.id)) {
|
||||
it.hidden = true;
|
||||
}
|
||||
let mut g = vec![0u8, 0, (rows - 1) as u8, (cols - 1) as u8];
|
||||
g.extend_from_slice(&(w as u16).to_be_bytes());
|
||||
g.extend_from_slice(&(h as u16).to_be_bytes());
|
||||
let grid_ispe = self.prop(ispe(w, h));
|
||||
let id = self.add(b"grid", g, vec![(grid_ispe, false)]);
|
||||
self.refs.push((b"dimg", id, tiles));
|
||||
id
|
||||
}
|
||||
|
||||
fn write(self, primary: u16) -> Vec<u8> {
|
||||
let ftyp = bx(b"ftyp", b"heic\0\0\0\0mif1heic");
|
||||
let mut hdlr = vec![0u8; 4];
|
||||
hdlr.extend_from_slice(b"pict");
|
||||
hdlr.extend_from_slice(&[0; 13]);
|
||||
let hdlr = full_box(b"hdlr", 0, &hdlr);
|
||||
let pitm = full_box(b"pitm", 0, &primary.to_be_bytes());
|
||||
let mut iinf = (self.items.len() as u16).to_be_bytes().to_vec();
|
||||
for it in &self.items {
|
||||
let mut e = vec![2, 0, 0, u8::from(it.hidden)]; // version 2, flags
|
||||
e.extend_from_slice(&it.id.to_be_bytes());
|
||||
e.extend_from_slice(&[0, 0]);
|
||||
e.extend_from_slice(it.kind);
|
||||
e.push(0);
|
||||
iinf.extend_from_slice(&bx(b"infe", &e));
|
||||
}
|
||||
let iinf = full_box(b"iinf", 0, &iinf);
|
||||
let mut iref = Vec::new();
|
||||
for (kind, from, to) in &self.refs {
|
||||
let mut r = from.to_be_bytes().to_vec();
|
||||
r.extend_from_slice(&(to.len() as u16).to_be_bytes());
|
||||
for t in to {
|
||||
r.extend_from_slice(&t.to_be_bytes());
|
||||
}
|
||||
iref.extend_from_slice(&bx(kind, &r));
|
||||
}
|
||||
let iref = full_box(b"iref", 0, &iref);
|
||||
let ipco = bx(b"ipco", &self.props.concat());
|
||||
let mut ipma = (self.items.iter().filter(|i| !i.props.is_empty()).count() as u32).to_be_bytes().to_vec();
|
||||
for it in self.items.iter().filter(|i| !i.props.is_empty()) {
|
||||
ipma.extend_from_slice(&it.id.to_be_bytes());
|
||||
ipma.push(it.props.len() as u8);
|
||||
ipma.extend(it.props.iter().map(|&(i, essential)| i | if essential { 0x80 } else { 0 }));
|
||||
}
|
||||
let iprp = bx(b"iprp", &[ipco, full_box(b"ipma", 0, &ipma)].concat());
|
||||
let iloc = |base: u32| {
|
||||
let mut l = vec![0x44, 0x00];
|
||||
l.extend_from_slice(&(self.items.len() as u16).to_be_bytes());
|
||||
let mut offset = base;
|
||||
for it in &self.items {
|
||||
l.extend_from_slice(&it.id.to_be_bytes());
|
||||
l.extend_from_slice(&[0, 0, 0, 0, 0, 1]); // construction method 0, data ref 0, one extent
|
||||
l.extend_from_slice(&offset.to_be_bytes());
|
||||
l.extend_from_slice(&(it.data.len() as u32).to_be_bytes());
|
||||
offset += it.data.len() as u32;
|
||||
}
|
||||
full_box(b"iloc", 1, &l)
|
||||
};
|
||||
let meta = |base| full_box(b"meta", 0, &[hdlr.clone(), pitm.clone(), iloc(base), iinf.clone(), iref.clone(), iprp.clone()].concat());
|
||||
let start = (ftyp.len() + meta(0).len() + 8) as u32;
|
||||
let mdat = bx(b"mdat", &self.items.iter().flat_map(|i| i.data.iter().copied()).collect::<Vec<u8>>());
|
||||
[ftyp, meta(start), mdat].concat()
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the HEIF file `spec` describes.
|
||||
pub fn build(spec: &Spec<'_>) -> Vec<u8> {
|
||||
let mut f = File::default();
|
||||
let primary = f.image(spec.width, spec.height, spec.bit_depth, false, spec.sample);
|
||||
let mut assoc = Vec::new();
|
||||
for p in &spec.props {
|
||||
let b = match p {
|
||||
Prop::Icc(icc) => bx(b"colr", &[b"prof".as_slice(), icc].concat()),
|
||||
Prop::Nclx(pr, tc, mc, full) => {
|
||||
let mut c = b"nclx".to_vec();
|
||||
for v in [pr, tc, mc] {
|
||||
c.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
c.push(if *full { 0x80 } else { 0 });
|
||||
bx(b"colr", &c)
|
||||
}
|
||||
Prop::Irot(a) => bx(b"irot", &[a & 3]),
|
||||
Prop::Imir(a) => bx(b"imir", &[a & 1]),
|
||||
Prop::Clap(w, h, ho, vo) => {
|
||||
let mut c = Vec::new();
|
||||
for v in [*w as i32, 1, *h as i32, 1, *ho, 1, *vo, 1] {
|
||||
c.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
bx(b"clap", &c)
|
||||
}
|
||||
};
|
||||
assoc.push((f.prop(b), true, matches!(p, Prop::Clap(..))));
|
||||
}
|
||||
if let Some(it) = f.items.iter_mut().find(|i| i.id == primary) {
|
||||
it.props.extend(assoc.iter().map(|&(i, e, _)| (i, e)));
|
||||
}
|
||||
if let Some(exif) = &spec.exif {
|
||||
let id = f.add(b"Exif", [&[0u8, 0, 0, 0][..], exif].concat(), Vec::new());
|
||||
f.refs.push((b"cdsc", id, vec![primary]));
|
||||
}
|
||||
let bd = spec.bit_depth;
|
||||
let aux = |f: &mut File, urn: &str, sample: &dyn Fn(u32, u32) -> [u16; 3]| {
|
||||
let id = f.image(spec.width, spec.height, bd, true, sample);
|
||||
let auxc = f.prop(full_box(b"auxC", 0, &[urn.as_bytes(), &[0]].concat()));
|
||||
if let Some(it) = f.items.iter_mut().find(|i| i.id == id) {
|
||||
it.props.push((auxc, true));
|
||||
}
|
||||
f.refs.push((b"auxl", id, vec![primary]));
|
||||
};
|
||||
if let Some(alpha) = spec.alpha {
|
||||
aux(&mut f, "urn:mpeg:hevc:2015:auxid:1", &|x, y| [alpha(x, y), 0, 0]);
|
||||
}
|
||||
for urn in &spec.ignored_aux {
|
||||
aux(&mut f, urn, &|x, y| [((x + y) % 256) as u16, 0, 0]);
|
||||
}
|
||||
if let Some(sample) = spec.thumbnail {
|
||||
// Half the size, with the primary image's colour and orientation (not its crop).
|
||||
let id = f.image(spec.width / 2, spec.height / 2, spec.bit_depth, false, sample);
|
||||
if let Some(it) = f.items.iter_mut().find(|i| i.id == id) {
|
||||
it.props.extend(assoc.iter().filter(|a| !a.2).map(|&(i, e, _)| (i, e)));
|
||||
}
|
||||
f.refs.push((b"thmb", id, vec![primary]));
|
||||
}
|
||||
f.write(primary)
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
//! Decoding lossless synthetic HEIF files ([`crate::testdata`]): every expected sample is known.
|
||||
|
||||
use crate::testdata::{Prop, Spec, build};
|
||||
use crate::{Error, Options, decode, decode_thumbnail, probe};
|
||||
|
||||
/// A luma value unique enough to identify its position; chroma neutral.
|
||||
fn luma(x: u32, y: u32) -> u16 {
|
||||
((x * 7 + y * 13) % 220 + 20) as u16
|
||||
}
|
||||
|
||||
fn grey(x: u32, y: u32) -> [u16; 3] {
|
||||
[luma(x, y), 128, 128]
|
||||
}
|
||||
|
||||
const FULL_601: Prop = Prop::Nclx(1, 13, 6, true);
|
||||
|
||||
/// Decoded red channel at `(x, y)`, back in 8-bit code values.
|
||||
fn at(d: &crate::Decoded, x: u32, y: u32) -> u16 {
|
||||
let ch = if d.has_alpha { 4 } else { 3 };
|
||||
d.data[((y * d.width + x) as usize) * ch] / 257
|
||||
}
|
||||
|
||||
fn decoded(spec: &Spec) -> crate::Decoded {
|
||||
decode(&build(spec), &Options::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_reassembles_exactly() {
|
||||
// 4 × 3 tiles of 32, cropped by the grid to 100 × 70.
|
||||
let d = decoded(&Spec { props: vec![FULL_601], ..Spec::new(100, 70, &grey) });
|
||||
assert_eq!((d.width, d.height, d.bit_depth, d.has_alpha), (100, 70, 8, false));
|
||||
for y in 0..70 {
|
||||
for x in 0..100 {
|
||||
let px = &d.data[((y * 100 + x) * 3) as usize..][..3];
|
||||
assert_eq!(px, [luma(x, y) * 257; 3], "({x}, {y})");
|
||||
}
|
||||
}
|
||||
let info = probe(&build(&Spec::new(100, 70, &grey))).unwrap();
|
||||
assert_eq!((info.width, info.height, info.coded_width, info.coded_height, info.bit_depth), (100, 70, 100, 70, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_follows_the_matrix_and_range() {
|
||||
// BT.601 full range, a saturated orange: within one code value of the exact conversion.
|
||||
let (y, cb, cr) = (150.0f32, 80.0, 190.0);
|
||||
let s = |_: u32, _: u32| [y as u16, cb as u16, cr as u16];
|
||||
let d = decoded(&Spec { props: vec![FULL_601], ..Spec::new(32, 32, &s) });
|
||||
let (u, v) = (cb - 128.0, cr - 128.0);
|
||||
let expected = [y + 1.402 * v, y - 0.344_136 * u - 0.714_136 * v, y + 1.772 * u].map(|c| c.clamp(0.0, 255.0));
|
||||
for (got, want) in d.data[..3].iter().zip(expected) {
|
||||
assert!((f32::from(*got) / 257.0 - want).abs() <= 1.0, "{:?} vs {expected:?}", &d.data[..3]);
|
||||
}
|
||||
assert_eq!(d.colour, crate::Colour { primaries: 1, transfer: 13, matrix: 6, full_range: true });
|
||||
|
||||
// No nclx and no VUI: the H.265 defaults, limited range (16 → black, 235 → white).
|
||||
let ends = |x: u32, _: u32| [if x < 16 { 16 } else { 235 }, 128, 128];
|
||||
let d = decoded(&Spec::new(32, 32, &ends));
|
||||
assert!(!d.colour.full_range);
|
||||
assert_eq!((d.data[0], d.data[20 * 3]), (0, 65535));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ten_bit_samples_keep_their_precision() {
|
||||
let s = |x: u32, y: u32| [(x * 31 + y) as u16 % 1024, 512, 512];
|
||||
let d = decoded(&Spec { bit_depth: 10, props: vec![FULL_601], ..Spec::new(64, 32, &s) });
|
||||
assert_eq!(d.bit_depth, 10);
|
||||
for x in [0, 1, 5, 33, 63] {
|
||||
let want = ((x * 31) % 1024) as f64 / 1023.0 * 65535.0;
|
||||
assert!((f64::from(d.data[(x * 3) as usize]) - want).abs() <= 1.0, "x {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_transforms_are_applied_like_libheif() {
|
||||
let (w, h) = (70u32, 40u32);
|
||||
let spec = |props: Vec<Prop>| Spec { props: [vec![FULL_601], props].concat(), ..Spec::new(w, h, &grey) };
|
||||
// irot: counter-clockwise quarter turns.
|
||||
let d = decoded(&spec(vec![Prop::Irot(1)]));
|
||||
assert_eq!((d.width, d.height), (h, w));
|
||||
assert_eq!(at(&d, 0, 0), luma(w - 1, 0)); // the right column becomes the top row
|
||||
assert_eq!(at(&d, h - 1, w - 1), luma(0, h - 1));
|
||||
let d = decoded(&spec(vec![Prop::Irot(2)]));
|
||||
assert_eq!(at(&d, 0, 0), luma(w - 1, h - 1));
|
||||
let d = decoded(&spec(vec![Prop::Irot(3)]));
|
||||
assert_eq!(at(&d, 0, 0), luma(0, h - 1));
|
||||
// imir: axis 0 exchanges top and bottom, axis 1 left and right (libheif's reading).
|
||||
let d = decoded(&spec(vec![Prop::Imir(0)]));
|
||||
assert_eq!((at(&d, 0, 0), at(&d, 5, 0)), (luma(0, h - 1), luma(5, h - 1)));
|
||||
let d = decoded(&spec(vec![Prop::Imir(1)]));
|
||||
assert_eq!((at(&d, 0, 0), at(&d, 0, 5)), (luma(w - 1, 0), luma(w - 1, 5)));
|
||||
// clap: centred crop moved by its offsets; a half-pixel corner goes left and down.
|
||||
let d = decoded(&spec(vec![Prop::Clap(31, 21, 3, -2)]));
|
||||
assert_eq!((d.width, d.height), (31, 21));
|
||||
// left = (70 - 31 + 6) / 2 = 22.5 → 22, top = (40 - 21 - 4) / 2 = 7.5 → 8
|
||||
assert_eq!(at(&d, 0, 0), luma(22, 8));
|
||||
// In association order: crop, then turn, then mirror.
|
||||
let d = decoded(&spec(vec![Prop::Clap(30, 20, 0, 0), Prop::Irot(1), Prop::Imir(1)]));
|
||||
assert_eq!((d.width, d.height), (20, 30));
|
||||
// The crop is (20, 10) to (49, 29); the turn brings its bottom-right corner to the top-right,
|
||||
// the left-right mirror to the top-left.
|
||||
assert_eq!(at(&d, 0, 0), luma(49, 29));
|
||||
let info = probe(&build(&spec(vec![Prop::Irot(1)]))).unwrap();
|
||||
assert_eq!((info.width, info.height, info.coded_width, info.coded_height), (h, w, w, h));
|
||||
// Transforms off: as coded.
|
||||
let d = decode(&build(&spec(vec![Prop::Irot(1)])), &Options { apply_transforms: false, ..Default::default() }).unwrap();
|
||||
assert_eq!((d.width, d.height, at(&d, 3, 2)), (w, h, luma(3, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_aux_images_and_thumbnails() {
|
||||
let icc = b"not parsed here: carried verbatim".to_vec();
|
||||
let exif = b"MM\0\x2a\0\0\0\x08\0\0".to_vec();
|
||||
let spec = Spec {
|
||||
props: vec![Prop::Icc(icc.clone()), FULL_601],
|
||||
exif: Some(exif.clone()),
|
||||
thumbnail: Some(&|_, _| [77, 128, 128]),
|
||||
ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap", "urn:mpeg:hevc:2015:auxid:2"],
|
||||
..Spec::new(64, 64, &grey)
|
||||
};
|
||||
let bytes = build(&spec);
|
||||
let d = decode(&bytes, &Options::default()).unwrap();
|
||||
assert_eq!((d.width, d.height, d.has_alpha), (64, 64, false));
|
||||
assert_eq!(d.icc.as_deref(), Some(icc.as_slice()));
|
||||
assert_eq!(d.exif.as_deref(), Some(exif.as_slice()));
|
||||
let t = decode_thumbnail(&bytes, 32, &Options::default()).unwrap().unwrap();
|
||||
assert_eq!((t.width, t.height, t.data[0]), (32, 32, 77 * 257));
|
||||
assert_eq!(t.exif.as_deref(), Some(exif.as_slice()));
|
||||
assert_eq!(t.icc.as_deref(), Some(icc.as_slice()));
|
||||
assert!(decode_thumbnail(&bytes, 33, &Options::default()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_comes_from_its_auxiliary_image() {
|
||||
let alpha = |x: u32, _: u32| (x * 4) as u16;
|
||||
let d = decoded(&Spec { props: vec![FULL_601], alpha: Some(&alpha), ..Spec::new(64, 32, &grey) });
|
||||
assert!(d.has_alpha);
|
||||
assert_eq!(d.data.len(), 64 * 32 * 4);
|
||||
assert_eq!((d.data[3], d.data[10 * 4 + 3]), (0, 40 * 257));
|
||||
assert_eq!(d.data[..3], [luma(0, 0) * 257; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_are_checked_before_decoding() {
|
||||
let bytes = build(&Spec::new(96, 64, &grey));
|
||||
let r = decode(&bytes, &Options { max_pixels: 96 * 64 - 1, ..Default::default() });
|
||||
assert!(matches!(r, Err(Error::Limit(_))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_corrupted_files_are_errors_not_panics() {
|
||||
let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) });
|
||||
let full = decode(&bytes, &Options::default()).unwrap();
|
||||
for cut in (0..bytes.len()).step_by(7) {
|
||||
let b = &bytes[..cut];
|
||||
// Cut inside the photo: an error. Cut in the trailing thumbnail: the whole photo.
|
||||
if let (Ok(_), Ok(d)) = (probe(b), decode(b, &Options::default())) {
|
||||
assert_eq!(d.data, full.data, "cut at {cut} of {}", bytes.len());
|
||||
}
|
||||
let _ = decode_thumbnail(b, 1, &Options::default());
|
||||
}
|
||||
// Flip bits all over the file: any outcome but a panic.
|
||||
let mut state = 0x2545_f491_u32;
|
||||
for _ in 0..400 {
|
||||
let mut b = bytes.clone();
|
||||
for _ in 0..3 {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
let i = state as usize % b.len();
|
||||
b[i] ^= 1 << (state % 8);
|
||||
}
|
||||
let _ = probe(&b);
|
||||
let _ = decode(&b, &Options::default());
|
||||
let _ = decode_thumbnail(&b, 1, &Options::default());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
//! The colour description in an HEVC sequence parameter set's VUI (Rec. ITU-T H.265, 7.3.2.2 and
|
||||
//! Annex E): the fallback a HEIF reader uses when the item carries no `colr` box of type `nclx`.
|
||||
//! iPhone photos are the common case: they label their colour with an ICC profile only and say in
|
||||
//! the VUI that their YCbCr is full range.
|
||||
//!
|
||||
//! Only the syntax up to `matrix_coeffs` is read. Anything this parser can't follow returns `None`
|
||||
//! and the caller falls back to the H.265 defaults; it never fails a decode.
|
||||
|
||||
/// What the VUI's `video_signal_type` says (H.265 E.3.1). Absent fields keep the defaults the
|
||||
/// standard infers: limited range, every code point 2 ("unspecified").
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Signal {
|
||||
pub full_range: bool,
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
}
|
||||
|
||||
impl Default for Signal {
|
||||
fn default() -> Self {
|
||||
Signal { full_range: false, primaries: 2, transfer: 2, matrix: 2 }
|
||||
}
|
||||
}
|
||||
|
||||
/// NAL unit type of a sequence parameter set.
|
||||
const SPS_NUT: u8 = 33;
|
||||
|
||||
/// Reads the signal description from the SPS among `parameter_sets` (NAL units with their
|
||||
/// two-byte header, as an `hvcC` record stores them). `None`: no SPS, or one this reader can't
|
||||
/// follow; `Some(Signal::default())`: an SPS without VUI colour fields.
|
||||
pub fn signal(parameter_sets: &[&[u8]]) -> Option<Signal> {
|
||||
let sps = parameter_sets.iter().find(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT))?;
|
||||
parse_sps(&unescape(sps.get(2..)?))
|
||||
}
|
||||
|
||||
/// Removes emulation prevention bytes (`00 00 03` → `00 00`).
|
||||
fn unescape(nal: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(nal.len());
|
||||
let mut zeros = 0usize;
|
||||
for &b in nal {
|
||||
if zeros >= 2 && b == 3 {
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
out.push(b);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Bits<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Bits<'_> {
|
||||
fn u(&mut self, n: u32) -> Option<u32> {
|
||||
let mut v = 0u32;
|
||||
for _ in 0..n {
|
||||
let byte = *self.data.get(self.pos / 8)?;
|
||||
v = (v << 1) | u32::from((byte >> (7 - self.pos % 8)) & 1);
|
||||
self.pos += 1;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn flag(&mut self) -> Option<bool> {
|
||||
self.u(1).map(|b| b == 1)
|
||||
}
|
||||
|
||||
fn skip(&mut self, n: usize) -> Option<()> {
|
||||
self.pos = self.pos.checked_add(n)?;
|
||||
(self.pos <= self.data.len().saturating_mul(8)).then_some(())
|
||||
}
|
||||
|
||||
/// `ue(v)`; values past 32 bits are refused (no valid field here needs them).
|
||||
fn ue(&mut self) -> Option<u32> {
|
||||
let mut zeros = 0u32;
|
||||
while !self.flag()? {
|
||||
zeros += 1;
|
||||
if zeros > 31 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let rest = self.u(zeros)?;
|
||||
((1u64 << zeros) - 1 + u64::from(rest)).try_into().ok()
|
||||
}
|
||||
|
||||
fn se(&mut self) -> Option<()> {
|
||||
self.ue().map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// `profile_tier_level(1, max_sub_layers_minus1)` (7.3.3): only skipped.
|
||||
fn profile_tier_level(r: &mut Bits, max_sub_layers_minus1: u32) -> Option<()> {
|
||||
r.skip(88)?; // general profile space .. general_inbld/reserved flag
|
||||
r.skip(8)?; // general_level_idc
|
||||
let mut present = [(false, false); 8];
|
||||
for p in present.iter_mut().take(max_sub_layers_minus1 as usize) {
|
||||
*p = (r.flag()?, r.flag()?);
|
||||
}
|
||||
if max_sub_layers_minus1 > 0 {
|
||||
r.skip(2 * (8 - max_sub_layers_minus1 as usize))?; // reserved_zero_2bits
|
||||
}
|
||||
for &(profile, level) in present.iter().take(max_sub_layers_minus1 as usize) {
|
||||
if profile {
|
||||
r.skip(88)?;
|
||||
}
|
||||
if level {
|
||||
r.skip(8)?;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `scaling_list_data()` (7.3.4): only skipped.
|
||||
fn scaling_list_data(r: &mut Bits) -> Option<()> {
|
||||
for size_id in 0..4u32 {
|
||||
let step = if size_id == 3 { 3 } else { 1 };
|
||||
for _ in (0..6).step_by(step) {
|
||||
if !r.flag()? {
|
||||
r.ue()?; // scaling_list_pred_matrix_id_delta
|
||||
} else {
|
||||
let coefs = 64.min(1 << (4 + (size_id << 1)));
|
||||
if size_id > 1 {
|
||||
r.se()?; // scaling_list_dc_coef_minus8
|
||||
}
|
||||
for _ in 0..coefs {
|
||||
r.se()?; // scaling_list_delta_coef
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `st_ref_pic_set(idx)` (7.3.7) as it appears in an SPS; returns `NumDeltaPocs[idx]`.
|
||||
fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option<u32> {
|
||||
if idx != 0 && r.flag()? {
|
||||
// inter_ref_pic_set_prediction_flag: predicted from the previous set (in an SPS,
|
||||
// delta_idx_minus1 is absent and RefRpsIdx = idx - 1).
|
||||
r.skip(1)?; // delta_rps_sign
|
||||
r.ue()?; // abs_delta_rps_minus1
|
||||
let reference = *num_delta_pocs.get(idx - 1)?;
|
||||
let mut count = 0u32;
|
||||
for _ in 0..=reference {
|
||||
let used = r.flag()?;
|
||||
let kept = used || r.flag()?; // use_delta_flag when not used_by_curr_pic_flag
|
||||
count += u32::from(kept);
|
||||
}
|
||||
return Some(count);
|
||||
}
|
||||
let negative = r.ue()?;
|
||||
let positive = r.ue()?;
|
||||
let total = negative.checked_add(positive)?;
|
||||
if total > 32 {
|
||||
return None; // at most 16 + 16 for any level
|
||||
}
|
||||
for _ in 0..total {
|
||||
r.ue()?; // delta_poc_s*_minus1
|
||||
r.skip(1)?; // used_by_curr_pic_s*_flag
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
fn parse_sps(rbsp: &[u8]) -> Option<Signal> {
|
||||
let r = &mut Bits { data: rbsp, pos: 0 };
|
||||
r.skip(4)?; // sps_video_parameter_set_id
|
||||
let max_sub_layers_minus1 = r.u(3)?;
|
||||
if max_sub_layers_minus1 > 6 {
|
||||
return None;
|
||||
}
|
||||
r.skip(1)?; // sps_temporal_id_nesting_flag
|
||||
profile_tier_level(r, max_sub_layers_minus1)?;
|
||||
r.ue()?; // sps_seq_parameter_set_id
|
||||
if r.ue()? == 3 {
|
||||
r.skip(1)?; // separate_colour_plane_flag
|
||||
}
|
||||
r.ue()?; // pic_width_in_luma_samples
|
||||
r.ue()?; // pic_height_in_luma_samples
|
||||
if r.flag()? {
|
||||
for _ in 0..4 {
|
||||
r.ue()?; // conf_win_*_offset
|
||||
}
|
||||
}
|
||||
r.ue()?; // bit_depth_luma_minus8
|
||||
r.ue()?; // bit_depth_chroma_minus8
|
||||
let log2_max_poc_lsb = r.ue()?.checked_add(4)?;
|
||||
if log2_max_poc_lsb > 16 {
|
||||
return None;
|
||||
}
|
||||
let first = if r.flag()? { 0 } else { max_sub_layers_minus1 };
|
||||
for _ in first..=max_sub_layers_minus1 {
|
||||
r.ue()?; // sps_max_dec_pic_buffering_minus1
|
||||
r.ue()?; // sps_max_num_reorder_pics
|
||||
r.ue()?; // sps_max_latency_increase_plus1
|
||||
}
|
||||
for _ in 0..6 {
|
||||
r.ue()?; // coding/transform block sizes, transform hierarchy depths
|
||||
}
|
||||
if r.flag()? && r.flag()? {
|
||||
// scaling_list_enabled_flag, sps_scaling_list_data_present_flag
|
||||
scaling_list_data(r)?;
|
||||
}
|
||||
r.skip(2)?; // amp_enabled_flag, sample_adaptive_offset_enabled_flag
|
||||
if r.flag()? {
|
||||
// pcm_enabled_flag
|
||||
r.skip(8)?; // pcm sample bit depths
|
||||
r.ue()?;
|
||||
r.ue()?;
|
||||
r.skip(1)?; // pcm_loop_filter_disabled_flag
|
||||
}
|
||||
let sets = r.ue()? as usize;
|
||||
if sets > 64 {
|
||||
return None;
|
||||
}
|
||||
let mut num_delta_pocs = Vec::with_capacity(sets);
|
||||
for idx in 0..sets {
|
||||
let n = st_ref_pic_set(r, idx, &num_delta_pocs)?;
|
||||
num_delta_pocs.push(n);
|
||||
}
|
||||
if r.flag()? {
|
||||
// long_term_ref_pics_present_flag
|
||||
let n = r.ue()?;
|
||||
if n > 32 {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..n {
|
||||
r.skip(log2_max_poc_lsb as usize + 1)?; // lt_ref_pic_poc_lsb_sps, used_by_curr_pic_lt_sps_flag
|
||||
}
|
||||
}
|
||||
r.skip(2)?; // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag
|
||||
let mut signal = Signal::default();
|
||||
if !r.flag()? {
|
||||
return Some(signal); // no VUI
|
||||
}
|
||||
if r.flag()? && r.u(8)? == 255 {
|
||||
// aspect_ratio_info_present_flag, aspect_ratio_idc == EXTENDED_SAR
|
||||
r.skip(32)?;
|
||||
}
|
||||
if r.flag()? {
|
||||
r.skip(1)?; // overscan_appropriate_flag
|
||||
}
|
||||
if r.flag()? {
|
||||
// video_signal_type_present_flag
|
||||
r.skip(3)?; // video_format
|
||||
signal.full_range = r.flag()?;
|
||||
if r.flag()? {
|
||||
signal.primaries = r.u(8)? as u8;
|
||||
signal.transfer = r.u(8)? as u8;
|
||||
signal.matrix = r.u(8)? as u8;
|
||||
}
|
||||
}
|
||||
Some(signal)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A bit writer for building parameter sets in tests.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Writer {
|
||||
pub bits: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
pub fn u(&mut self, v: u32, n: u32) {
|
||||
for i in (0..n).rev() {
|
||||
self.bits.push(i < 32 && (v >> i) & 1 == 1);
|
||||
}
|
||||
}
|
||||
pub fn ue(&mut self, v: u32) {
|
||||
let x = u64::from(v) + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
self.u(0, len - 1);
|
||||
for i in (0..len).rev() {
|
||||
self.bits.push((x >> i) & 1 == 1);
|
||||
}
|
||||
}
|
||||
pub fn bytes(mut self) -> Vec<u8> {
|
||||
self.bits.push(true); // rbsp_stop_one_bit
|
||||
while !self.bits.len().is_multiple_of(8) {
|
||||
self.bits.push(false);
|
||||
}
|
||||
self.bits.chunks(8).map(|c| c.iter().fold(0u8, |a, &b| (a << 1) | u8::from(b))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn sps(vui: impl FnOnce(&mut Writer), st_sets: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
let mut w = Writer::default();
|
||||
w.u(0, 4);
|
||||
w.u(1, 3); // one sub-layer above the base
|
||||
w.u(1, 1);
|
||||
w.u(0, 88);
|
||||
w.u(93, 8);
|
||||
w.u(0b11, 2); // sub-layer profile and level present
|
||||
w.u(0, 2 * 7);
|
||||
w.u(0, 88);
|
||||
w.u(0, 8);
|
||||
w.ue(0);
|
||||
w.ue(1); // 4:2:0
|
||||
w.ue(4032);
|
||||
w.ue(3024);
|
||||
w.u(1, 1); // conformance window
|
||||
for _ in 0..4 {
|
||||
w.ue(3);
|
||||
}
|
||||
w.ue(0);
|
||||
w.ue(0);
|
||||
w.ue(4);
|
||||
w.u(1, 1);
|
||||
for _ in 0..2 {
|
||||
w.ue(1);
|
||||
w.ue(0);
|
||||
w.ue(0);
|
||||
}
|
||||
for v in [0, 3, 0, 3, 0, 0] {
|
||||
w.ue(v);
|
||||
}
|
||||
w.u(1, 1); // scaling lists, with data
|
||||
w.u(1, 1);
|
||||
for size_id in 0..4u32 {
|
||||
for _ in (0..6).step_by(if size_id == 3 { 3 } else { 1 }) {
|
||||
w.u(1, 1);
|
||||
if size_id > 1 {
|
||||
w.ue(0);
|
||||
}
|
||||
for _ in 0..64.min(1 << (4 + (size_id << 1))) {
|
||||
w.ue(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
w.u(0, 2);
|
||||
w.u(1, 1); // pcm
|
||||
w.u(0x77, 8);
|
||||
w.ue(0);
|
||||
w.ue(1);
|
||||
w.u(1, 1);
|
||||
st_sets(&mut w);
|
||||
w.u(1, 1); // long-term pictures
|
||||
w.ue(1);
|
||||
w.u(0, 8 + 1);
|
||||
w.u(0, 2);
|
||||
vui(&mut w);
|
||||
let mut nal = vec![SPS_NUT << 1, 1];
|
||||
// escape like a real NAL so `signal` has to unescape
|
||||
let mut zeros = 0;
|
||||
for b in w.bytes() {
|
||||
if zeros >= 2 && b <= 3 {
|
||||
nal.push(3);
|
||||
zeros = 0;
|
||||
}
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
nal.push(b);
|
||||
}
|
||||
nal
|
||||
}
|
||||
|
||||
fn two_rps(w: &mut Writer) {
|
||||
w.ue(2);
|
||||
w.ue(1); // set 0: one negative picture
|
||||
w.ue(0);
|
||||
w.ue(0);
|
||||
w.u(1, 1);
|
||||
w.u(1, 1); // set 1: predicted from set 0
|
||||
w.u(0, 1);
|
||||
w.ue(0);
|
||||
w.u(0, 1); // not used ...
|
||||
w.u(1, 1); // ... but kept
|
||||
w.u(1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_full_range_and_matrix_from_the_vui() {
|
||||
let nal = sps(
|
||||
|w| {
|
||||
w.u(1, 1); // vui present
|
||||
w.u(1, 1);
|
||||
w.u(255, 8);
|
||||
w.u(0x0001_0001, 32);
|
||||
w.u(1, 1);
|
||||
w.u(0, 1);
|
||||
w.u(1, 1); // video_signal_type_present_flag
|
||||
w.u(5, 3);
|
||||
w.u(1, 1); // full range
|
||||
w.u(1, 1);
|
||||
w.u(12, 8);
|
||||
w.u(13, 8);
|
||||
w.u(6, 8);
|
||||
},
|
||||
two_rps,
|
||||
);
|
||||
let s = signal(&[&[0x40, 1, 0xc], &nal, &[0x44, 1]]);
|
||||
assert_eq!(s, Some(Signal { full_range: true, primaries: 12, transfer: 13, matrix: 6 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_fields_take_the_standard_defaults() {
|
||||
let no_vui = sps(|w| w.u(0, 1), |w| w.ue(0));
|
||||
assert_eq!(signal(&[&no_vui]), Some(Signal::default()));
|
||||
let range_only = sps(
|
||||
|w| {
|
||||
w.u(1, 1);
|
||||
w.u(0, 2);
|
||||
w.u(1, 1);
|
||||
w.u(5, 3);
|
||||
w.u(1, 1);
|
||||
w.u(0, 1); // no colour description
|
||||
},
|
||||
|w| w.ue(0),
|
||||
);
|
||||
assert_eq!(signal(&[&range_only]), Some(Signal { full_range: true, ..Signal::default() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_truncated_sps_is_none() {
|
||||
assert_eq!(signal(&[]), None);
|
||||
assert_eq!(signal(&[&[0x40, 1, 2, 3]]), None);
|
||||
let nal = sps(|w| w.u(0, 1), |w| w.ue(0));
|
||||
for cut in 2..nal.len() - 2 {
|
||||
// never panics; most cuts are None
|
||||
let _ = signal(&[nal.get(..cut).unwrap_or_default()]);
|
||||
}
|
||||
assert_eq!(signal(&[&[SPS_NUT << 1, 1, 0xff, 0xff, 0xff]]), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
//! YCbCr → RGB for decoded HEVC pictures, done here rather than in heic-rs so the result matches
|
||||
//! libheif (the reference every HEIF tool is checked against) to within one code value:
|
||||
//!
|
||||
//! - **Which matrix and range.** The item's `colr` `nclx` box when there is one; otherwise the
|
||||
//! HEVC VUI ([`crate::vui`]); otherwise the H.265 defaults (limited range). An unspecified matrix
|
||||
//! is BT.601, as libheif reads it. heic-rs 0.1.1 ignores the VUI and assumes BT.709 limited
|
||||
//! range for every file without `nclx`, which shifts every iPhone photo (full range, ICC only)
|
||||
//! by about 8 code values.
|
||||
//! - **Chroma upsampling.** Nearest neighbour (each chroma sample covers its 2×2 or 2×1 luma
|
||||
//! block), as libheif does by default. libheif switches to a centred bilinear filter (weights
|
||||
//! 9/3/3/1 sixteenths, in integers) when a `clap` crop starts at an odd column or row; so does
|
||||
//! [`Upsampling::Bilinear`].
|
||||
//!
|
||||
//! The arithmetic is floating point and the result 16-bit, whatever the coded depth, so the
|
||||
//! develop pipeline gets every bit the conversion produces.
|
||||
|
||||
use heic_rs::grid::Mosaic;
|
||||
use heic_rs::hevc::{ChromaFormat, Frame};
|
||||
|
||||
/// The coded pictures one image is made of.
|
||||
pub enum Planes<'a> {
|
||||
Picture(&'a Frame),
|
||||
Grid(Mosaic<'a>),
|
||||
}
|
||||
|
||||
impl Planes<'_> {
|
||||
pub fn size(&self) -> (usize, usize) {
|
||||
match self {
|
||||
Planes::Picture(f) => (f.width as usize, f.height as usize),
|
||||
Planes::Grid(m) => (m.width() as usize, m.height() as usize),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bit_depth(&self) -> u8 {
|
||||
match self {
|
||||
Planes::Picture(f) => f.bit_depth,
|
||||
Planes::Grid(m) => m.bit_depth(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chroma(&self) -> ChromaFormat {
|
||||
match self {
|
||||
Planes::Picture(f) => f.chroma,
|
||||
Planes::Grid(m) => m.chroma(),
|
||||
}
|
||||
}
|
||||
|
||||
fn chroma_width(&self) -> usize {
|
||||
let (w, h) = self.size();
|
||||
self.chroma().chroma_size(w as u32, h as u32).0 as usize
|
||||
}
|
||||
|
||||
fn luma_row(&self, y: usize, dst: &mut [u16]) -> Option<()> {
|
||||
match self {
|
||||
Planes::Picture(f) => copy_row(&f.y, f.y_stride as usize, y, dst),
|
||||
Planes::Grid(m) => m.luma_row(y, dst),
|
||||
}
|
||||
}
|
||||
|
||||
fn chroma_row(&self, cr: bool, r: usize, dst: &mut [u16]) -> Option<()> {
|
||||
match self {
|
||||
Planes::Picture(f) => copy_row(if cr { &f.cr } else { &f.cb }, f.c_stride as usize, r, dst),
|
||||
Planes::Grid(m) => m.chroma_row(cr, r, dst),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_row(plane: &[u16], stride: usize, y: usize, dst: &mut [u16]) -> Option<()> {
|
||||
let start = y.checked_mul(stride)?;
|
||||
dst.copy_from_slice(plane.get(start..start.checked_add(dst.len())?)?);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// How the three coded channels map to R, G, B (H.273 `MatrixCoefficients`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Matrix {
|
||||
/// 0: the channels are G, B, R.
|
||||
Identity,
|
||||
/// 8: YCgCo.
|
||||
YCgCo,
|
||||
/// Every Kr/Kb matrix: R = Y + `cr_r`·Cr, G = Y − `cb_g`·Cb − `cr_g`·Cr, B = Y + `cb_b`·Cb.
|
||||
Kr { cr_r: f32, cb_g: f32, cr_g: f32, cb_b: f32 },
|
||||
}
|
||||
|
||||
impl Matrix {
|
||||
/// Unknown, reserved and unspecified codes are BT.601, like libheif.
|
||||
fn from_code(code: u16) -> Matrix {
|
||||
let (kr, kb) = match code {
|
||||
0 => return Matrix::Identity,
|
||||
8 => return Matrix::YCgCo,
|
||||
1 => (0.2126, 0.0722),
|
||||
4 => (0.30, 0.11),
|
||||
7 => (0.212, 0.087),
|
||||
// 10 (BT.2020 constant luminance) is approximated by its non-constant matrix.
|
||||
9 | 10 => (0.2627, 0.0593),
|
||||
_ => (0.299, 0.114),
|
||||
};
|
||||
let kg = 1.0 - kr - kb;
|
||||
Matrix::Kr { cr_r: 2.0 * (1.0 - kr), cb_g: 2.0 * kb * (1.0 - kb) / kg, cr_g: 2.0 * kr * (1.0 - kr) / kg, cb_b: 2.0 * (1.0 - kb) }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the samples are to be read: the matrix and the range.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Signal {
|
||||
pub matrix: u16,
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
/// How subsampled chroma is brought to the luma resolution.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Upsampling {
|
||||
Nearest,
|
||||
/// Centred bilinear: chroma sits between its two luma columns (and rows for 4:2:0).
|
||||
Bilinear,
|
||||
}
|
||||
|
||||
/// One chroma row brought to the full width `w` of luma row `y`.
|
||||
fn chroma_at(planes: &Planes<'_>, cr: bool, y: usize, w: usize, mode: Upsampling, buf: &mut [u16], next: &mut [u16]) -> Option<Vec<u16>> {
|
||||
let chroma = planes.chroma();
|
||||
let (xs, ys) = (chroma.x_shift(), chroma.y_shift());
|
||||
let (wf, hf) = planes.size();
|
||||
let rows = chroma.chroma_size(wf as u32, hf as u32).1 as usize;
|
||||
let cy = y >> ys;
|
||||
planes.chroma_row(cr, cy, buf)?;
|
||||
let last = buf.len().checked_sub(1)?;
|
||||
if mode == Upsampling::Nearest || xs == 0 {
|
||||
return (0..w).map(|x| buf.get(x >> xs).copied()).collect();
|
||||
}
|
||||
// The nearer neighbour on each axis takes a quarter of the weight.
|
||||
let ny = if ys == 0 {
|
||||
cy
|
||||
} else if y.is_multiple_of(2) {
|
||||
cy.saturating_sub(1)
|
||||
} else {
|
||||
(cy + 1).min(rows.checked_sub(1)?)
|
||||
};
|
||||
planes.chroma_row(cr, ny, next)?;
|
||||
(0..w)
|
||||
.map(|x| {
|
||||
let cx = x >> 1;
|
||||
let nx = if x % 2 == 0 { cx.saturating_sub(1) } else { (cx + 1).min(last) };
|
||||
let (a, an) = (u32::from(*buf.get(cx)?), u32::from(*buf.get(nx)?));
|
||||
let v = if ys == 0 {
|
||||
(3 * a + an + 2) >> 2
|
||||
} else {
|
||||
let (b, bn) = (u32::from(*next.get(cx)?), u32::from(*next.get(nx)?));
|
||||
(9 * a + 3 * b + 3 * an + bn + 8) >> 4
|
||||
};
|
||||
u16::try_from(v).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Converts `planes` to interleaved 16-bit RGB, or RGBA with room left for alpha (filled with
|
||||
/// 65535 here). `None` when the planes can't supply a row they promised (a malformed grid).
|
||||
pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampling) -> Option<Vec<u16>> {
|
||||
let (w, h) = planes.size();
|
||||
let ch = if alpha { 4 } else { 3 };
|
||||
let bd = u32::from(planes.bit_depth());
|
||||
if !(8..=16).contains(&bd) || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let max = ((1u32 << bd) - 1) as f32;
|
||||
let half = (1u32 << (bd - 1)) as f32;
|
||||
let scale = (1u32 << (bd - 8)) as f32;
|
||||
// Normalised Y = (v − y_off)·y_mul, Cb/Cr = (v − half)·c_mul.
|
||||
let (y_off, y_mul, c_mul) =
|
||||
if signal.full_range { (0.0, 1.0 / max, 1.0 / max) } else { (16.0 * scale, 1.0 / (219.0 * scale), 1.0 / (224.0 * scale)) };
|
||||
let matrix = Matrix::from_code(signal.matrix);
|
||||
let mono = planes.chroma() == ChromaFormat::Monochrome;
|
||||
let cw = planes.chroma_width();
|
||||
|
||||
let mut out = vec![0u16; w.checked_mul(h)?.checked_mul(ch)?];
|
||||
let row = |y: usize, dst: &mut [u16]| -> Option<()> {
|
||||
let mut luma = vec![0u16; w];
|
||||
planes.luma_row(y, &mut luma)?;
|
||||
let (cb, cr) = if mono {
|
||||
(Vec::new(), Vec::new())
|
||||
} else {
|
||||
let (mut buf, mut next) = (vec![0u16; cw], vec![0u16; cw]);
|
||||
(chroma_at(planes, false, y, w, mode, &mut buf, &mut next)?, chroma_at(planes, true, y, w, mode, &mut buf, &mut next)?)
|
||||
};
|
||||
for (x, (px, &yv)) in dst.chunks_exact_mut(ch).zip(&luma).enumerate() {
|
||||
let rgb = if mono {
|
||||
let v = (f32::from(yv) - y_off) * y_mul;
|
||||
[v, v, v]
|
||||
} else {
|
||||
let (u, v) = (f32::from(*cb.get(x)?), f32::from(*cr.get(x)?));
|
||||
match matrix {
|
||||
// G, B, R all read like luma.
|
||||
Matrix::Identity => [(v - y_off) * y_mul, (f32::from(yv) - y_off) * y_mul, (u - y_off) * y_mul],
|
||||
Matrix::YCgCo => {
|
||||
let (yn, cg, co) = ((f32::from(yv) - y_off) * y_mul, (u - half) * c_mul, (v - half) * c_mul);
|
||||
let t = yn - cg;
|
||||
[t + co, yn + cg, t - co]
|
||||
}
|
||||
Matrix::Kr { cr_r, cb_g, cr_g, cb_b } => {
|
||||
let (yn, u, v) = ((f32::from(yv) - y_off) * y_mul, (u - half) * c_mul, (v - half) * c_mul);
|
||||
[yn + cr_r * v, yn - cb_g * u - cr_g * v, yn + cb_b * u]
|
||||
}
|
||||
}
|
||||
};
|
||||
for (o, c) in px.iter_mut().zip(rgb) {
|
||||
*o = (c.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
|
||||
}
|
||||
if alpha && let Some(a) = px.get_mut(3) {
|
||||
*a = u16::MAX;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
};
|
||||
let row_len = w * ch;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let ok = {
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(row_len).enumerate().all(|(y, dst)| row(y, dst).is_some())
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let ok = out.chunks_mut(row_len).enumerate().all(|(y, dst)| row(y, dst).is_some());
|
||||
ok.then_some(out)
|
||||
}
|
||||
|
||||
/// Writes an alpha picture's luma, full range, into channel 3 of `rgba`.
|
||||
pub fn put_alpha(planes: &Planes<'_>, rgba: &mut [u16]) -> Option<()> {
|
||||
let (w, h) = planes.size();
|
||||
let max = ((1u32 << u32::from(planes.bit_depth()).clamp(1, 16)) - 1) as f32;
|
||||
let mut luma = vec![0u16; w];
|
||||
for (y, row) in rgba.chunks_exact_mut(w * 4).enumerate().take(h) {
|
||||
planes.luma_row(y, &mut luma)?;
|
||||
for (px, &a) in row.as_chunks_mut::<4>().0.iter_mut().zip(&luma) {
|
||||
let v = (f32::from(a) / max).clamp(0.0, 1.0);
|
||||
px[3] = (v * 65535.0 + 0.5) as u16;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(w: u32, h: u32, bd: u8, chroma: ChromaFormat, y: u16, cb: u16, cr: u16) -> Frame {
|
||||
let (cw, chh) = chroma.chroma_size(w, h);
|
||||
let n = (cw * chh) as usize;
|
||||
Frame {
|
||||
width: w,
|
||||
height: h,
|
||||
bit_depth: bd,
|
||||
chroma,
|
||||
y: vec![y; (w * h) as usize],
|
||||
cb: vec![cb; n],
|
||||
cr: vec![cr; n],
|
||||
y_stride: w,
|
||||
c_stride: cw,
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(f: &Frame, matrix: u16, full_range: bool) -> [u16; 3] {
|
||||
let v = to_rgb16(&Planes::Picture(f), Signal { matrix, full_range }, false, Upsampling::Nearest).unwrap();
|
||||
[v[0], v[1], v[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_and_primaries_convert_exactly() {
|
||||
// Full-range grey stays grey; limited-range black and white reach the ends.
|
||||
assert_eq!(rgb(&frame(4, 4, 8, ChromaFormat::Yuv420, 128, 128, 128), 6, true), [128 * 257; 3]);
|
||||
assert_eq!(rgb(&frame(4, 4, 8, ChromaFormat::Yuv420, 16, 128, 128), 1, false), [0; 3]);
|
||||
assert_eq!(rgb(&frame(4, 4, 8, ChromaFormat::Yuv420, 235, 128, 128), 1, false), [65535; 3]);
|
||||
assert_eq!(rgb(&frame(4, 4, 10, ChromaFormat::Yuv420, 940, 512, 512), 9, false), [65535; 3]);
|
||||
// BT.601 full-range pure red: Y = 76.245, Cb = 84.97, Cr = 255.5 → (255, 0, 0) within rounding.
|
||||
let red = rgb(&frame(2, 2, 8, ChromaFormat::Yuv444, 76, 85, 255), 6, true);
|
||||
assert!(red[0] > 65000 && red[1] < 300 && red[2] < 300, "{red:?}");
|
||||
// Identity: the channels are G, B, R.
|
||||
assert_eq!(rgb(&frame(2, 2, 8, ChromaFormat::Yuv444, 10, 20, 30), 0, true), [30 * 257, 10 * 257, 20 * 257]);
|
||||
// Monochrome.
|
||||
assert_eq!(rgb(&frame(2, 2, 8, ChromaFormat::Monochrome, 51, 0, 0), 6, true), [51 * 257; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chroma_is_nearest_neighbour() {
|
||||
// 4:2:0, 4×2 luma, chroma 2×1: the left chroma sample covers columns 0–1 only.
|
||||
let mut f = frame(4, 2, 8, ChromaFormat::Yuv420, 128, 128, 128);
|
||||
f.cr = vec![200, 128];
|
||||
let v = to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).unwrap();
|
||||
let red: Vec<u16> = v.chunks(3).map(|p| p[0]).collect();
|
||||
assert!(red[0] == red[1] && red[1] == red[4] && red[0] > red[2] && red[2] == 128 * 257, "{red:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilinear_chroma_is_centred_9_3_3_1() {
|
||||
// 4:2:0, 4×4 luma, chroma 2×2 with one bright corner sample.
|
||||
let mut f = frame(4, 4, 8, ChromaFormat::Yuv420, 128, 128, 128);
|
||||
f.cr = vec![200, 128, 128, 128];
|
||||
let p = Planes::Picture(&f);
|
||||
let (mut buf, mut next) = (vec![0; 2], vec![0; 2]);
|
||||
let rows: Vec<Vec<u16>> = (0..4).map(|y| chroma_at(&p, true, y, 4, Upsampling::Bilinear, &mut buf, &mut next).unwrap()).collect();
|
||||
// Row 0 sits nearest chroma row 0 (and clamps upward); row 1 blends a quarter of row 1.
|
||||
assert_eq!(rows[0], [200, 182, 146, 128]);
|
||||
assert_eq!(rows[1], [182, 169, 142, 128]);
|
||||
assert_eq!(rows[2], [146, 142, 133, 128]);
|
||||
assert_eq!(rows[3], [128; 4]);
|
||||
assert_eq!(chroma_at(&p, true, 1, 4, Upsampling::Nearest, &mut buf, &mut next).unwrap(), [200, 200, 128, 128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_and_bad_planes() {
|
||||
let f = frame(2, 2, 8, ChromaFormat::Yuv420, 128, 128, 128);
|
||||
let mut v = to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, true, Upsampling::Nearest).unwrap();
|
||||
assert_eq!(v[3], 65535);
|
||||
put_alpha(&Planes::Picture(&frame(2, 2, 8, ChromaFormat::Monochrome, 51, 0, 0)), &mut v).unwrap();
|
||||
assert_eq!(v[3], 51 * 257);
|
||||
let mut short = frame(4, 4, 8, ChromaFormat::Yuv420, 128, 128, 128);
|
||||
short.y.truncate(5);
|
||||
assert!(to_rgb16(&Planes::Picture(&short), Signal { matrix: 6, full_range: true }, false, Upsampling::Bilinear).is_none());
|
||||
assert!(
|
||||
to_rgb16(
|
||||
&Planes::Picture(&frame(2, 2, 7, ChromaFormat::Yuv420, 0, 0, 0)),
|
||||
Signal { matrix: 6, full_range: true },
|
||||
false,
|
||||
Upsampling::Nearest
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -204,6 +204,12 @@
|
|||
- 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`.
|
||||
- HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own
|
||||
full-range/matrix signalling is honoured (iPhone photos are full range), chroma is upsampled like libheif, grids
|
||||
take their tiles' ICC profile, and `imir` mirrors the way libheif writes it. Importing a HEIC reads its size from
|
||||
the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages
|
||||
(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports
|
||||
"HEIC/HEIF support isn't included in this build" for each `.heic`/`.heif` at import.
|
||||
|
||||
### Presets and profiles
|
||||
- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip`
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ let
|
|||
"lightcraft"
|
||||
"lightcraft-cli"
|
||||
];
|
||||
|
||||
# HEIC/HEIF decoding (opt-in upstream: HEVC patents are the distributor's call), as the release builds.
|
||||
buildFeatures = [
|
||||
"lightcraft/heif"
|
||||
"lightcraft-cli/heif"
|
||||
];
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "lightcraft";
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ echo "==> LightCraft $VERSION for FreeBSD $ARCH"
|
|||
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli)
|
||||
(cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif)
|
||||
fi
|
||||
WORK="$CARGO_TARGET_DIR/freebsd-package"
|
||||
STAGE="$WORK/$BASENAME"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ BASENAME="lightcraft-$VERSION-linux-$ARCH"
|
|||
echo "==> LightCraft $VERSION for Linux $ARCH ($FORMATS)"
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli)
|
||||
(cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif)
|
||||
fi
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
WORK="$CARGO_TARGET_DIR/linux-package"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ echo "==> LightCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize:
|
|||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
args=()
|
||||
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
|
||||
(cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli "${args[@]}")
|
||||
(cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif "${args[@]}")
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ if (-not $SkipBuild) {
|
|||
[Environment]::SetEnvironmentVariable($flagVar, '-C target-feature=+crt-static')
|
||||
# Fail the build (rather than warn) if the icon/VERSIONINFO can't be embedded.
|
||||
$env:LIGHTCRAFT_REQUIRE_WINRES = '1'
|
||||
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p lightcraft -p lightcraft-cli --target $Target }
|
||||
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif --target $Target }
|
||||
}
|
||||
|
||||
$Bin = Join-Path $TargetDir "$Target\release"
|
||||
|
|
|
|||
|
|
@ -268,7 +268,11 @@ fn cmd_ci() -> Result<(), String> {
|
|||
run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?;
|
||||
let mut c = cargo();
|
||||
c.args(["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"]);
|
||||
run(c, "cargo test -p lightcraft-codecs -p lightcraft-heif --features lightcraft-codecs/heif")
|
||||
run(c, "cargo test -p lightcraft-codecs -p lightcraft-heif --features lightcraft-codecs/heif")?;
|
||||
// the engine's HEIC import test, with the decoder (the workspace run checks the error)
|
||||
let mut c = cargo();
|
||||
c.args(["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]);
|
||||
run(c, "cargo test -p lightcraft-engine --lib --features lightcraft-codecs/heif heic")
|
||||
}),
|
||||
),
|
||||
(
|
||||
|
|
|
|||
Loading…
Reference in New Issue