From 3100cc1a5c72c8a5f7c9702f70d0be629d301707 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Fri, 9 Oct 2026 17:27:06 +0200 Subject: [PATCH 1/2] LR-IMP-CAMERA-COVERAGE: Sony black levels from the SR2 layout and the lossless M/S pedestal Part of #535 (items 2 and 3). - SR2SubIFD black level: decrypt the directory's first entry (always the black level, 0x7310) to find where its value sits, and decrypt it with the keystream known for that position (1638, 2166, 2418, 2510). The fixed read at 2510 gave 354-365 on the DSLR-A450/A500/A550 and 975 on the DSLR-A700, where ExifTool and the data floor say 512 (A700 render: dE 20.1 -> 8.8, dL* -9.8 -> 0.0). Keystreams recovered black-box (plain 0x7310 and exiftool -v3 as known plaintext), consistent over 115 ARWs from 97 bodies, 15 layouts. - Downsized lossless M/S ARWs (linear YCbCr): black 1024 and white 16895, i.e. the recorded 512 plus 512, measured against same-scene full-size codings on 12 files from 4 bodies (offset 1015-1052, scale 0.98-1.02). The 512 pedestal lifted shadows by ~3 % of white; M/S renders now match their full-size twins (7RM5 M dE 6.7 -> 3.1). - Only these 16 of 140 Sony files change; render cache version 24. - Tests: synthetic SR2 blocks for every known layout (incl. the A700 trap), YCbCr black/white; corpus test of black levels against the data floor; A500 and A700 samples added to the corpus list. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/arw.rs | 169 ++++++++++++++++++++++++++++------- crates/raw/tests/corpus.rs | 61 +++++++++++++ docs/parity.md | 2 +- xtask/src/main.rs | 12 +++ 5 files changed, 210 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1231221b..4a997d76 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 22; +pub const RENDER_CACHE_VERSION: u64 = 24; /// 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]; diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 7c299046..ab14951e 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -17,8 +17,9 @@ //! White balance: the raw IFD's `0x7313` levels when present (bodies from about 2017 on); otherwise the //! `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]). Without //! either, an ARW opened with unit multipliers, i.e. a strong green cast (issue #148). Black level: the raw IFD's -//! `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]; 800 rather than the -//! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). +//! `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`SR2_HEAD_KEYSTREAM`]; 800 rather than the +//! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). Downsized lossless +//! (YCbCr) data sit [`YCBCR_OFFSET`] above that. //! //! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose //! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go @@ -302,6 +303,18 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result> { Ok(out) } +/// Sony's downsized lossless ARWs (M and S sizes: linear YCbCr, already white-balanced) are stored this far above the +/// sensor black level the raw IFD records (`0x7310`, 512 in every file seen); their reference levels say Y black 0. +/// Measured against a full-size coding of the same scene shot seconds apart, on the pixels where both camera JPEGs +/// agree: YCbCr = k · (full-size − black) · WB + b, with b 1015–1052 and green k 0.98–1.02, on 11 of 12 files from +/// four bodies (ILCE-7M4 M, S and APS-C S; ILCE-7RM5 M, S and S35 S; ILCE-7CR M, S and S35 S; ILCE-9M3 M, S and +/// APS-C S; the twelfth's reference was shot 24 s later, 7 % brighter). Their darkest 0.1 % lies at 1047–1127. So +/// black and white both move up by 512: the scale stays the full-size raw's, and an M/S file renders like its +/// full-size twin. Subtracting only 512 left a pedestal of 3 % of white, lifting the shadows (issue #535). +const YCBCR_OFFSET: f32 = 512.0; +/// White of the YCbCr data before [`YCBCR_OFFSET`]: the reference levels' Y white (required by [`read_ycbcr_tiles`]). +const YCBCR_WHITE: f32 = 16383.0; + /// Downsized M/S ARWs are linear YCbCr 4:2:0 / 4:2:2, not CFA, despite retaining dummy CFA tags. /// T.81 supplies the lossless coding; TIFF 6.0 supplies the YCbCr coefficients and reference /// levels. Sony's reference chroma black and white are identical (the neutral offset). @@ -371,23 +384,40 @@ fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Re const SR2_SUBIFD_OFFSET: u16 = 0x7200; const SR2_SUBIFD_LENGTH: u16 = 0x7201; const SR2_SUBIFD_KEY: u16 = 0x7221; -/// Black level inside the encrypted `SR2SubIFD` (bodies that do not write `0x7310` in the raw IFD), recovered by -/// black-box known-plaintext analysis of CC0 raw.pixls.us samples; no description of Sony's cipher was used. +/// The encrypted `SR2SubIFD` (bodies that do not write `0x7310` in the raw IFD keep their black level only there) +/// is a TIFF directory whose bytes are XOR-ed with a keystream. Everything below was recovered by black-box +/// known-plaintext analysis of CC0 raw.pixls.us samples; no description of Sony's cipher and no decoder source was +/// used. Plaintext came from the plain `0x7310` some bodies also write and from the decrypted directory ExifTool +/// prints (`exiftool -v3`, used as a tool). /// -/// Every Sony ARW we examined (16 files, 15 bodies, 2012–2021) stores the same key (`11 22 33 44`), so its -/// keystream is the same in every file. The DSC-RX100M6 and ILCE-7M3 also write their black levels in plain form -/// (`0x7310`: 800 and 512); XOR-ing their `SR2SubIFD`s against each other matched `pack(512×4) ^ pack(800×4)` at -/// exactly one position, byte 2510, which gave the eight keystream bytes there. Applied to the other files at that -/// position they decode to four equal levels that agree with each sensor's dark-pixel floor: 800 on every 1″-sensor -/// body (RX10, RX100 … RX100M5), 512 on the APS-C/full-frame ones (NEX-5N/6, SLT-A77, ILCE-6000/7/7M2/7RM2), in -/// both `SR2SubIFD` sizes seen (29252 and 56958 bytes). Newer layouts (ILCE-7M4, 35398 bytes) put other data there -/// and are rejected by the plausibility checks; those bodies write `0x7310` anyway. -const SR2_BLACK_AT: usize = 2510; -const SR2_BLACK_KEYSTREAM: [u8; 8] = [0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]; +/// Every ARW examined stores the same key (`11 22 33 44`), and the keystream depends only on the position in the +/// `SR2SubIFD`: the same bytes at each position in 115 files from 97 bodies (DSLR-A200 to ILCE-9M3) and 15 directory layouts +/// (lengths 25150 … 62112 bytes). In all of them the directory's first entry is the black level (tag `0x7310`, +/// SHORT, count 4), whose value lives at a layout-dependent position (1638 … 2786). So the first entry is +/// decrypted ([`SR2_HEAD_KEYSTREAM`]) to find that position, and the value is decrypted with the keystream known +/// for it ([`SR2_BLACK_KEYSTREAMS`]). Reading a fixed position instead (2510, right for the 29252, 33210 and +/// 56958-byte layouts) read other data on the DSLR-A450/A500/A550 (27152 bytes: 354–365) and the DSLR-A700 +/// (62112 bytes: 975), whose black is 512 by ExifTool and by the data floor (issue #535). const SR2_KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; +/// Keystream of the `SR2SubIFD`'s first 14 bytes: entry count and first entry (tag, type, count, value offset). +/// The first 16 keystream bytes were identical in all 115 files. +const SR2_HEAD_KEYSTREAM: [u8; 14] = [0x54, 0xc2, 0xc4, 0xd6, 0x49, 0xc4, 0x78, 0xbc, 0x34, 0xae, 0x2f, 0x8e, 0x25, 0xd8]; +/// Keystream at the black-level value, by its position in the `SR2SubIFD`, for the layouts of bodies without a +/// plain `0x7310` (bodies with one: 2030, 2102, 2186, 2558 and 2786; not needed). 1638: DSLR-A700 (and the +/// undecoded A200/A300/A350); 2166: DSLR-A450/A500/A550 (and the undecoded A230 … A390, A850, A900); 2418: +/// DSLR-A560/A580, NEX-3/5/C3, SLT-A33/A35/A55; 2510: NEX-5N … NEX-7, SLT-A37 … A99, ILCE-3000 … 7RM2, ILCA-68/77M2/ +/// 99M2, RX10/RX100/RX1 series. Each was the same in every file whose black level sits there (4, 10, 8 and 55 +/// files), and decodes to that file's black level (512, or 800 on the 1″ sensors) at its dark-pixel floor. +const SR2_BLACK_KEYSTREAMS: [(usize, [u8; 8]); 4] = [ + (1638, [0x18, 0x95, 0x65, 0xf0, 0x18, 0x8f, 0x61, 0xc6]), + (2166, [0xc6, 0x17, 0x02, 0xcb, 0x89, 0x21, 0xbc, 0x43]), + (2418, [0xd1, 0xef, 0xda, 0xfe, 0x50, 0xf3, 0xd0, 0xbe]), + (2510, [0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]), +]; -/// The per-channel black level from the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]), in 14-bit units; `None` -/// unless the file uses the known key and the four decoded levels are equal and plausible. +/// The per-channel black level from the encrypted `SR2SubIFD` (see [`SR2_HEAD_KEYSTREAM`]), in 14-bit units; `None` +/// unless the file uses the known key, the directory's first entry is a black level at a known position and the +/// four decoded levels are equal and plausible. fn sr2_black(bytes: &[u8], ifd0: &Ifd, order: lightcraft_tiff::ByteOrder) -> Option { let private = ifd0.bytes(t::DNG_PRIVATE_DATA)?; let at = order.read_u32(private, 0)? as u64; @@ -397,17 +427,26 @@ fn sr2_black(bytes: &[u8], ifd0: &Ifd, order: lightcraft_tiff::ByteOrder) -> Opt return None; } let (start, len) = (usize::try_from(sr2.u64(SR2_SUBIFD_OFFSET)?).ok()?, usize::try_from(sr2.u64(SR2_SUBIFD_LENGTH)?).ok()?); - if len < SR2_BLACK_AT + 8 { - return None; - } - sr2_black_levels(bytes.get(start.checked_add(SR2_BLACK_AT)?..start.checked_add(SR2_BLACK_AT + 8)?)?, order) + sr2_black_in(bytes.get(start..start.checked_add(len)?)?, start, order) } -/// Decipher the eight `SR2SubIFD` bytes at [`SR2_BLACK_AT`] into one black level (the mean of four near-equal, -/// plausible per-channel levels). -fn sr2_black_levels(cipher: &[u8], order: lightcraft_tiff::ByteOrder) -> Option { - let plain: Vec = cipher.iter().zip(SR2_BLACK_KEYSTREAM).map(|(c, k)| c ^ k).collect(); - let levels: Vec = (0..4).filter_map(|i| order.read_u16(&plain, 2 * i)).collect(); +/// [`sr2_black`] on the encrypted `SR2SubIFD` bytes `block`, which start at file offset `start` (the directory's +/// value offsets are file offsets). +fn sr2_black_in(block: &[u8], start: usize, order: lightcraft_tiff::ByteOrder) -> Option { + let head: Vec = block.get(..SR2_HEAD_KEYSTREAM.len())?.iter().zip(SR2_HEAD_KEYSTREAM).map(|(c, k)| c ^ k).collect(); + let (tag, kind, count, value_at) = (order.read_u16(&head, 2)?, order.read_u16(&head, 4)?, order.read_u32(&head, 6)?, order.read_u32(&head, 10)?); + if tag != BLACK_LEVEL || kind != 3 || count != 4 { + return None; + } + let pos = usize::try_from(value_at).ok()?.checked_sub(start)?; + let &(_, keystream) = SR2_BLACK_KEYSTREAMS.iter().find(|(p, _)| *p == pos)?; + let plain: Vec = block.get(pos..pos.checked_add(8)?)?.iter().zip(keystream).map(|(c, k)| c ^ k).collect(); + sr2_black_levels(&plain, order) +} + +/// One black level from the four deciphered per-channel levels (their mean), when they are near-equal and plausible. +fn sr2_black_levels(plain: &[u8], order: lightcraft_tiff::ByteOrder) -> Option { + let levels: Vec = (0..4).filter_map(|i| order.read_u16(plain, 2 * i)).collect(); let (&lo, &hi) = (levels.iter().min()?, levels.iter().max()?); (levels.len() == 4 && (64..=4096).contains(&lo) && hi - lo <= 64).then(|| levels.iter().map(|&v| f32::from(v)).sum::() / 4.0) } @@ -507,14 +546,15 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 }; let black = match raw.f64s(BLACK_LEVEL).as_deref() { - Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::() / v.len() as f64) as f32), + Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::() / v.len() as f64) as f32 + YCBCR_OFFSET), Some([a, b, c, d]) => { BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } } + _ if linear_rgb => BlackLevel::uniform(default_black + YCBCR_OFFSET), _ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)), }; let white = if linear_rgb { - 16383.0 + YCBCR_WHITE + YCBCR_OFFSET } else { raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits)) }; @@ -591,6 +631,9 @@ mod tests { assert!(full.cfa.is_none()); assert_eq!(full.data.len(), 48); assert_eq!(full.wb_multipliers, Some([1.0; 3])); + // the YCbCr data sit 512 above the recorded sensor black level; white moves with it + assert_eq!((full.black.mean(), full.white_at(0)), (1024.0, 16895.0)); + assert_eq!((header.black.mean(), header.white_at(0)), (1024.0, 16895.0)); let RawData::U16(ref pixels) = full.data else { panic!("integer ARW"); }; @@ -735,16 +778,74 @@ mod tests { #[test] fn sr2_black_level_decodes_and_rejects_garbage() { use lightcraft_tiff::ByteOrder::Little; - let cipher = - |levels: [u16; 4]| -> Vec { levels.iter().flat_map(|v| v.to_le_bytes()).zip(SR2_BLACK_KEYSTREAM).map(|(p, k)| p ^ k).collect() }; - assert_eq!(sr2_black_levels(&cipher([800; 4]), Little), Some(800.0)); - assert_eq!(sr2_black_levels(&cipher([512, 512, 514, 512]), Little), Some(512.5)); - assert_eq!(sr2_black_levels(&cipher([65320, 64928, 65260, 488]), Little), None); // ILCE-7M4's layout - assert_eq!(sr2_black_levels(&cipher([0; 4]), Little), None); - assert_eq!(sr2_black_levels(&cipher([800; 4])[..5], Little), None); + let plain = |levels: [u16; 4]| -> Vec { levels.iter().flat_map(|v| v.to_le_bytes()).collect() }; + assert_eq!(sr2_black_levels(&plain([800; 4]), Little), Some(800.0)); + assert_eq!(sr2_black_levels(&plain([512, 512, 514, 512]), Little), Some(512.5)); + assert_eq!(sr2_black_levels(&plain([65320, 64928, 65260, 488]), Little), None); + assert_eq!(sr2_black_levels(&plain([0; 4]), Little), None); + assert_eq!(sr2_black_levels(&plain([800; 4])[..5], Little), None); assert_eq!(sr2_black_levels(&[], Little), None); } + /// An encrypted `SR2SubIFD` of `len` bytes at file offset `start` whose first entry is a black level stored at + /// `value_pos` (relative), holding `levels`; `other` puts four more levels at position 2510 (where the old + /// fixed read looked). + fn sr2_block(start: usize, len: usize, tag: u16, value_pos: usize, levels: [u16; 4], other: Option<[u16; 4]>) -> Vec { + let mut plain = vec![0u8; len]; + plain[0..2].copy_from_slice(&180u16.to_le_bytes()); + plain[2..4].copy_from_slice(&tag.to_le_bytes()); + plain[4..6].copy_from_slice(&3u16.to_le_bytes()); + plain[6..10].copy_from_slice(&4u32.to_le_bytes()); + plain[10..14].copy_from_slice(&((start + value_pos) as u32).to_le_bytes()); + for (i, v) in levels.iter().enumerate() { + plain[value_pos + 2 * i..value_pos + 2 * i + 2].copy_from_slice(&v.to_le_bytes()); + } + if let Some(o) = other { + for (i, v) in o.iter().enumerate() { + plain[2510 + 2 * i..2510 + 2 * i + 2].copy_from_slice(&v.to_le_bytes()); + } + } + // encrypt the bytes whose keystream is known (the rest stays plain: it is never read) + for (b, k) in plain.iter_mut().zip(SR2_HEAD_KEYSTREAM) { + *b ^= k; + } + for (pos, ks) in SR2_BLACK_KEYSTREAMS { + if let Some(dst) = plain.get_mut(pos..pos + 8) { + for (b, k) in dst.iter_mut().zip(ks) { + *b ^= k; + } + } + } + plain + } + + #[test] + fn sr2_black_level_follows_the_first_entry_to_its_layout() { + use lightcraft_tiff::ByteOrder::Little; + let start = 37584; + // every layout with a known keystream, e.g. the DSLR-A700's (62112 bytes, value at 1638) and the + // DSLR-A450/A500/A550's (27152 bytes, value at 2166) + for (pos, _) in SR2_BLACK_KEYSTREAMS { + for level in [512, 800] { + let block = sr2_block(start, 27152, BLACK_LEVEL, pos, [level; 4], None); + assert_eq!(sr2_black_in(&block, start, Little), Some(f32::from(level)), "value at {pos}"); + } + } + // the A700's layout holds four other equal values at 2510 (975 each); the old fixed read took those + let a700 = sr2_block(start, 62112, BLACK_LEVEL, 1638, [512; 4], Some([975; 4])); + assert_eq!(sr2_black_in(&a700, start, Little), Some(512.0)); + // not a black-level entry, a value at a position without a known keystream, outside the block, before it + assert_eq!(sr2_black_in(&sr2_block(start, 27152, 0x7311, 2166, [512; 4], None), start, Little), None); + assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2030, [512; 4], None), start, Little), None); + assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None)[..2170], start, Little), None); + let block = sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None); + assert_eq!(sr2_black_in(&block, start + 4000, Little), None, "value offset before the block"); + assert_eq!(sr2_black_in(&block[..10], start, Little), None, "truncated directory"); + assert_eq!(sr2_black_in(&[], start, Little), None); + // implausible levels at a known position + assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2166, [4; 4], None), start, Little), None); + } + #[test] fn quad_tiles_place_cfa_cells() { // 14×12 mosaic in 8×8 tiles (the right and bottom tiles overhang), each tile a 4×4 frame of 2×2 cells, diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ce32565..b89f8154 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -357,6 +357,67 @@ fn corpus_sony_pre2017_colour_metadata() { eprintln!("pre-2017 Sony ARW colour metadata checked on {seen} files"); } +/// Issue #535: Sony black levels against the data. The SR2SubIFD keeps the black level at a position that depends +/// on its layout (the DSLR-A500 and A700 read 365 and 975 from a fixed position, where ExifTool and the data say +/// 512), and the downsized lossless M and S codings (linear YCbCr) sit 512 above the recorded level (1024, with +/// white moved by the same 512). Each file's darkest 0.1 % per channel must lie just above its black level: a black +/// level that is too low leaves a pedestal, one that is too high clips the shadows. +#[test] +fn corpus_sony_black_levels_sit_at_the_data_floor() { + use lightcraft_raw::RawData; + let dir = corpus_root().join("raw"); + // (file, black, white): SR2SubIFD black at three layouts, a plain 0x7310, and the lossless L / M / S codings + let cases = [ + ("arw-sony-a500.arw", 512.0, None), + ("arw-sony-a700.arw", 512.0, None), + ("arw-sony-rx100m3.arw", 800.0, None), + ("arw-sony-a7m4-lossless-l.arw", 512.0, Some(16383.0)), + ("arw-sony-a7m4-lossless-m.arw", 1024.0, Some(16895.0)), + ("arw-sony-a7m4-lossless-s.arw", 1024.0, Some(16895.0)), + ]; + let mut seen = 0; + for (name, black, white) in cases { + let bytes = match std::fs::read(dir.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {name} absent"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(img.black.mean(), black, "{name}: black level"); + if let Some(white) = white { + assert_eq!(img.white_at(0), white, "{name}: white level"); + } + let RawData::U16(ref data) = img.data else { panic!("{name}: integer samples expected") }; + // per CFA position (2×2) or per colour sample, inside the default crop, about a million samples each + let channels = if img.cpp == 3 { 3 } else { 4 }; + let (a, c) = (img.active_area, img.crop); + let step = (c.width * c.height / 1_000_000).max(1); + let mut samples = vec![Vec::new(); channels]; + for (k, (x, y)) in (c.y..c.y + c.height).flat_map(|y| (c.x..c.x + c.width).map(move |x| (x + a.x, y + a.y))).enumerate() { + if (k / 2) % step != 0 { + continue; + } + if img.cpp == 3 { + for (s, v) in samples.iter_mut().enumerate() { + v.push(data[(y * img.width + x) * 3 + s]); + } + } else { + samples[(y % 2) * 2 + x % 2].push(data[y * img.width + x]); + } + } + for (i, mut v) in samples.into_iter().enumerate() { + v.sort_unstable(); + let floor = f32::from(v[v.len() / 1000]); + assert!((black - 64.0..=black + 128.0).contains(&floor), "{name}: channel {i}: darkest 0.1 % at {floor}, black level {black}"); + } + seen += 1; + } + eprintln!("Sony black levels checked against the data floor on {seen} files"); +} + /// The embedded JPEG as linear RGB, reduced to `gw × gh` cells. fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> { use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; diff --git a/docs/parity.md b/docs/parity.md index 4c27755b..8300b7fc 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -144,7 +144,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`), Hasselblad 3FR with uncompressed raw data (X1D, X1D II, X2D, CFV 100C: a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`; the lossless-JPEG 3FR and FFF files use scan selection value 8 and stay preview-only), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; the encrypted SR2 black level is now found through its directory's first entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 115 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cfdec92a..f1fa7eee 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -356,6 +356,18 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/ILCE-7RM2/12-bit-uncompressed.ARW", "71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39", ), + // older DSLRs whose SR2SubIFD keeps the black level elsewhere than the bodies above (#535): A500 (27152-byte + // layout, like the A450/A550), A700 (62112 bytes) + ( + "arw-sony-a500.arw", + "https://raw.pixls.us/data/Sony/DSLR-A500/DSC02421.ARW", + "1407fb596a391df67c15b90026a1a76702a2e7386dbdff2f0815b903ea49cead", + ), + ( + "arw-sony-a700.arw", + "https://raw.pixls.us/data/Sony/DSLR-A700/DSC07249.ARW", + "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40", + ), // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples ( "cr2-canon-40d.cr2", From 179910c020d0359eb549e9326c6911259b51ee34 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Fri, 9 Oct 2026 20:46:51 +0200 Subject: [PATCH 2/2] LR-IMP-CAMERA-COVERAGE: Sony ARW white balance and black level from one SR2SubIFD decryptor Part of #535 (item 1). Stacked on #542 (SR2 black levels); supersedes its positional keystream tables. - White balance is WB_RGGBLevels (0x7313), the gains the camera applied: the raw IFD's plain copy when present, else the copy in the encrypted SR2SubIFD, which every ARW from the DSLR-A200 on carries (identical to the plain one on all 57 CC0 files that have both). The maker note's Tag2010 gains stay only as a last resort. - 14 bodies had no white balance and rendered green whenever the camera look was rejected (A450 ... A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C). On others Tag2010 missed the applied gains by up to 100 % (ILCE-3500 at 5600 K: R -31 / B +101 % against the camera JPEG's neutral pixels, now +4 / +0 %; ILCE-7S on Shade -9 / +15 -> -1 / +1 %; SLT-A57 -25 / +26 -> -6 / -3 %). - New vendor/sr2.rs decrypts the whole SR2SubIFD, found black-box: exiftool -v5's decrypted dump as known plaintext gives the keystream (key 11 22 33 44, the same at every position in 126 CC0 ARWs from 97 bodies, 15 layouts); an exhaustive search over lag pairs found k[n] = k[n-63] ^ k[n-127] on 32-bit words, so the first 127 words generate the rest, matching all 126 files (5.2 MB of keystream). - The SR2 black level (0x7310) is now read through the same decryptor, replacing #542's head and per-position keystream tables: one SR2 decryption path. Identical results on all 131 Sony files (every value #542 decoded; the 51 it could not are the files' plain 0x7310). Unit tests pin the keystream against answers recorded without the generator: every byte #542 and #148 had recovered, the SHA-256 of the whole 62112-byte keystream recovered from the CC0 DSLR-A700 sample, and the encrypted WB_RGGBLevels of four CC0 files (one per layout) with the levels ExifTool decrypts. - Sweep (4f350c4 -> this): gains equal ExifTool's decrypted WB_RGGBLevels on all 100 decoded CC0 CFA files; on 83 files with >= 200 JPEG-neutral pixels, within 10 % of the camera's applied gains 71 -> 83, over 10 % 5 -> 0. Accepted fits unchanged (max 0.15 dE); forced fallback median dE 18.50 -> 18.06, neutral-area cast on the 22 changed files 8.8 -> 1.2. - Corpus test for five CC0 samples (A500, A33, A700, ILCE-3500 5600 K, ILCE-7S Shade; three added to RAW_SAMPLES); the pre-2017 test's gains move to WB_RGGBLevels (RX100 III, RX100, ILCE-7RM2: within 2.6 % of the old Tag2010 values in Auto). Synthetic tests for decryption, directory parsing, source precedence, black level through the full DNGPrivateData chain, and hostile input; #542's layout tests (incl. the A700 trap) now run through the decryptor. - Render cache version 25. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/arw.rs | 299 +++++++++++++++++++++++----------- crates/raw/src/vendor/mod.rs | 1 + crates/raw/src/vendor/sr2.rs | 301 +++++++++++++++++++++++++++++++++++ crates/raw/tests/corpus.rs | 46 +++++- docs/parity.md | 4 +- xtask/src/main.rs | 15 +- 8 files changed, 566 insertions(+), 106 deletions(-) create mode 100644 crates/raw/src/vendor/sr2.rs diff --git a/ROADMAP.md b/ROADMAP.md index 32f6a89b..7d2b557e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,8 +175,8 @@ CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pix Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files, -lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered -maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and +lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted +`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from 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. diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 4a997d76..b3f25df5 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 24; +pub const RENDER_CACHE_VERSION: u64 = 25; /// 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]; diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index ab14951e..b0f8c2d6 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -14,12 +14,16 @@ //! a 128-bit little-endian block: 11-bit max, 11-bit min, 4-bit index of the max, 4-bit index of the min and 14 //! seven-bit deltas above the min, scaled by the smallest shift that fits `max − min` into 7 bits. //! -//! White balance: the raw IFD's `0x7313` levels when present (bodies from about 2017 on); otherwise the -//! `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]). Without -//! either, an ARW opened with unit multipliers, i.e. a strong green cast (issue #148). Black level: the raw IFD's -//! `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`SR2_HEAD_KEYSTREAM`]; 800 rather than the -//! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). Downsized lossless -//! (YCbCr) data sit [`YCBCR_OFFSET`] above that. +//! White balance: `WB_RGGBLevels` (`0x7313`), the gains the camera applied. Bodies from about 2017 on write them in +//! plain form in the raw IFD; every ARW from the DSLR-A200 on has them in the encrypted `SR2SubIFD` (see +//! [`super::sr2`]), and the two agree on every file that has both. Only when neither can be read do we fall back on +//! the `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]), which +//! can differ widely from what the camera applied (issue #535). Without any, an ARW opened with unit multipliers, +//! i.e. a strong green cast (issue #148). +//! +//! Black level: the raw IFD's `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`sr2_black`]; 800 +//! rather than the default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). +//! Downsized lossless (YCbCr) data sit [`YCBCR_OFFSET`] above that. //! //! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose //! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go @@ -63,9 +67,12 @@ const DECIPHER: [u8; 256] = { }; /// `Tag2010` layouts (ExifTool "Sony Tag2010a" … "Tag2010i" tables): the models each is documented for and the -/// byte offset of `WB_RGBLevels` (three `u16`, the as-shot white balance as R, G, B gains; Tag2010g and h share -/// the offset). Newer bodies (Tag2010i) also write the levels in plain form in the raw IFD (`0x7313`), which takes -/// precedence. +/// byte offset of `WB_RGBLevels` (three `u16`, R, G, B gains; Tag2010g and h share the offset). Only a fallback +/// for files whose `WB_RGGBLevels` (`0x7313`, plain or in the `SR2SubIFD`) cannot be read. On the 45 CC0 +/// raw.pixls.us ARWs that have both but no plain `0x7313`, the two agree within 5 % on 35 and differ by 8–100 % +/// on the other 10: five of the eight shot with a preset, colour temperature or custom white balance (ILCE-3500 at +/// 5600 K: R −34 %, B +100 %) and five in Auto (SLT-A37: R −32 %, B +53 %). Where the camera JPEG has enough +/// neutral pixels to tell, the gains the camera applied are `WB_RGGBLevels`, not these. const TAG2010_WB: &[(&[&str], usize)] = &[ (&["NEX-5N"], 4476), (&["SLT-A65", "SLT-A77", "NEX-7", "NEX-VG20E"], 4480), @@ -155,9 +162,9 @@ const TAG2010_WB: &[(&[&str], usize)] = &[ ), ]; -/// As-shot white balance from the maker note's enciphered `Tag2010` block (bodies that do not write `0x7313` in -/// the raw IFD: most ARWs before 2017). `model` is the Exif model; Sony appends a regional "V" to some names -/// (SLT-A77V), which the documented lists omit. +/// White balance from the maker note's enciphered `Tag2010` block, the fallback described at [`TAG2010_WB`]. +/// `model` is the Exif model; Sony appends a regional "V" to some names (SLT-A77V), which the documented lists +/// omit. fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<[f32; 3]> { let model = model.trim(); let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?; @@ -172,6 +179,18 @@ fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> O ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r, 1.0, b]) } +/// White-balance gains `[R/G, 1, B/G]` from `WB_RGGBLevels` (R, G, G, B); `None` unless there are four levels +/// with a positive green and plausible ratios. +fn rggb_gains(levels: &[f64]) -> Option<[f32; 3]> { + let &[r, g1, g2, b] = levels else { return None }; + let g = (g1 + g2) / 2.0; + if !g.is_finite() || g <= 0.0 { + return None; + } + let (r, b) = (r / g, b / g); + ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r as f32, 1.0, b as f32]) +} + /// The inverse tone curve: 11-bit code → 14-bit sensor value. pub(crate) fn code_curve(thresholds: &[u64]) -> Vec { let mut bp = [0usize, 4095, 4095, 4095, 4095, 4095]; @@ -380,68 +399,18 @@ fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Re Ok(out) } -/// `SR2Private` tags (ExifTool "Sony SR2Private" table; the IFD is referenced by IFD0's `DNGPrivateData`). -const SR2_SUBIFD_OFFSET: u16 = 0x7200; -const SR2_SUBIFD_LENGTH: u16 = 0x7201; -const SR2_SUBIFD_KEY: u16 = 0x7221; -/// The encrypted `SR2SubIFD` (bodies that do not write `0x7310` in the raw IFD keep their black level only there) -/// is a TIFF directory whose bytes are XOR-ed with a keystream. Everything below was recovered by black-box -/// known-plaintext analysis of CC0 raw.pixls.us samples; no description of Sony's cipher and no decoder source was -/// used. Plaintext came from the plain `0x7310` some bodies also write and from the decrypted directory ExifTool -/// prints (`exiftool -v3`, used as a tool). +/// The black level stored in the encrypted `SR2SubIFD` (`0x7310`; bodies that do not write it in the raw IFD keep +/// it only there), in 14-bit units; `None` unless it is SHORT, count 4, and the four levels are equal and plausible. /// -/// Every ARW examined stores the same key (`11 22 33 44`), and the keystream depends only on the position in the -/// `SR2SubIFD`: the same bytes at each position in 115 files from 97 bodies (DSLR-A200 to ILCE-9M3) and 15 directory layouts -/// (lengths 25150 … 62112 bytes). In all of them the directory's first entry is the black level (tag `0x7310`, -/// SHORT, count 4), whose value lives at a layout-dependent position (1638 … 2786). So the first entry is -/// decrypted ([`SR2_HEAD_KEYSTREAM`]) to find that position, and the value is decrypted with the keystream known -/// for it ([`SR2_BLACK_KEYSTREAMS`]). Reading a fixed position instead (2510, right for the 29252, 33210 and -/// 56958-byte layouts) read other data on the DSLR-A450/A500/A550 (27152 bytes: 354–365) and the DSLR-A700 -/// (62112 bytes: 975), whose black is 512 by ExifTool and by the data floor (issue #535). -const SR2_KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; -/// Keystream of the `SR2SubIFD`'s first 14 bytes: entry count and first entry (tag, type, count, value offset). -/// The first 16 keystream bytes were identical in all 115 files. -const SR2_HEAD_KEYSTREAM: [u8; 14] = [0x54, 0xc2, 0xc4, 0xd6, 0x49, 0xc4, 0x78, 0xbc, 0x34, 0xae, 0x2f, 0x8e, 0x25, 0xd8]; -/// Keystream at the black-level value, by its position in the `SR2SubIFD`, for the layouts of bodies without a -/// plain `0x7310` (bodies with one: 2030, 2102, 2186, 2558 and 2786; not needed). 1638: DSLR-A700 (and the -/// undecoded A200/A300/A350); 2166: DSLR-A450/A500/A550 (and the undecoded A230 … A390, A850, A900); 2418: -/// DSLR-A560/A580, NEX-3/5/C3, SLT-A33/A35/A55; 2510: NEX-5N … NEX-7, SLT-A37 … A99, ILCE-3000 … 7RM2, ILCA-68/77M2/ -/// 99M2, RX10/RX100/RX1 series. Each was the same in every file whose black level sits there (4, 10, 8 and 55 -/// files), and decodes to that file's black level (512, or 800 on the 1″ sensors) at its dark-pixel floor. -const SR2_BLACK_KEYSTREAMS: [(usize, [u8; 8]); 4] = [ - (1638, [0x18, 0x95, 0x65, 0xf0, 0x18, 0x8f, 0x61, 0xc6]), - (2166, [0xc6, 0x17, 0x02, 0xcb, 0x89, 0x21, 0xbc, 0x43]), - (2418, [0xd1, 0xef, 0xda, 0xfe, 0x50, 0xf3, 0xd0, 0xbe]), - (2510, [0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]), -]; - -/// The per-channel black level from the encrypted `SR2SubIFD` (see [`SR2_HEAD_KEYSTREAM`]), in 14-bit units; `None` -/// unless the file uses the known key, the directory's first entry is a black level at a known position and the -/// four decoded levels are equal and plausible. -fn sr2_black(bytes: &[u8], ifd0: &Ifd, order: lightcraft_tiff::ByteOrder) -> Option { - let private = ifd0.bytes(t::DNG_PRIVATE_DATA)?; - let at = order.read_u32(private, 0)? as u64; - let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; - let (sr2, _) = lightcraft_tiff::parse_ifd_at(bytes, at, order, 0, false, &opts).ok()?; - if sr2.bytes(SR2_SUBIFD_KEY)? != SR2_KEY { - return None; +/// Its value sits at a layout-dependent position (1638 … 2786 bytes into the block), which the directory entry +/// gives. Reading a fixed position instead (2510, right for the 29252, 33210 and 56958-byte layouts) read other data +/// on the DSLR-A450/A500/A550 (27152 bytes: 354–365) and the DSLR-A700 (62112 bytes: 975), whose black is 512 by +/// ExifTool and by the data floor (issue #535). +fn sr2_black(sr2: &super::sr2::SubIfd) -> Option { + match sr2.short_bytes(BLACK_LEVEL)? { + (3, plain) if plain.len() == 8 => sr2_black_levels(plain, sr2.order()), + _ => None, } - let (start, len) = (usize::try_from(sr2.u64(SR2_SUBIFD_OFFSET)?).ok()?, usize::try_from(sr2.u64(SR2_SUBIFD_LENGTH)?).ok()?); - sr2_black_in(bytes.get(start..start.checked_add(len)?)?, start, order) -} - -/// [`sr2_black`] on the encrypted `SR2SubIFD` bytes `block`, which start at file offset `start` (the directory's -/// value offsets are file offsets). -fn sr2_black_in(block: &[u8], start: usize, order: lightcraft_tiff::ByteOrder) -> Option { - let head: Vec = block.get(..SR2_HEAD_KEYSTREAM.len())?.iter().zip(SR2_HEAD_KEYSTREAM).map(|(c, k)| c ^ k).collect(); - let (tag, kind, count, value_at) = (order.read_u16(&head, 2)?, order.read_u16(&head, 4)?, order.read_u32(&head, 6)?, order.read_u32(&head, 10)?); - if tag != BLACK_LEVEL || kind != 3 || count != 4 { - return None; - } - let pos = usize::try_from(value_at).ok()?.checked_sub(start)?; - let &(_, keystream) = SR2_BLACK_KEYSTREAMS.iter().find(|(p, _)| *p == pos)?; - let plain: Vec = block.get(pos..pos.checked_add(8)?)?.iter().zip(keystream).map(|(c, k)| c ^ k).collect(); - sr2_black_levels(&plain, order) } /// One black level from the four deciphered per-channel levels (their mean), when they are near-equal and plausible. @@ -545,13 +514,16 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { _ => Cfa::bayer_static("RGGB"), }; let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 }; + // the decrypted SR2SubIFD, read once and only when a plain black level or white balance is missing + let sr2_cell = std::cell::OnceCell::new(); + let sr2 = || sr2_cell.get_or_init(|| super::sr2::SubIfd::read(bytes, ifd0, tiff.order)).as_ref(); let black = match raw.f64s(BLACK_LEVEL).as_deref() { Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::() / v.len() as f64) as f32 + YCBCR_OFFSET), Some([a, b, c, d]) => { BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } } _ if linear_rgb => BlackLevel::uniform(default_black + YCBCR_OFFSET), - _ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)), + _ => BlackLevel::uniform((scale_bits >= 14).then(|| sr2().and_then(sr2_black)).flatten().unwrap_or(default_black)), }; let white = if linear_rgb { YCBCR_WHITE + YCBCR_OFFSET @@ -565,11 +537,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default())); let wb = raw .f64s(WB_RGGB) - .filter(|v| v.len() == 4 && v[1] > 0.0 && v[0] > 0.0 && v[3] > 0.0) - .map(|v| { - let g = (v[1] + v[2]) / 2.0; - [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] - }) + .and_then(|v| rggb_gains(&v)) + .or_else(|| rggb_gains(&sr2()?.shorts(WB_RGGB)?)) .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))); let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), @@ -775,6 +744,151 @@ mod tests { assert!(tag2010_wb("DSC-RX100M3", &encipher(&odd), Little).is_none()); } + #[test] + fn rggb_gains_need_four_plausible_levels() { + assert_eq!(rggb_gains(&[2048.0, 1024.0, 1024.0, 1536.0]), Some([2.0, 1.0, 1.5])); + assert_eq!(rggb_gains(&[2048.0, 1000.0, 1048.0, 1536.0]), Some([2.0, 1.0, 1.5])); + for bad in [ + &[2048.0, 1024.0, 1024.0][..], + &[2048.0, 1024.0, 1024.0, 1536.0, 1.0], + &[2048.0, 1024.0, -1024.0, 1536.0], + &[2048.0, 0.0, 0.0, 1536.0], + &[2048.0, f64::NAN, 1024.0, 1536.0], + &[-2048.0, 1024.0, 1024.0, 1536.0], + &[9000.0, 1024.0, 1024.0, 1536.0], + &[2048.0, 1024.0, 1024.0, f64::INFINITY], + ] { + assert_eq!(rggb_gains(bad), None, "{bad:?}"); + } + } + + /// A 32 × 4 ARW2 file whose white balance can come from the raw IFD's `0x7313`, an encrypted `SR2SubIFD` + /// (reached through `DNGPrivateData` and the `SR2Private` IFD, as Sony stores it, with `key`; black level 800) + /// and a maker note whose `Tag2010` block (RX100 III layout) holds `WB_RGBLevels`. + fn arw_with_wb(plain: Option<[u16; 4]>, sr2: Option<[u16; 4]>, tag2010: Option<[u16; 3]>, key: [u8; 4]) -> Vec { + use lightcraft_tiff::{ByteOrder::Little, IfdBuilder, ImageData, TiffWriter, Value}; + let mut tag2010_block = vec![0u8; 700]; + for (i, v) in tag2010.unwrap_or([0; 3]).iter().enumerate() { + tag2010_block[612 + 2 * i..614 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let tag2010_block = encipher(&tag2010_block); + // the maker note: "SONY DSC " header, then an IFD whose offsets are file offsets + let note = |at: usize| -> Vec { + let mut n = b"SONY DSC \0\0\0".to_vec(); + n.extend([1, 0]); + n.extend(MN_TAG2010.to_le_bytes()); + n.extend(7u16.to_le_bytes()); + n.extend((tag2010_block.len() as u32).to_le_bytes()); + n.extend(((at + n.len() + 8) as u32).to_le_bytes()); + n.extend([0; 4]); + n.extend(&tag2010_block); + n + }; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSC-RX100M3".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + if let Some(levels) = plain { + raw.set(WB_RGGB, Value::Short(levels.to_vec())); + } + if tag2010.is_some() { + raw.set_child(t::EXIF_IFD, IfdBuilder::new().with(t::MAKER_NOTE, Value::Undefined(note(0)))); + } + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; 32 * 4]] }); + let mut file = TiffWriter::default().write(&[raw]).unwrap(); + let parsed = Tiff::parse(&file).unwrap(); + if let Some(e) = parsed.exif().and_then(|e| e.get(t::MAKER_NOTE)) { + let at = e.offset as usize; + file[at..at + e.count()].copy_from_slice(¬e(at)); + } + if let Some(levels) = sr2 { + // SR2Private IFD (three entries), then the encrypted SR2SubIFD + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let block = super::super::sr2::tests::block(start, &[(BLACK_LEVEL, 3, &[800; 4]), (WB_RGGB, 8, &levels)], Little); + file.extend([3, 0]); + for (tag, kind, value) in + [(0x7200u16, 4u16, (start as u32).to_le_bytes()), (0x7201, 4, (block.len() as u32).to_le_bytes()), (0x7221, 7, key)] + { + file.extend(tag.to_le_bytes()); + file.extend(kind.to_le_bytes()); + file.extend(if kind == 7 { 4u32 } else { 1 }.to_le_bytes()); + file.extend(value); + } + file.extend([0; 4]); + file.extend(block); + let at = parsed.ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + file[at..at + 4].copy_from_slice(&(private as u32).to_le_bytes()); + } + file + } + + #[test] + fn white_balance_prefers_the_applied_levels() { + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + let (plain, sr2, tag2010) = ([2048, 1024, 1024, 1536], [2932, 1024, 1024, 1576], [484, 256, 788]); + let wb = |file: Vec| { + let header = decode(&file, Mode::Header).unwrap(); + let full = decode(&file, Mode::Full).unwrap(); + assert_eq!(header.wb_multipliers, full.wb_multipliers); + assert_eq!(header.black, full.black); + full.wb_multipliers + }; + // the same decrypted directory gives the black level, with the known key only + let black = |file: Vec| decode(&file, Mode::Full).unwrap().black.mean(); + assert_eq!(black(arw_with_wb(None, Some(sr2), None, KEY)), 800.0); + assert_eq!(black(arw_with_wb(None, Some(sr2), None, [1, 2, 3, 4])), 512.0); + assert_eq!(black(arw_with_wb(None, None, None, KEY)), 512.0); + let sr2_gains = Some([2932.0 / 1024.0, 1.0, 1576.0 / 1024.0]); + // the encrypted SR2SubIFD's WB_RGGBLevels over Tag2010 (issue #535: ILCE-3500 at 5600 K) and when alone + assert_eq!(wb(arw_with_wb(None, Some(sr2), Some(tag2010), KEY)), sr2_gains); + assert_eq!(wb(arw_with_wb(None, Some(sr2), None, KEY)), sr2_gains); + // the plain levels over both + assert_eq!(wb(arw_with_wb(Some(plain), Some(sr2), Some(tag2010), KEY)), Some([2.0, 1.0, 1.5])); + // Tag2010 when the SR2SubIFD has another key, implausible levels or none at all + let fallback = Some([484.0 / 256.0, 1.0, 788.0 / 256.0]); + assert_eq!(wb(arw_with_wb(None, Some(sr2), Some(tag2010), [1, 2, 3, 4])), fallback); + assert_eq!(wb(arw_with_wb(None, Some([9000, 1024, 1024, 1576]), Some(tag2010), KEY)), fallback); + assert_eq!(wb(arw_with_wb(None, None, Some(tag2010), KEY)), fallback); + assert_eq!(wb(arw_with_wb(None, None, None, KEY)), None); + } + + #[test] + fn broken_sr2_private_data_is_ignored() { + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + let file = arw_with_wb(None, Some([2932, 1024, 1024, 1576]), None, KEY); + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = u32::from_le_bytes(file[at..at + 4].try_into().unwrap()) as usize; + let patched = |pos: usize, bytes: &[u8]| { + let mut f = file.clone(); + f[pos..pos + bytes.len()].copy_from_slice(bytes); + decode(&f, Mode::Full).map(|img| img.wb_multipliers) + }; + // DNGPrivateData pointing nowhere or into the image; SR2SubIFD offset or length past the end, or enormous + for (pos, bytes) in [ + (at, u32::MAX.to_le_bytes()), + (at, 8u32.to_le_bytes()), + (private + 2 + 8, u32::MAX.to_le_bytes()), + (private + 2 + 12 + 8, u32::MAX.to_le_bytes()), + (private + 2 + 12 + 8, (file.len() as u32).to_le_bytes()), + (private + 2 + 12 + 8, (1u32 << 21).to_le_bytes()), + ] { + assert_eq!(patched(pos, &bytes).unwrap(), None, "{pos} {bytes:?}"); + } + // the file cut anywhere inside the SR2 data still decodes (the image comes first), without white balance + for len in [private, private + 20, file.len() - 30, file.len() - 1] { + let img = decode(&file[..len], Mode::Full).unwrap(); + assert_eq!(img.wb_multipliers, None, "{len}"); + } + } + #[test] fn sr2_black_level_decodes_and_rejects_garbage() { use lightcraft_tiff::ByteOrder::Little; @@ -805,27 +919,22 @@ mod tests { plain[2510 + 2 * i..2510 + 2 * i + 2].copy_from_slice(&v.to_le_bytes()); } } - // encrypt the bytes whose keystream is known (the rest stays plain: it is never read) - for (b, k) in plain.iter_mut().zip(SR2_HEAD_KEYSTREAM) { - *b ^= k; - } - for (pos, ks) in SR2_BLACK_KEYSTREAMS { - if let Some(dst) = plain.get_mut(pos..pos + 8) { - for (b, k) in dst.iter_mut().zip(ks) { - *b ^= k; - } - } - } + super::super::sr2::tests::encrypt(&mut plain); plain } + /// [`sr2_black`] on the encrypted `SR2SubIFD` bytes `block`, which start at file offset `start`. + fn sr2_black_in(block: &[u8], start: usize, order: lightcraft_tiff::ByteOrder) -> Option { + sr2_black(&super::super::sr2::SubIfd::decrypt(block, start, order)) + } + #[test] fn sr2_black_level_follows_the_first_entry_to_its_layout() { use lightcraft_tiff::ByteOrder::Little; let start = 37584; - // every layout with a known keystream, e.g. the DSLR-A700's (62112 bytes, value at 1638) and the - // DSLR-A450/A500/A550's (27152 bytes, value at 2166) - for (pos, _) in SR2_BLACK_KEYSTREAMS { + // the value position of every layout seen, e.g. the DSLR-A700's (62112 bytes, value at 1638) and the + // DSLR-A450/A500/A550's (27152 bytes, value at 2166); the first four are those of bodies without a plain 0x7310 + for pos in [1638, 2166, 2418, 2510, 2030, 2102, 2186, 2558, 2786] { for level in [512, 800] { let block = sr2_block(start, 27152, BLACK_LEVEL, pos, [level; 4], None); assert_eq!(sr2_black_in(&block, start, Little), Some(f32::from(level)), "value at {pos}"); @@ -834,9 +943,11 @@ mod tests { // the A700's layout holds four other equal values at 2510 (975 each); the old fixed read took those let a700 = sr2_block(start, 62112, BLACK_LEVEL, 1638, [512; 4], Some([975; 4])); assert_eq!(sr2_black_in(&a700, start, Little), Some(512.0)); - // not a black-level entry, a value at a position without a known keystream, outside the block, before it + // not a black-level entry, another type, a value outside the block, before it assert_eq!(sr2_black_in(&sr2_block(start, 27152, 0x7311, 2166, [512; 4], None), start, Little), None); - assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2030, [512; 4], None), start, Little), None); + let mut long = sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None); + long[4] ^= 3 ^ 4; // SHORT -> LONG (type field of the first entry) + assert_eq!(sr2_black_in(&long, start, Little), None); assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None)[..2170], start, Little), None); let block = sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None); assert_eq!(sr2_black_in(&block, start + 4000, Little), None, "value offset before the block"); diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index e9f887c8..e0019c48 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,6 +12,7 @@ pub mod pef; pub mod raf; mod rafc; pub mod rw2; +mod sr2; pub mod srw; use crate::{BlackLevel, Cfa, Rect}; diff --git a/crates/raw/src/vendor/sr2.rs b/crates/raw/src/vendor/sr2.rs new file mode 100644 index 00000000..28bf60ed --- /dev/null +++ b/crates/raw/src/vendor/sr2.rs @@ -0,0 +1,301 @@ +//! Sony's encrypted `SR2SubIFD`. +//! +//! Sony ARWs from the DSLR-A200 on carry a private directory, the `SR2SubIFD`, reached from IFD0's +//! `DNGPrivateData` through the `SR2Private` IFD (`SR2SubIFDOffset` `0x7200`, `SR2SubIFDLength` `0x7201`, +//! `SR2SubIFDKey` `0x7221`; tag names from ExifTool's Sony tag documentation). The block is a TIFF directory in the +//! file's byte order whose value offsets are file offsets into the block itself, and the whole block is encrypted. +//! Among its entries are the black level (`0x7310`) and the white balance the camera applied, `WB_RGGBLevels` +//! (`0x7313`: R, G, G, B); bodies from about 2017 on also write both in plain form in the raw IFD. +//! +//! This module is the only place the block is decrypted: the black level and the white balance are both read through +//! [`SubIfd`] (issue #535; it replaces positional keystream tables recovered earlier for the black level alone). +//! +//! **Clean-room provenance.** The decryption was derived only by black-box known-plaintext analysis of CC0 files. No +//! raw-decoder source (dcraw, LibRaw, rawspeed, rawloader/rawler, darktable, RawTherapee, ExifTool's Perl code, …) +//! and no description of Sony's cipher or keystream generator was read or consulted. The steps: +//! +//! 1. **Files.** The 126 ARWs with an `SR2SubIFD` among the CC0 Sony samples of raw.pixls.us collected for issue +//! #535: 97 bodies from the DSLR-A200 to the ILCE-9M3 and ZV-E1, every Sony body on the site that has one, with +//! every coding of the bodies named in open issues. +//! 2. **Plaintext.** `exiftool -v5 FILE`, run as a black-box tool, prints the decrypted `SR2SubIFD` as a hex dump +//! with file offsets. XOR-ing each dumped byte with the byte at the same offset in the file gives the keystream +//! byte at that position of the block. +//! 3. **Comparison.** All 126 files store the key `11 22 33 44`, and their keystreams agree byte for byte at every +//! position they share: 15 layouts from 25150 to 62112 bytes, 5.2 MB of keystream in all. Bytes after the last +//! whole 4-byte word (two, in every block whose length is not a multiple of four) are stored in the clear. +//! 4. **Structure.** Read as 32-bit words, the longest keystream (the DSLR-A700 sample `DSC07249.ARW`, SHA-256 +//! `3159e288…`, 15528 words) was tested for every recurrence `k[n] = k[n − a] ⊕ k[n − b]` with +//! `1 ≤ a < b < 300`: an exhaustive search, not a guided one. Exactly two pairs hold on every word: `(63, 127)` +//! and `(126, 254)`, the second a consequence of the first. With `(63, 127)`, any 127 consecutive words fix all +//! later ones, so [`SEED`] is simply the first 127 words of that file's keystream. +//! 5. **Checks.** Generated from [`SEED`], the keystream equals the one recovered from each of the 126 files at every +//! position. It also reproduces bytes recovered independently from plain tag values before the generator existed +//! (8 bytes at position 2510, issue #148, from the plain black levels of the DSC-RX100M6 and ILCE-7M3) and the +//! positional tables the black level was read with until issue #535; the unit tests pin all of them. +//! +//! Only that key is supported. The one other key seen, in a DSC-R1 SR2 file, gives a different keystream that obeys +//! the same recurrence from its own first 127 words, but one sample per key cannot show how a seed follows from a +//! key, so files with another key get `None`. + +use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t}; + +/// `SR2Private` tags. +const SUBIFD_OFFSET: u16 = 0x7200; +const SUBIFD_LENGTH: u16 = 0x7201; +const SUBIFD_KEY: u16 = 0x7221; +/// The key stored by every ARW examined; [`SEED`] is its keystream. +const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; +/// Upper bound on the block length we decrypt. The largest seen is 62112 bytes (DSLR-A700). +const MAX_LEN: usize = 1 << 20; +/// TIFF field types of the levels: SHORT and SSHORT. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; + +/// The first 127 keystream words for [`KEY`] (each XOR-ed with four block bytes, most significant byte first). +/// Later words follow `k[n] = k[n − 63] ⊕ k[n − 127]`; see the module docs. +const SEED: [u32; 127] = [ + 0x54c2c4d6, 0x49c478bc, 0x34ae2f8e, 0x25d80504, 0xc0d9d6b0, 0xd838fb71, 0xe8eff27d, 0xfbc1fcea, 0x506c499a, 0x47f20f37, 0x710777cf, 0x7867e7ba, + 0x42d67caa, 0x7f2bd11a, 0x67a216ca, 0x0e986d40, 0x4ae8d4c0, 0xe36778b4, 0x5a958415, 0xdbfe2be8, 0x20faa1aa, 0x7132a6b8, 0xf4de4b7f, 0x55991aa1, + 0xa849d5aa, 0x49577832, 0xb92f3daa, 0x399cc526, 0x22cdd000, 0xe1977a29, 0x37c5db55, 0xb0177e1e, 0x2a1016aa, 0xa300086e, 0x3bab9bfe, 0x262eece0, + 0x23771aa9, 0x0a5dc91c, 0x31b902ae, 0x58e64bf8, 0x259c300e, 0xa57705c8, 0x284a6541, 0xfb229c60, 0x1bacaa9e, 0xbcab3350, 0x67cd9fbe, 0x8f135e60, + 0xf8c26a40, 0x6770da61, 0x3e1febfd, 0xd0c70803, 0x8dbb037b, 0x6f6fa4c5, 0x6749d10d, 0x7f51598d, 0xd5e5a4ec, 0x207dfa91, 0x6558ebc2, 0xbe594639, + 0x617a9e5d, 0x3c497950, 0x0844eb3f, 0x04207ed2, 0xd27ceac4, 0x70d20f05, 0xb47003f6, 0xe9e4e3ae, 0xcc19d265, 0x326dd956, 0xf0d3a327, 0xb71275f0, + 0x7994e285, 0x0aff594d, 0x128e8345, 0x7bda597a, 0xd634c380, 0xe24a006f, 0x8974818b, 0x3320b22a, 0xbe808417, 0xa2d5648a, 0x6fe80b39, 0x23ebad41, + 0xa2d11e5d, 0x027d9397, 0x9a722ac8, 0x432c7dac, 0x7146692a, 0x82a3dc77, 0xd66887c5, 0x831f43b7, 0x4e5dddde, 0x03793f81, 0x306ab437, 0x00ccf86c, + 0xfc6ed3d2, 0x076b8fdb, 0x9808cfca, 0x0f4eef6e, 0xc8cc3830, 0x104ac16a, 0xa189eff4, 0x3e085c08, 0xd28baf88, 0x5c853ac4, 0xe60480f8, 0xc51acd98, + 0x691e5ee1, 0x333feeb9, 0x1e35bc33, 0xec4a4642, 0xee57c5a5, 0xbeeb51f7, 0xe0c4f32c, 0xa5422f6a, 0x1d266d12, 0x3752fd3b, 0xfbc53c7d, 0x2421a4a3, + 0xcdc6a2de, 0x26e6b330, 0x6c073d46, 0x058e2f27, 0x43833f30, 0x46d1382e, 0x5f0804ec, +]; + +/// XOR `block` with the keystream from its first byte on (whole 4-byte words; a shorter tail stays as it is). +/// Encrypts and decrypts alike. +fn apply_keystream(block: &mut [u8]) { + let mut ring = SEED; + for (n, word) in block.as_chunks_mut::<4>().0.iter_mut().enumerate() { + // `ring[i]` holds k[n − 127] until it is replaced by k[n]; k[n − 63] sits at (n − 63) mod 127 + let i = n % SEED.len(); + if n >= SEED.len() { + ring[i] ^= ring[(i + 64) % SEED.len()]; + } + for (b, k) in word.iter_mut().zip(ring[i].to_be_bytes()) { + *b ^= k; + } + } +} + +/// A decrypted `SR2SubIFD`. +pub(crate) struct SubIfd { + /// File offset of the block: the directory's value offsets are file offsets. + start: usize, + plain: Vec, + order: ByteOrder, +} + +impl SubIfd { + /// Find and decrypt the file's `SR2SubIFD`; `None` when it has none, it lies outside the file, is larger than + /// [`MAX_LEN`] or uses another key than [`KEY`]. + pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option { + let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; + let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?; + if private.bytes(SUBIFD_KEY)? != KEY { + return None; + } + let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?; + let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?; + if len > MAX_LEN { + return None; + } + Some(Self::decrypt(bytes.get(start..start.checked_add(len)?)?, start, order)) + } + + /// Decrypt `block`, the encrypted directory found at file offset `start`. + pub(crate) fn decrypt(block: &[u8], start: usize, order: ByteOrder) -> Self { + let mut plain = block.to_vec(); + apply_keystream(&mut plain); + Self { start, plain, order } + } + + /// The byte order of the directory's values (the file's). + pub(crate) fn order(&self) -> ByteOrder { + self.order + } + + /// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type + /// or its values lie outside the block. + pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> { + let o = self.order; + let entries = usize::from(o.read_u16(&self.plain, 0)?); + let entry = (0..entries).map_while(|i| self.plain.get(2 + 12 * i..14 + 12 * i)).find(|e| o.read_u16(e, 0) == Some(tag))?; + let kind = o.read_u16(entry, 2)?; + if kind != SHORT && kind != SSHORT { + return None; + } + let size = usize::try_from(o.read_u32(entry, 4)?).ok()?.checked_mul(2)?; + let values = if size <= 4 { + entry.get(8..8 + size)? + } else { + let at = usize::try_from(o.read_u32(entry, 8)?).ok()?.checked_sub(self.start)?; + self.plain.get(at..at.checked_add(size)?)? + }; + Some((kind, values)) + } + + /// The values of a SHORT or SSHORT entry (see [`SubIfd::short_bytes`]). + pub(crate) fn shorts(&self, tag: u16) -> Option> { + let (kind, values) = self.short_bytes(tag)?; + let value = |v: u16| if kind == SSHORT { f64::from(v as i16) } else { f64::from(v) }; + Some(values.as_chunks::<2>().0.iter().map(|&c| value(self.order.u16(c))).collect()) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use lightcraft_tiff::ByteOrder::{Big, Little}; + + /// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values + /// of more than two shorts stored after the table at file offsets, as Sony does. + pub(crate) fn block(start: usize, entries: &[(u16, u16, &[u16])], order: ByteOrder) -> Vec { + let mut plain = Vec::new(); + order.put_u16(&mut plain, entries.len() as u16); + let mut data = Vec::new(); + let data_at = start + 2 + 12 * entries.len() + 4; + for &(tag, kind, values) in entries { + order.put_u16(&mut plain, tag); + order.put_u16(&mut plain, kind); + order.put_u32(&mut plain, values.len() as u32); + let mut field = Vec::new(); + values.iter().for_each(|&v| order.put_u16(&mut field, v)); + if field.len() <= 4 { + field.resize(4, 0); + plain.extend(field); + } else { + order.put_u32(&mut plain, (data_at + data.len()) as u32); + data.extend(field); + } + } + plain.extend([0; 4]); + plain.extend(data); + plain.extend([0xa5; 2]); // a length that is not a multiple of four, like most real blocks + apply_keystream(&mut plain); + plain + } + + /// Encrypt (or decrypt) `block` in place, as a file stores an `SR2SubIFD` at its first byte. + pub(crate) fn encrypt(block: &mut [u8]) { + apply_keystream(block); + } + + /// Keystream bytes recovered earlier, each from plain tag values or ExifTool's decrypted dump at one position, + /// before the generator existed: the black level's 8 bytes at 2510 (#148), and the positional tables the black + /// level was read with until #535 (#542: the directory's count and first entry, and the black-level value in the + /// four layouts of bodies without a plain `0x7310`). The generator must reproduce every one of them, so the black + /// levels it decrypts are those the tables gave. + #[test] + fn keystream_matches_bytes_recovered_at_single_positions() { + let mut ks = vec![0u8; 62114]; + apply_keystream(&mut ks); + let recovered: [(usize, &[u8]); 5] = [ + (0, &[0x54, 0xc2, 0xc4, 0xd6, 0x49, 0xc4, 0x78, 0xbc, 0x34, 0xae, 0x2f, 0x8e, 0x25, 0xd8]), + (1638, &[0x18, 0x95, 0x65, 0xf0, 0x18, 0x8f, 0x61, 0xc6]), + (2166, &[0xc6, 0x17, 0x02, 0xcb, 0x89, 0x21, 0xbc, 0x43]), + (2418, &[0xd1, 0xef, 0xda, 0xfe, 0x50, 0xf3, 0xd0, 0xbe]), + (2510, &[0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]), + ]; + for (at, bytes) in recovered { + assert_eq!(&ks[at..at + bytes.len()], bytes, "keystream at {at}"); + } + } + + /// Known answers recorded from CC0 files without the generator, so that a wrong seed word or recurrence fails + /// here even without the corpus: the SHA-256 of the whole keystream recovered from the DSLR-A700 sample + /// (`DSC07249.ARW`: the file's bytes XOR ExifTool's decrypted dump; 62112 bytes, the longest block seen, so it + /// covers every black-level and white-balance position of every layout), and the encrypted `WB_RGGBLevels` of + /// one file per layout of bodies without a plain `0x7313`, with the levels ExifTool decrypts from them. + #[test] + fn keystream_matches_recorded_known_answers() { + use sha2::{Digest, Sha256}; + let mut ks = vec![0u8; 62112]; + apply_keystream(&mut ks); + let digest: String = Sha256::digest(&ks).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(digest, "d55e96d7bb69cb823d9dfa038dd9f3cfb6b080be4df8c8aa2bc56f71a4a3f183"); + // (file, position in the block, bytes stored there, WB_RGGBLevels) + let cases: [(&str, usize, [u8; 8], [i16; 4]); 4] = [ + ("DSLR-A700 DSC07249", 1654, [0xc9, 0x2a, 0xb6, 0xa4, 0x1f, 0xc1, 0x72, 0x30], [2128, 1024, 1024, 1564]), + ("DSLR-A500 DSC02421", 2182, [0xb9, 0x6a, 0xed, 0xd3, 0x0d, 0xa8, 0xbb, 0xda], [2212, 1024, 1024, 1416]), + ("SLT-A33 DSC01867", 2434, [0x3c, 0x43, 0x05, 0x3f, 0x0d, 0xe2, 0x05, 0xcc], [2344, 1024, 1024, 1508]), + ("ILCE-3500 DSC06923", 2526, [0x37, 0x2e, 0x71, 0x5b, 0x88, 0xb7, 0x21, 0x73], [2932, 1024, 1024, 1576]), + ]; + for (file, at, stored, levels) in cases { + let mut block = vec![0u8; (at + 8).next_multiple_of(4)]; + block[at..at + 8].copy_from_slice(&stored); + apply_keystream(&mut block); + let got: Vec = block[at..at + 8].chunks(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect(); + assert_eq!(got, levels, "{file}"); + } + } + + #[test] + fn keystream_follows_its_recurrence() { + let mut zeros = vec![0u8; 62114]; + apply_keystream(&mut zeros); + // the recurrence, past the seed, and the clear tail + let word = |n: usize| u32::from_be_bytes(zeros[4 * n..4 * n + 4].try_into().unwrap()); + assert_eq!(word(127), SEED[64] ^ SEED[0]); + assert!((200..15528).all(|n| word(n) == word(n - 63) ^ word(n - 127))); + assert_eq!(zeros[62112..], [0, 0]); + // applying it twice restores the input + let mut again = zeros.clone(); + apply_keystream(&mut again); + assert!(again[..62112].iter().all(|&b| b == 0)); + } + + #[test] + fn reads_entries_inline_and_at_file_offsets() { + for order in [Little, Big] { + let start = 75964; + let enc = block(start, &[(0x7310, SHORT, &[512; 4]), (0x7311, SHORT, &[256]), (0x7313, SSHORT, &[2212, 1024, 1024, 1416])], order); + let sub = SubIfd::decrypt(&enc, start, order); + assert_eq!(sub.shorts(0x7313), Some(vec![2212.0, 1024.0, 1024.0, 1416.0]), "{order:?}"); + assert_eq!(sub.shorts(0x7310), Some(vec![512.0; 4])); + assert_eq!(sub.shorts(0x7311), Some(vec![256.0])); + assert_eq!(sub.shorts(0x7312), None); + // SSHORT is signed + let enc = block(start, &[(0x7313, SSHORT, &[0xffff, 1024])], order); + assert_eq!(SubIfd::decrypt(&enc, start, order).shorts(0x7313), Some(vec![-1.0, 1024.0])); + } + } + + #[test] + fn hostile_directories_give_none() { + let start = 4096; + let good = block(start, &[(0x7313, SSHORT, &[2212, 1024, 1024, 1416])], Little); + let read = |b: &[u8], start: usize| SubIfd::decrypt(b, start, Little).shorts(0x7313); + assert!(read(&good, start).is_some()); + // decrypted at another file offset, the value offset points before or past the block + assert_eq!(read(&good, start + 64), None); + assert_eq!(read(&good, start - 64), None); + // every truncation, down to empty + for n in 0..good.len() - 2 { + assert_eq!(read(&good[..n], start), None, "{n} bytes"); + } + // garbage: still encrypted; 65535 entries of tag 0xffff; a huge count, another type, a wild offset + assert_eq!(SubIfd { start, plain: good.clone(), order: Little }.shorts(0x7313), None); + assert_eq!(SubIfd { start, plain: vec![0xff; 1000], order: Little }.shorts(0x7313), None); + let mut plain = good.clone(); + apply_keystream(&mut plain); + let patched = |at: usize, bytes: &[u8]| { + let mut p = plain.clone(); + p[at..at + bytes.len()].copy_from_slice(bytes); + apply_keystream(&mut p); + read(&p, start) + }; + assert_eq!(patched(6, &u32::MAX.to_le_bytes()), None); // count + assert_eq!(patched(4, &4u16.to_le_bytes()), None); // LONG + assert_eq!(patched(10, &u32::MAX.to_le_bytes()), None); // value offset + // an entry count past the end of the block: the scan stops where the block ends, so an entry before that + // point is still found + assert_eq!(patched(0, &u16::MAX.to_le_bytes()), Some(vec![2212.0, 1024.0, 1024.0, 1416.0])); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index b89f8154..0316dbbc 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -328,17 +328,20 @@ fn corpus_proraw_sky_matte() { } /// Issue #148: Sony ARWs from before ~2017 carry no plain white-balance, black-level or crop tags in the raw IFD. -/// White balance comes from the maker note's enciphered `Tag2010`, the black level from the encrypted `SR2SubIFD` -/// and the crop from `FullImageSize`; without them the RX100 III opened bright green. Expected values: the black -/// levels agree with each sensor's dark-pixel floor, the gains with the neutral sky of the camera JPEG. +/// White balance and black level come from the encrypted `SR2SubIFD`, the crop from `FullImageSize`; without them +/// the RX100 III opened bright green. Expected values: the black levels agree with each sensor's dark-pixel floor; +/// the gains are the `SR2SubIFD`'s `WB_RGGBLevels`, within 4 % of the gains that make the camera JPEG's neutral +/// pixels neutral (RX100 III 2.59 / 1.69, RX100 2.21 / 2.01, ILCE-7RM2 2.67 / 1.47). Until issue #535 they came +/// from the maker note's `Tag2010` (2.61 / 1.72, 2.23 / 2.00, 2.58 / 1.46), which differs from what the camera +/// applied on other files (see `corpus_sony_sr2_white_balance`). #[test] fn corpus_sony_pre2017_colour_metadata() { let dir = corpus_root().join("raw"); // (file, black, approximate R and B gains, crop width × height) let cases = [ - ("arw-sony-rx100m3.arw", 800.0, [2.61, 1.72], (5472, 3648)), - ("arw-sony-rx100.arw", 800.0, [2.23, 2.00], (5472, 3648)), - ("arw-sony-a7rm2-12bit-uncompressed.arw", 512.0, [2.58, 1.46], (7952, 5304)), + ("arw-sony-rx100m3.arw", 800.0, [2.55, 1.72], (5472, 3648)), + ("arw-sony-rx100.arw", 800.0, [2.21, 1.99], (5472, 3648)), + ("arw-sony-a7rm2-12bit-uncompressed.arw", 512.0, [2.58, 1.45], (7952, 5304)), ]; let mut seen = 0; for (name, black, [r, b], (cw, ch)) in cases { @@ -418,6 +421,37 @@ fn corpus_sony_black_levels_sit_at_the_data_floor() { eprintln!("Sony black levels checked against the data floor on {seen} files"); } +/// Issue #535: the white balance a Sony ARW records as applied, `WB_RGGBLevels`, read from the encrypted +/// `SR2SubIFD` when the raw IFD has no plain copy. Before, the first three opened with no white balance (green) and +/// the last two with the maker note's `Tag2010` gains, which there follow the scene rather than the preset used. +/// Expected levels: ExifTool's decrypted `WB_RGGBLevels` (green 1024). In brackets, the gains that make the +/// camera JPEG's neutral pixels neutral. +#[test] +fn corpus_sony_sr2_white_balance() { + let dir = corpus_root().join("raw"); + // (file, R and B levels) + let cases = [ + ("arw-sony-a500.arw", [2212, 1416]), // 27152-byte SR2SubIFD layout (2.04 / 1.35) + ("arw-sony-a33.arw", [2344, 1508]), // 29000-byte layout (2.29 / 1.48) + ("arw-sony-a700.arw", [2128, 1564]), // 62112-byte layout (too few neutral pixels) + ("arw-sony-a3500-5600k.arw", [2932, 1576]), // 5600 K (2.76 / 1.53; Tag2010 1.89 / 3.08) + ("arw-sony-a7s-shade.arw", [2932, 1372]), // Shade (2.90 / 1.32; Tag2010 2.63 / 1.52) + ]; + let mut seen = 0; + for (name, [r, b]) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance")); + let (er, eb) = (r as f32 / 1024.0, b as f32 / 1024.0); + assert!((wb[0] - er).abs() < 1e-4 && wb[1] == 1.0 && (wb[2] - eb).abs() < 1e-4, "{name}: white balance {wb:?}, expected [{er}, 1, {eb}]"); + seen += 1; + } + eprintln!("Sony SR2SubIFD white balance checked on {seen} files"); +} + /// The embedded JPEG as linear RGB, reduced to `gw × gh` cells. fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> { use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; diff --git a/docs/parity.md b/docs/parity.md index 8300b7fc..f03122c9 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -142,9 +142,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`), Hasselblad 3FR with uncompressed raw data (X1D, X1D II, X2D, CFV 100C: a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`; the lossless-JPEG 3FR and FFF files use scan selection value 8 and stay preview-only), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`), Hasselblad 3FR with uncompressed raw data (X1D, X1D II, X2D, CFV 100C: a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`; the lossless-JPEG 3FR and FFF files use scan selection value 8 and stay preview-only), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; the encrypted SR2 black level is now found through its directory's first entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 115 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f1fa7eee..2d7053a9 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -340,7 +340,7 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/ILCE-7M4/ILCE-7M4_DSC06676_FullFrame-LossLess-Compressed-Small.ARW", "cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223", ), - // pre-2017 bodies: white balance only in the enciphered maker note, black level only in the SR2SubIFD (#148) + // pre-2017 bodies: white balance and black level only in the encrypted SR2SubIFD (#148) ( "arw-sony-rx100m3.arw", "https://raw.pixls.us/data/Sony/DSC-RX100M3/DSC00734.ARW", @@ -368,6 +368,19 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/DSLR-A700/DSC07249.ARW", "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40", ), + // SR2SubIFD white balance (#535): the A500 and A700 above, a third layout of a body that had none before + // (SLT-A33, 29000 bytes), and two shots whose maker-note gains differ from the preset applied (5600 K, Shade) + ("arw-sony-a33.arw", "https://raw.pixls.us/data/Sony/SLT-A33/DSC01867.ARW", "1a59856394f10d4fadb40f5ab9c6d1c89f473f4dbcdefd216fbbbbf1ad8d21f9"), + ( + "arw-sony-a3500-5600k.arw", + "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", + "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", + ), + ( + "arw-sony-a7s-shade.arw", + "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", + "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", + ), // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples ( "cr2-canon-40d.cr2",