# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/src/vendor/nef.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-10 01:14:24 -04:00
6 changed files with 187 additions and 25 deletions
-4
View File
@@ -188,10 +188,6 @@ packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recov
118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded.
Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete):
- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 32;
pub const RENDER_CACHE_VERSION: u64 = 33;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
+1 -2
View File
@@ -17,8 +17,7 @@
//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (Nikon "lossy after split" NEF,
//! compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources
//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources
//! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no
//! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input.
#![forbid(unsafe_code)]
+2 -2
View File
@@ -3,8 +3,8 @@
//! Sources: TIFF 6.0 (the raw image is a standard CFA SubIFD), Laurent Clévy's NEF structure notes (prose: IFD
//! layout, SubIFDs, maker note header) and the ExifTool Nikon tag-name documentation (`0x000c` WB_RBLevels,
//! `0x003d` BlackLevel, `0x0096` NEFLinearizationTable). The Huffman-compressed data (compression 34713) is decoded
//! by [`super::nefc`], which documents its clean-room sources; files it can't decode yet ("lossy after split")
//! are reported as [`RawError::Unsupported`] and their embedded previews still work. So are High Efficiency NEFs
//! by [`super::nefc`], which documents its clean-room sources; "lossy after split" files are decoded when their strip follows the
//! rule documented there; files it can't decode are reported as [`RawError::Unsupported`] and their embedded previews still work. So are High Efficiency NEFs
//! (HE / HE★, Z 8, Z 9, Z 6III, Z f): they keep compression 34713 but carry a wavelet codestream that starts with
//! the JPEG XS markers SOC + CAP (ISO/IEC 21122-1, `FF10 FF50`) and have no `0x0096` table (issue #193).
+182 -15
View File
@@ -39,9 +39,18 @@
//! 252 in 12-bit units); with the first-interval step they sit at that black level.
//! - Values in the maker note are in the maker note's byte order (newer bodies write little-endian notes).
//!
//! "Lossy after split" (non-zero split row at `0x0096` offset 562, type 2 tables only, found in 11 of the CC0
//! samples): derived by black-box analysis of those files. Rows below the split row use the lossy Huffman path
//! above unchanged. Row `split` starts at the very next bit (no alignment); from there every pixel is a word of
//! `bits - 4` bits (8 at 12 bit, 10 at 14 bit), MSB first, with no row padding. A word is a complete prefix code
//! for a difference category `n` followed by the top `W - prefix length` bits of the T.81 additional bits (the low
//! bits are dropped); the difference is the centre of the magnitude bin that was kept. The predictor is the same
//! as above and the running value is not clamped (only the sample index is, before the curve lookup). The camera
//! ends the strip with 0 to 7 one-bits after the last full row, which the decoder checks.
//!
//! Not supported (returned as [`RawError::Unsupported`], the embedded preview is used instead):
//! - "Lossy after split" files (non-zero split row at `0x0096` offset 562): from that row on a different table
//! is used which our analysis has not recovered yet.
//! - "Lossy after split" files whose fixed-rate section does not end within the last byte of the strip (see
//! below): refused rather than decoded wrongly.
//! - Other `0x0096` versions, bit depths other than 12/14.
//! - Code words that never occurred in any sample (the lossy 12-bit 8-bit code word `11111110`, and code words
//! longer than 9 bits in the lossy tables) are rejected as corrupt; they cannot code a difference within the
@@ -102,6 +111,8 @@ pub(crate) struct DecodeTable {
pub encoding: Encoding,
/// Predictor seeds `[row parity][column]`.
pub seeds: [[i32; 2]; 2],
/// First row of the fixed-rate section ("lossy after split"), 0 when the whole strip is Huffman coded.
pub split: usize,
}
/// Parse maker note `0x0096` for a `bits`-bit image. `order` is the maker note's byte order.
@@ -116,6 +127,7 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result<Decod
for (i, s) in seeds.iter_mut().flatten().enumerate() {
*s = u16_at(2 + 2 * i).ok_or_else(short)? as i32;
}
let mut split = 0;
let encoding = match (v0, v1) {
(0x46, _) => Encoding::Lossless,
(0x44, 0x10 | 0x20 | 0x40) => {
@@ -127,12 +139,7 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result<Decod
if v1 == 0x10 {
Encoding::Lossy(points)
} else {
let split = u16_at(562).unwrap_or(0);
if split != 0 {
return Err(RawError::Unsupported(format!(
"Nikon \"lossy after split\" compressed NEF (split at row {split}) is not decoded yet"
)));
}
split = u16_at(562).unwrap_or(0) as usize;
// type 2: `n` points covering `range` codes, one every `range / (n - 1)`; the curve's first interval
// rises by exactly that step (identity in the dark part) and the table version sets the span:
// `0x20` covers all 2^bits codes, `0x40` the first quarter (see the module docs)
@@ -156,7 +163,66 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result<Decod
}
_ => return Err(RawError::Unsupported(format!("Nikon compressed NEF version {v0:#04x} {v1:#04x}"))),
};
Ok(DecodeTable { encoding, seeds })
Ok(DecodeTable { encoding, seeds, split })
}
/// Difference-category prefix codes of the fixed-rate section: `(prefix bits, prefix length, category n)`.
/// A word of `W` bits is a prefix followed by `W - length` bits of the T.81 additional bits of category `n`.
/// (The 14-bit `010` = category 12 never occurs in the samples; its category is deduced.)
const FIXED_12: &[(u32, u32, u32)] = &[
(0b00, 2, 9),
(0b010, 3, 10),
(0b011, 3, 8),
(0b100, 3, 7),
(0b101, 3, 6),
(0b110, 3, 5),
(0b1110, 4, 4),
(0b11110, 5, 3),
(0b111110, 6, 2),
(0b1111110, 7, 1),
(0b11111110, 8, 0),
];
const FIXED_14: &[(u32, u32, u32)] = &[
(0b00, 2, 8),
(0b010, 3, 12),
(0b011, 3, 11),
(0b100, 3, 10),
(0b101, 3, 9),
(0b110, 3, 7),
(0b1110, 4, 6),
(0b11110, 5, 5),
(0b111110, 6, 4),
(0b1111110, 7, 3),
(0b11111110, 8, 2),
(0b111111110, 9, 1),
(0b1111111110, 10, 0),
];
/// Word (`bits - 4` bits) to dequantised difference for the fixed-rate section; `None` marks the unused all-ones word.
fn fixed_word_table(bits: u32) -> Vec<Option<i32>> {
let (w, codes) = if bits == 12 { (8, FIXED_12) } else { (10, FIXED_14) };
let mut lut = vec![None; 1usize << w];
for &(prefix, len, n) in codes {
let kept = w - len;
for m in 0..1u32 << kept {
let e = n.saturating_sub(kept);
let ex = (m << e) as i32;
let half = (1i32 << e) >> 1;
let d = if n == 0 {
0
} else if ex >= 1 << (n - 1) {
ex + half
} else if e > 0 {
ex + half - (1 << n)
} else {
ex - ((1 << n) - 1)
};
if let Some(slot) = lut.get_mut(((prefix << kept) | m) as usize) {
*slot = Some(d);
}
}
}
lut
}
/// Decode a `w × h` Nikon Huffman-compressed strip `src` with `bits`-bit samples.
@@ -183,6 +249,11 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa
Encoding::Lossless => (&[], (1i32 << bits) - 1),
Encoding::Lossy(curve) => (curve, curve.len() as i32 - 1),
};
if table.split != 0 && table.split >= h {
return Err(RawError::Corrupt(format!("NEF: split row {} outside the {h} rows", table.split)));
}
let fixed = if table.split != 0 { fixed_word_table(bits) } else { Vec::new() };
let word = bits - 4;
let mut out = vec![0u16; n];
let mut stream = Bits::new(src);
let mut vpred = table.seeds;
@@ -191,7 +262,12 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa
let seeds = vpred.get_mut(y & 1).ok_or(RawError::Limit("NEF row"))?;
let mut hpred = [0i32; 2];
for (x, o) in row.iter_mut().enumerate() {
let d = diff(&mut stream, &huff).ok_or_else(|| RawError::Corrupt(format!("NEF: invalid Huffman code at row {y}")))?;
let d = if table.split != 0 && y >= table.split {
let code = stream.get(word) as usize;
fixed.get(code).copied().flatten().ok_or_else(|| RawError::Corrupt(format!("NEF: invalid fixed-rate word at row {y}")))?
} else {
diff(&mut stream, &huff).ok_or_else(|| RawError::Corrupt(format!("NEF: invalid Huffman code at row {y}")))?
};
let p = if x < 2 {
let s = seeds.get_mut(x).ok_or(RawError::Limit("NEF column"))?;
*s += d;
@@ -202,7 +278,7 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa
hpred[x & 1]
};
let v = p.clamp(0, max);
clipped += (v != p) as usize;
clipped += (v != p && (table.split == 0 || y < table.split)) as usize;
*o = if lut.is_empty() { v as u16 } else { lut.get(v as usize).copied().unwrap_or(0) };
}
// the camera pads the strip; reading more than a few bytes past its end means truncated or corrupt data
@@ -210,6 +286,19 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa
return Err(RawError::Corrupt(format!("NEF: compressed data ends at row {y} of {h}")));
}
}
if table.split != 0 {
// the fixed-rate section must fill the rest of the strip: whole rows of w words and 0..7 bits of padding.
// A strip may hold a few more rows than the image (one body stores 4022 rows for 4020); anything else means
// the file does not follow the rule, so refuse it instead of decoding it wrongly.
let left = (src.len() * 8).checked_sub(stream.consumed_bits());
let row_bits = w * word as usize;
if !left.is_some_and(|l| l % row_bits < 8 && l / row_bits <= 4) {
return Err(RawError::Unsupported(format!(
"Nikon \"lossy after split\" NEF whose fixed-rate section (from row {}) does not end within the last byte",
table.split
)));
}
}
// a wrong table / corrupt stream drifts out of range quickly; real files stay inside (a handful of edge pixels)
if clipped > n / 1000 + 16 {
return Err(RawError::Corrupt(format!("NEF: {clipped} decoded values out of range")));
@@ -293,7 +382,7 @@ mod tests {
fn lossless(bits: u32) -> DecodeTable {
let s = 1 << (bits - 3);
DecodeTable { encoding: Encoding::Lossless, seeds: [[s, s], [s, s]] }
DecodeTable { encoding: Encoding::Lossless, seeds: [[s, s], [s, s]], split: 0 }
}
#[test]
@@ -417,6 +506,84 @@ mod tests {
assert!(parse_table(&odd, ByteOrder::Little, 12).is_err());
}
/// Pack a string of `0`/`1` (spaces ignored) MSB first, padding the last byte with one-bits like the camera.
fn pack(bits: &str) -> Vec<u8> {
let b: Vec<bool> = bits.chars().filter(|c| !c.is_whitespace()).map(|c| c == '1').collect();
b.chunks(8).map(|c| (0..8).fold(0u8, |a, i| (a << 1) | c.get(i).copied().unwrap_or(true) as u8)).collect()
}
fn split_table(v1: u8, seed: u16, step: u16, white: u16, split: u16) -> DecodeTable {
let mut t = hand_table(v1, seed, step, white);
t[562..564].copy_from_slice(&split.to_le_bytes());
parse_table(&t, ByteOrder::Little, if step == 4 { 12 } else { 14 }).unwrap()
}
/// 12-bit "lossy after split": row 0 Huffman coded, rows 1 and 2 as 8-bit words that follow the last Huffman
/// bit directly. Bit strings written by hand from the rule (prefix, then the kept top bits of the extra bits).
const SPLIT_12: &str = "011 100 11110 1110 0 11110 11111110 11110100 11100111 01110000 00000001 11111110 01111111 11111110";
#[test]
fn lossy_after_split_known_answers_12_bit() {
let t = split_table(0x40, 252, 4, 4095, 1);
assert_eq!(t.split, 1);
let out = decode(&pack(SPLIT_12), 4, 3, 12, &t).unwrap();
// row 1: d = 0 (n0), +4 (n3, exact), -8 (n4, exact), +132 (n8 keeps 5 bits: 16 << 3 plus half a bin, 4);
// row 2: -500 (n9 keeps 6 bits: bin 1 of the negative side, centre -508 + 8), then +252 on the running
// value -244 which is not clamped (8, not 252)
assert_eq!(out, [256, 252, 255, 252, 252, 256, 244, 388, 0, 252, 8, 252]);
}
#[test]
fn lossy_after_split_known_answers_14_bit() {
let t = split_table(0x40, 1008, 16, 16383, 1);
// row 0 as in the 0x40 test above; row 1 in 10-bit words: 0 (n0), +32 (n6, exact), -254 (n8, exact),
// +510 (n9 keeps 7 bits: 127 << 2 plus 2)
let bits = "1100 100 111110 11110 0 111110 1111111110 1110100000 0000000001 1011111111";
let out = decode(&pack(bits), 4, 2, 14, &t);
// word 3 is `00` + 8 bits: 00 00000001 -> n8 extra 00000001 = -254
assert_eq!(out.unwrap(), [1012, 1008, 1011, 1008, 1008, 1040, 754, 1550]);
}
#[test]
fn lossy_after_split_dequantisation_is_symmetric() {
let lut = fixed_word_table(12);
assert_eq!((lut[0b00_000000], lut[0b00_111111]), (Some(-508), Some(508)));
assert_eq!((lut[0b011_00000], lut[0b011_11111]), (Some(-252), Some(252)));
assert_eq!(lut[0b11111110], Some(0));
assert_eq!(lut[0xff], None);
let lut = fixed_word_table(14);
assert_eq!((lut[0b00_00000000], lut[0b00_11111111]), (Some(-255), Some(255)));
assert_eq!(lut[0b101_0000000], Some(-510));
assert_eq!(lut[0b1111111110], Some(0));
assert_eq!(lut[0x3ff], None);
for bits in [12, 14] {
let lut = fixed_word_table(bits);
assert_eq!(lut.iter().filter(|d| d.is_none()).count(), 1, "{bits}-bit: only the all-ones word is unused");
}
}
#[test]
fn lossy_after_split_refuses_strips_that_do_not_follow_the_rule() {
let t = split_table(0x40, 252, 4, 4095, 1);
let mut longer = pack(SPLIT_12);
longer.extend_from_slice(&[0xff; 3]);
assert!(matches!(decode(&longer, 4, 3, 12, &t), Err(RawError::Unsupported(_))));
let shorter = &pack(SPLIT_12)[..10];
assert!(decode(shorter, 4, 3, 12, &t).is_err());
// the unused all-ones word
let bad = pack("011 100 11110 1110 0 11110 11111111 11111110 11111110 11111110");
assert!(matches!(decode(&bad, 4, 2, 12, &t), Err(RawError::Corrupt(_))));
// a split row beyond the image
assert!(matches!(decode(&pack(SPLIT_12), 4, 1, 12, &t), Err(RawError::Corrupt(_))));
// through the container
let mut table = hand_table(0x40, 252, 4, 4095);
table[562..564].copy_from_slice(&1u16.to_le_bytes());
let ok = crate::decode(&nef_file(pack(SPLIT_12), 4, 3, 12, table.clone(), ByteOrder::Little)).unwrap();
assert_eq!(ok.data, crate::RawData::U16(vec![256, 252, 255, 252, 252, 256, 244, 388, 0, 252, 8, 252]));
let e = crate::decode(&nef_file(longer, 4, 3, 12, table, ByteOrder::Little));
assert!(matches!(e, Err(RawError::Unsupported(_))), "{e:?}");
}
#[test]
fn lossy_type1_full_curve() {
let mut t = vec![0x44, 0x10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 5];
@@ -436,7 +603,7 @@ mod tests {
let corrupt = |r: Result<DecodeTable>| matches!(r, Err(RawError::Corrupt(_)));
let mut split = lossy_table(12, ByteOrder::Big);
split[562..564].copy_from_slice(&345u16.to_be_bytes());
assert!(unsupported(parse_table(&split, ByteOrder::Big, 12)));
assert_eq!(parse_table(&split, ByteOrder::Big, 12).unwrap().split, 345);
assert!(unsupported(parse_table(&[0x49, 0x30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ByteOrder::Big, 12)));
assert!(unsupported(parse_table(&lossy_table(12, ByteOrder::Big), ByteOrder::Big, 16)));
assert!(corrupt(parse_table(&[], ByteOrder::Big, 12)));
@@ -502,11 +669,11 @@ mod tests {
let src = encode(&img, w, &LOSSY_12, t.seeds);
let r = crate::decode(&nef_file(src.clone(), w as u32, h as u32, 12, table.clone(), ByteOrder::Little)).unwrap();
assert_eq!(r.data, crate::RawData::U16(img.iter().map(|&v| curve[v as usize]).collect()));
// the split variant falls back to the preview
// a split file whose strip does not follow the fixed-rate rule is refused, never decoded wrongly
let mut split = table;
split[562..564].copy_from_slice(&3u16.to_le_bytes());
let e = crate::decode(&nef_file(src, w as u32, h as u32, 12, split, ByteOrder::Little));
assert!(matches!(e, Err(RawError::Unsupported(_))), "{e:?}");
assert!(e.is_err(), "{e:?}");
}
#[test]
+1 -1
View File
@@ -65,7 +65,7 @@ Take the first one nobody is working on.
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed ORF, Nikon
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
2. **LR-IMP-FORMATS** (P0): CR3 lossless Bayer and version 0x100/0x200 C-RAW now decodes (M50/R100/R8 full sensor exact). Remaining unverified CRX variants, compressed RAF / ORF, Nikon
"lossy after split" NEF, Canon sRAW; AVIF decode (HEIC decodes behind the codecs' non-default `heif` feature:
Canon sRAW; AVIF decode (HEIC decodes behind the codecs' non-default `heif` feature:
the optional `lightcraft-heif` crate on heic-rs, the same decoder and policy as PhotoCraft). Clean-room, from prose descriptions only (see
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2