diff --git a/ROADMAP.md b/ROADMAP.md index a0af5206..77c9ba58 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -176,8 +176,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 90a0d416..b1d2b8a4 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 = 30; +pub const RENDER_CACHE_VERSION: u64 = 31; /// 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 2e3eec22..27896b56 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -14,11 +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_BLACK_AT`]; 800 rather than the -//! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). +//! 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 @@ -79,9 +84,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), @@ -171,9 +179,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)))?; @@ -188,6 +196,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]; @@ -319,6 +339,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). @@ -384,47 +416,23 @@ 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; -/// 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 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 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]; -const SR2_KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; - -/// 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. -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()?); - 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) } -/// 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(); +/// 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) } @@ -523,15 +531,19 @@ 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), + 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() } } - _ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)), + _ if linear_rgb => BlackLevel::uniform(default_black + YCBCR_OFFSET), + _ => BlackLevel::uniform((scale_bits >= 14).then(|| sr2().and_then(sr2_black)).flatten().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)) }; @@ -542,11 +554,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), @@ -608,6 +617,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"); }; @@ -833,19 +845,219 @@ mod tests { assert_eq!(crate::embedded_preview_dynamic_range_optimized(&lightcraft_tiff::TiffWriter::new(Little, false).write(&[ifd0]).unwrap()), 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; - 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()); + } + } + 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; + // 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}"); + } + } + // 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, 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); + 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"); + 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/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 1a34d7a8..2a59aec8 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -347,17 +347,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 { @@ -442,6 +445,98 @@ fn corpus_sony_a7cr_codings() { eprintln!("ILCE-7CR codings checked on {} files", decoded.len()); } +/// 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"); +} + +/// 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 8872fc2d..a5f84516 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -143,8 +143,8 @@ 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; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), 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), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), 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; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus 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`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. 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-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; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), 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), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), 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; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus 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`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. 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 bf41eb10..59c914df 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -363,7 +363,7 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/3480/nice/Sony%20-%20ILCE-7RM4%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", "e6dafe42643f69ab9d1fd00414b7a1f5104df354bb589201600ac934b29b5e4a", ), - // 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", @@ -379,6 +379,31 @@ 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", + ), + // 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",