Files
lightcraft/crates/codecs/src/sniff.rs
T
Exalted5373andClaude Opus 5.5 90361935ba LR-IMP-FORMATS: HEIC decodes like libheif; release builds include it
heic-rs reconstructs iPhone HEVC tiles bit-exactly (checked against libde265), but
its colour stage was 2-27 code values off libheif on every iPhone photo: it ignores
the HEVC VUI and assumes BT.709 limited range. lightcraft-heif now converts YCbCr
itself (nclx, else VUI, else H.265 defaults; nearest chroma, bilinear for odd clap
offsets, as libheif), inherits tile ICC/nclx on grids, follows libheif's imir axis
and clap rounding, and outputs 16-bit RGB. Measured on 30 real and synthetic
files: within 1 code value of libheif (pillow-heif 1.8 / libheif 1.23).

Also: HEIF header reader for import (no pixel decode), embedded thumbnail for small
decodes, nclx/VUI colour hint (shared CICP mapping with PNG), clear 4:2:2/4:4:4
Unsupported error, Format::not_decodable as the one source of "why not" (import
reports "HEIC/HEIF support isn't included in this build"), `.heif` imports.
Release packaging and Nix build with the heif feature, as whats-new promised.
lightcraft_heif::testdata writes lossless (PCM) synthetic HEICs for the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:38:00 -07:00

290 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Format detection from leading bytes.
use crate::exif::Tiff;
use serde::{Deserialize, Serialize};
/// Image container formats LightCraft recognises.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Format {
Jpeg,
Png,
/// Ordinary (non-camera-raw) TIFF, incl. BigTIFF.
Tiff,
WebP,
Avif,
/// HEIC / HEIF (ISO-BMFF with HEVC).
Heif,
/// JPEG XL (bare codestream or ISO-BMFF container).
Jxl,
Gif,
Bmp,
/// Photoshop PSD / PSB (merged composite).
Psd,
/// TIFF-structured camera raw (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, 3FR, IIQ, ERF, …):
/// route to `lightcraft-raw`.
RawTiffLike,
/// Non-TIFF camera raw containers (CR3, RAF, CRW, MRW, X3F): route to `lightcraft-raw`.
RawOther,
}
impl Format {
/// Canonical lowercase file extension.
pub fn extension(self) -> &'static str {
match self {
Format::Jpeg => "jpg",
Format::Png => "png",
Format::Tiff => "tif",
Format::WebP => "webp",
Format::Avif => "avif",
Format::Heif => "heic",
Format::Jxl => "jxl",
Format::Gif => "gif",
Format::Bmp => "bmp",
Format::Psd => "psd",
Format::RawTiffLike => "dng",
Format::RawOther => "raw",
}
}
pub fn mime(self) -> &'static str {
match self {
Format::Jpeg => "image/jpeg",
Format::Png => "image/png",
Format::Tiff => "image/tiff",
Format::WebP => "image/webp",
Format::Avif => "image/avif",
Format::Heif => "image/heic",
Format::Jxl => "image/jxl",
Format::Gif => "image/gif",
Format::Bmp => "image/bmp",
Format::Psd => "image/vnd.adobe.photoshop",
Format::RawTiffLike | Format::RawOther => "image/x-raw",
}
}
/// 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 {
#[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,
}
}
/// Camera raw formats (decode with `lightcraft-raw`).
pub fn is_raw(self) -> bool {
matches!(self, Format::RawTiffLike | Format::RawOther)
}
}
/// Detect the format from the first bytes of a file (a few KiB suffices; TIFF raw detection walks IFDs
/// when more of the file is available).
pub fn sniff(b: &[u8]) -> Option<Format> {
if b.len() < 4 {
return None;
}
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
if b.starts_with(b"\x89PNG\r\n\x1a\n") {
return Some(Format::Png);
}
if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") {
return Some(Format::Gif);
}
if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" {
return Some(Format::WebP);
}
if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(&[0, 0, 0, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A]) {
return Some(Format::Jxl);
}
if b.starts_with(b"8BPS") && b.len() >= 6 && matches!(u16::from_be_bytes([b[4], b[5]]), 1 | 2) {
return Some(Format::Psd);
}
if b.starts_with(b"FUJIFILMCCD-RAW") || b.starts_with(b"\0MRM") || b.starts_with(b"FOVb") {
return Some(Format::RawOther);
}
if b.len() >= 14 && &b[0..2] == b"II" && &b[6..14] == b"HEAPCCDR" {
return Some(Format::RawOther);
}
if b.len() >= 12 && &b[4..8] == b"ftyp" {
return sniff_bmff(b);
}
if b.starts_with(b"IIRO") || b.starts_with(b"IIRS") || b.starts_with(b"MMOR") || b.starts_with(b"IIU\0") {
// Olympus ORF / Panasonic RW2: TIFF-structured with a private magic.
return Some(Format::RawTiffLike);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") {
return Some(if tiff_is_raw(b) { Format::RawTiffLike } else { Format::Tiff });
}
if b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
return Some(Format::Tiff);
}
if b.len() >= 26 && b.starts_with(b"BM") {
let dib = u32::from_le_bytes([b[14], b[15], b[16], b[17]]);
if matches!(dib, 12 | 16 | 40 | 52 | 56 | 64 | 108 | 124) {
return Some(Format::Bmp);
}
}
None
}
fn sniff_bmff(b: &[u8]) -> Option<Format> {
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
let end = size.clamp(12, b.len().min(4096));
let mut brands: Vec<&[u8]> = vec![&b[8..12]];
let mut p = 16;
while p + 4 <= end {
brands.push(&b[p..p + 4]);
p += 4;
}
let has = |x: &[u8]| brands.contains(&x);
if has(b"crx ") {
return Some(Format::RawOther);
}
if has(b"jxl ") {
return Some(Format::Jxl);
}
if has(b"avif") || has(b"avis") {
return Some(Format::Avif);
}
if [&b"heic"[..], b"heix", b"hevc", b"hevx", b"heim", b"heis", b"hevm", b"hevs", b"mif1", b"msf1"].iter().any(|x| has(x)) {
return Some(Format::Heif);
}
None
}
/// Heuristic camera-raw detection for classic TIFF: DNGVersion, the CR2 signature, CFA/LinearRaw
/// photometric or raw-only compressions in IFD0 or its SubIFDs, or a camera Make with 10–16-bit data
/// and no ordinary photometric.
fn tiff_is_raw(b: &[u8]) -> bool {
if b.len() >= 10 && &b[8..10] == b"CR" {
return true;
}
let Some(t) = Tiff::new(b) else { return false };
let Some(ifd0) = t.first_ifd() else { return false };
let sony = t.ifd(ifd0).is_some_and(|(entries, _)| {
entries.iter().any(|e| e.tag == 0x010f && t.bytes(e).and_then(|v| v.get(..4)).is_some_and(|v| v.eq_ignore_ascii_case(b"SONY")))
});
let mut stack = vec![ifd0];
let mut seen = 0;
while let Some(pos) = stack.pop() {
seen += 1;
if seen > 16 {
break;
}
let Some((entries, _)) = t.ifd(pos) else { continue };
// Sony M/S lossless ARWs retain a dummy CFA pattern, but store linear YCbCr.
// Requiring the raw pattern plus this layout keeps ordinary Sony TIFFs as TIFFs.
let uint = |tag| entries.iter().find(|e| e.tag == tag).and_then(|e| t.uint(e));
if sony && entries.iter().any(|e| e.tag == 0x828e) && uint(0x0103) == Some(7) && uint(0x0106) == Some(6) && uint(0x0115) == Some(3) {
return true;
}
for e in &entries {
match e.tag {
0xC612 => return true, // DNGVersion
0x0106 if matches!(t.uint(e), Some(32803 | 34892)) => return true,
0x0103 if matches!(t.uint(e), Some(32767 | 32769 | 32770 | 34316 | 34713 | 65000 | 65535)) => return true,
0x014A => {
// SubIFDs (LONG or IFD type): walk them.
let n = e.count.min(8) as usize;
if n == 1 {
if let Some(p) = t.uint(e) {
stack.push(p as usize);
}
} else if let Some(off) = t.u32_at(e.value_pos) {
for i in 0..n {
if let Some(p) = t.u32_at(off as usize + 4 * i) {
stack.push(p as usize);
}
}
}
}
_ => {}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn magic_numbers() {
assert_eq!(sniff(b"\xFF\xD8\xFF\xE0\0\x10JFIF"), Some(Format::Jpeg));
assert_eq!(sniff(b"\x89PNG\r\n\x1a\n...."), Some(Format::Png));
assert_eq!(sniff(b"GIF89a......"), Some(Format::Gif));
assert_eq!(sniff(b"RIFF\0\0\0\0WEBPVP8 "), Some(Format::WebP));
assert_eq!(sniff(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif));
assert_eq!(sniff(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), Some(Format::Heif));
assert_eq!(sniff(b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom"), Some(Format::RawOther));
assert_eq!(sniff(&[0xFF, 0x0A, 0xFA, 0x7F]), Some(Format::Jxl));
assert_eq!(sniff(b"8BPS\0\x01\0\0\0\0\0\0"), Some(Format::Psd));
assert_eq!(sniff(b"FUJIFILMCCD-RAW 0201"), Some(Format::RawOther));
assert_eq!(sniff(b"II*\0\x08\0\0\0\0\0"), Some(Format::Tiff));
assert_eq!(sniff(b"II*\0\x10\0\0\0CR\x02\0"), Some(Format::RawTiffLike));
assert_eq!(sniff(b"IIRO\x08\0\0\0"), Some(Format::RawTiffLike));
assert_eq!(sniff(b"nope"), None);
assert_eq!(sniff(b""), None);
}
#[test]
fn dng_detected() {
// II*, IFD at 8 with a single DNGVersion entry.
let mut b = b"II*\0\x08\0\0\0".to_vec();
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&0xC612u16.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&4u32.to_le_bytes());
b.extend_from_slice(&[1, 4, 0, 0]);
b.extend_from_slice(&0u32.to_le_bytes());
assert_eq!(sniff(&b), Some(Format::RawTiffLike));
}
#[test]
fn sony_linear_ycbcr_subifd_is_raw_but_ordinary_tiff_is_not() {
fn fixture(compression: u32, pattern: bool) -> Vec<u8> {
let mut b = b"II*\0\x08\0\0\0".to_vec();
let entry = |b: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| {
b.extend_from_slice(&tag.to_le_bytes());
b.extend_from_slice(&typ.to_le_bytes());
b.extend_from_slice(&count.to_le_bytes());
b.extend_from_slice(&value.to_le_bytes());
};
b.extend_from_slice(&2u16.to_le_bytes());
entry(&mut b, 0x010f, 2, 5, 38);
entry(&mut b, 0x014a, 4, 1, 44);
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(b"SONY\0\0");
b.extend_from_slice(&(if pattern { 4u16 } else { 3 }).to_le_bytes());
entry(&mut b, 0x0103, 3, 1, compression);
entry(&mut b, 0x0106, 3, 1, 6);
entry(&mut b, 0x0115, 3, 1, 3);
if pattern {
entry(&mut b, 0x828e, 1, 4, u32::MAX);
}
b.extend_from_slice(&0u32.to_le_bytes());
b
}
let mut raw = fixture(7, true);
assert_eq!(sniff(&raw), Some(Format::RawTiffLike));
assert_eq!(sniff(&fixture(7, false)), Some(Format::Tiff));
assert_eq!(sniff(&fixture(1, true)), Some(Format::Tiff));
assert_eq!(sniff(&raw[..38]), Some(Format::Tiff));
raw[38..42].copy_from_slice(b"TEST");
assert_eq!(sniff(&raw), Some(Format::Tiff));
}
}