From 94780ed299eaad83ff38a34a6bdf64f00c39fc01 Mon Sep 17 00:00:00 2001 From: Orthic Labs <102761952+adrdsouza@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:36:00 +0530 Subject: [PATCH] LR-IMP-FORMATS: decode downsized Sony lossless ARWs Co-authored-by: Codex --- ROADMAP.md | 3 + apps/lightcraft-cli/tests/cli.rs | 3 +- crates/codecs/src/sniff.rs | 43 +++++++ crates/engine/src/camera_preview.rs | 39 +++++- crates/engine/src/rename.rs | 19 +-- crates/engine/src/tests_import.rs | 10 +- crates/engine/src/tests_libops.rs | 2 +- crates/raw/src/ljpeg.rs | 177 +++++++++++++++++++++++++++- crates/raw/src/vendor/arw.rs | 116 +++++++++++++++++- crates/raw/tests/corpus.rs | 2 - crates/ui-egui/src/headless.rs | 29 ++++- crates/ui-egui/src/tests_masking.rs | 14 ++- docs/parity.md | 2 +- docs/whats-new.md | 4 + 14 files changed, 434 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fdfbec0cb..2c1098c10 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,6 +160,9 @@ The milestone estimates in the table above were made before work started and are ## Raw format coverage and known gaps +Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB; +private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export. + 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), 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 (Bayer and diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 37799f4aa..016de36ef 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -292,7 +292,8 @@ fn devices_are_listed_and_imported_from() { assert_eq!(dev["name"], "CARD", "{line}"); // the import review of its DCIM folder finds the photo let dcim = dev["path"].as_str().unwrap().to_string(); - let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths=[\"{dcim}\"]")]).output().unwrap(); + let paths = format!("paths={}", json!([dcim])); + let o = Command::new(BIN).args(["run", "library.importPreview", &paths]).output().unwrap(); let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap(); assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}"); } diff --git a/crates/codecs/src/sniff.rs b/crates/codecs/src/sniff.rs index d31629047..a02d87d24 100644 --- a/crates/codecs/src/sniff.rs +++ b/crates/codecs/src/sniff.rs @@ -163,6 +163,9 @@ fn tiff_is_raw(b: &[u8]) -> bool { } let Some(t) = Tiff::new(b) else { return false }; let Some(ifd0) = t.first_ifd() else { return false }; + let sony = t.ifd(ifd0).is_some_and(|(entries, _)| { + entries.iter().any(|e| e.tag == 0x010f && t.bytes(e).and_then(|v| v.get(..4)).is_some_and(|v| v.eq_ignore_ascii_case(b"SONY"))) + }); let mut stack = vec![ifd0]; let mut seen = 0; while let Some(pos) = stack.pop() { @@ -171,6 +174,12 @@ fn tiff_is_raw(b: &[u8]) -> bool { break; } let Some((entries, _)) = t.ifd(pos) else { continue }; + // Sony M/S lossless ARWs retain a dummy CFA pattern, but store linear YCbCr. + // Requiring the raw pattern plus this layout keeps ordinary Sony TIFFs as TIFFs. + let uint = |tag| entries.iter().find(|e| e.tag == tag).and_then(|e| t.uint(e)); + if sony && entries.iter().any(|e| e.tag == 0x828e) && uint(0x0103) == Some(7) && uint(0x0106) == Some(6) && uint(0x0115) == Some(3) { + return true; + } for e in &entries { match e.tag { 0xC612 => return true, // DNGVersion @@ -233,4 +242,38 @@ mod tests { b.extend_from_slice(&0u32.to_le_bytes()); assert_eq!(sniff(&b), Some(Format::RawTiffLike)); } + + #[test] + fn sony_linear_ycbcr_subifd_is_raw_but_ordinary_tiff_is_not() { + fn fixture(compression: u32, pattern: bool) -> Vec { + let mut b = b"II*\0\x08\0\0\0".to_vec(); + let entry = |b: &mut Vec, tag: u16, typ: u16, count: u32, value: u32| { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&typ.to_le_bytes()); + b.extend_from_slice(&count.to_le_bytes()); + b.extend_from_slice(&value.to_le_bytes()); + }; + b.extend_from_slice(&2u16.to_le_bytes()); + entry(&mut b, 0x010f, 2, 5, 38); + entry(&mut b, 0x014a, 4, 1, 44); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(b"SONY\0\0"); + b.extend_from_slice(&(if pattern { 4u16 } else { 3 }).to_le_bytes()); + entry(&mut b, 0x0103, 3, 1, compression); + entry(&mut b, 0x0106, 3, 1, 6); + entry(&mut b, 0x0115, 3, 1, 3); + if pattern { + entry(&mut b, 0x828e, 1, 4, u32::MAX); + } + b.extend_from_slice(&0u32.to_le_bytes()); + b + } + let mut raw = fixture(7, true); + assert_eq!(sniff(&raw), Some(Format::RawTiffLike)); + assert_eq!(sniff(&fixture(7, false)), Some(Format::Tiff)); + assert_eq!(sniff(&fixture(1, true)), Some(Format::Tiff)); + assert_eq!(sniff(&raw[..38]), Some(Format::Tiff)); + raw[38..42].copy_from_slice(b"TEST"); + assert_eq!(sniff(&raw), Some(Format::Tiff)); + } } diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 41bbdca84..b1ed392b7 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -40,7 +40,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf } // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. let k = (crop.width.max(crop.height).div_ceil(384).max(2)).div_ceil(2) * 2; - let sensor = raw.develop_binned(k, 0.99).ok()??; + let sensor = sensor_proxy(raw, k)?; let mut sensor = fit(&sensor, 96, 96, Filter::Box); let reference = fit(&reference, sensor.width, sensor.height, Filter::Box); let gain = 2f32.powf(transform.baseline_exposure as f32); @@ -52,6 +52,14 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf Some(look) } +fn sensor_proxy(raw: &RawImage, k: usize) -> Option { + Some(match raw.develop_binned(k, 0.99).ok()? { + Some(sensor) => sensor, + None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, 384, 384, Filter::Box), + None => return None, + }) +} + /// A fit must cut the held-out squared error to below this share of the fallback's. const MIN_IMPROVEMENT: f64 = 0.7; /// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public @@ -205,6 +213,35 @@ fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option { #[cfg(test)] mod tests { use super::*; + + #[test] + fn linear_arw_gets_a_sensor_proxy_without_demosaicing() { + use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect}; + let mut raw = RawImage { + format: RawFormat::Arw, + width: 32, + height: 32, + cpp: 3, + data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)), + cfa: None, + bits: 16, + black: BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: Rect::new(0, 0, 32, 32), + crop: Rect::new(0, 0, 32, 32), + orientation: Orientation::from_exif(1), + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: true, + opcodes: OpcodeLists::default(), + metadata: lightcraft_meta::Metadata::default(), + }; + let proxy = sensor_proxy(&raw, 2).unwrap(); + assert_eq!((proxy.width, proxy.height), (32, 32)); + assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]); + raw.data = RawData::F32(Vec::new()); + assert!(sensor_proxy(&raw, 2).is_none()); + } #[test] fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() { let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 27fc5fb5f..fe7d26113 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -769,12 +769,15 @@ mod tests { } } fn idx(&self, p: &Path) -> Option { - let p = p.to_string_lossy(); - self.files.borrow().iter().position(|(f, _)| if self.ci { f.to_lowercase() == p.to_lowercase() } else { *f == p }) + let p = p.to_string_lossy().replace('\\', "/"); + self.files.borrow().iter().position(|(f, _)| { + let f = f.replace('\\', "/"); + if self.ci { f.to_lowercase() == p.to_lowercase() } else { f == p } + }) } /// The listing: (exact path, contents), sorted. pub fn listing(&self) -> Vec<(String, String)> { - let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.clone(), String::from_utf8_lossy(c).to_string())).collect(); + let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect(); v.sort(); v } @@ -825,8 +828,10 @@ mod tests { } fn file_photo(id: u64, path: &str) -> Photo { - let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00") + // Imported catalog paths use native separators, as do planner destinations. + let path = Path::new(path).components().collect::().to_string_lossy().to_string(); + let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00") } /// Issue #95: a case-only rename is a real change on a case-insensitive volume and never @@ -884,7 +889,7 @@ mod tests { let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1); // c can't be renamed (the share went away), and neither can Trip-1 be moved back fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]); - let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string(); + let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/"); assert!(err.contains("rename /p/c.jpg"), "{err}"); assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}"); assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}"); @@ -899,7 +904,7 @@ mod tests { ); // the catalog matches the disk let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source { - Source::File { path } => path.clone(), + Source::File { path } => path.replace('\\', "/"), Source::Demo { .. } => String::new(), }; assert_eq!(path(&s, 1), "/p/Trip-1.jpg"); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index dd3f7ca1f..af106765e 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -181,8 +181,12 @@ fn find_missing_matches_renamed_files_by_content() { let to = |name: &str| { found.iter().find(|f| f["from"].as_str().unwrap().ends_with(name)).map(|f| (f["to"].as_str().unwrap().to_string(), f["by"].clone())) }; - assert_eq!(to("a.png"), Some((dir.join("moved/Trip-001.png").to_string_lossy().to_string(), json!("content")))); - assert_eq!(to("b.png"), Some((dir.join("moved/real/Trip-002.png").to_string_lossy().to_string(), json!("content"))), "not the impostor"); + assert_eq!(to("a.png"), Some((dir.join("moved").join("Trip-001.png").to_string_lossy().to_string(), json!("content")))); + assert_eq!( + to("b.png"), + Some((dir.join("moved").join("real").join("Trip-002.png").to_string_lossy().to_string(), json!("content"))), + "not the impostor" + ); assert_eq!(r["missing"], 1); assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "{r}"); let _ = std::fs::remove_dir_all(&dir); @@ -582,7 +586,7 @@ fn folders_rename_and_move_with_their_photos() { assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself"); let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap(); assert_eq!(r["relinked"], 1); - assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy()); + assert_eq!(path(&s), root.join("Archive").join("Italy 2026").join("one.png").to_string_lossy()); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index 909414984..607b5e634 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -273,7 +273,7 @@ fn import_review_preview_and_options() { let mut s = Session::new().with_fs(); s.open_library(&lib, false).unwrap(); // c.png is already in the library - s.execute("library.import", &json!({"paths": [src.join("sub/c.png").to_string_lossy()]})).unwrap(); + s.execute("library.import", &json!({"paths": [src.join("sub").join("c.png").to_string_lossy()]})).unwrap(); let n0 = s.catalog.len(); let undo0 = s.undo.len(); let pv = s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap(); diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index cb2d04675..c8087a8e8 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -207,6 +207,7 @@ struct Header { height: usize, width: usize, comps: Vec, + sampling: Vec, tables: [Option; 4], table_for: Vec, predictor: u8, @@ -228,6 +229,7 @@ fn parse_header(d: &[u8]) -> Result { height: 0, width: 0, comps: vec![], + sampling: vec![], tables: [None, None, None, None], table_for: vec![], predictor: 1, @@ -272,10 +274,11 @@ fn parse_header(d: &[u8]) -> Result { } for c in 0..n { let (id, samp) = (seg[6 + 3 * c], seg[7 + 3 * c]); - if samp != 0x11 { - return Err(RawError::Unsupported("lossless JPEG with subsampled components".into())); + if !(1..=4).contains(&(samp >> 4)) || !(1..=4).contains(&(samp & 15)) { + return Err(err("invalid sampling factors")); } h.comps.push(id); + h.sampling.push(samp); } if !(2..=16).contains(&h.precision) { return Err(err("bad precision")); @@ -340,12 +343,119 @@ fn parse_header(d: &[u8]) -> Result { /// Parse only the frame header: (width, height, components, precision). pub fn frame_info(d: &[u8]) -> Result<(usize, usize, usize, u8), RawError> { let h = parse_header(d)?; + check_full_resolution(&h)?; Ok((h.width, h.height, h.comps.len(), h.precision)) } +fn check_full_resolution(h: &Header) -> Result<(), RawError> { + if h.sampling.iter().any(|&s| s != 0x11) { + return Err(RawError::Unsupported("lossless JPEG with subsampled components".into())); + } + Ok(()) +} + +fn check_subsampled(h: &Header) -> Result { + let vertical = match h.sampling.as_slice() { + [0x22, 0x11, 0x11] => 2, + [0x21, 0x11, 0x11] => 1, + _ => return Err(RawError::Unsupported("lossless JPEG subsampling layout".into())), + }; + if h.predictor != 1 || h.restart != 0 { + return Err(RawError::Unsupported("lossless JPEG subsampled predictor / restarts".into())); + } + if h.width == 0 || h.height == 0 || !h.width.is_multiple_of(2) || !h.height.is_multiple_of(vertical) { + return Err(err("subsampled frame dimensions do not match MCU size")); + } + Ok(vertical) +} + +/// Sony M/S tiles: horizontal prediction, no restart markers. Keep subsampled planes separate; +/// expanding them into a mosaic would silently corrupt the existing CFA callers. +pub(crate) fn frame_info_subsampled(d: &[u8]) -> Result<(usize, usize), RawError> { + let h = parse_header(d)?; + check_subsampled(&h)?; + Ok((h.width, h.height)) +} + +pub(crate) struct FrameSubsampled { + pub width: usize, + pub height: usize, + pub vertical_subsampling: usize, + /// Y at full resolution; Cb and Cr at half width, with 1× or 2× vertical subsampling. + pub planes: [Vec; 3], +} + +/// Sony's 4:2:0 / 4:2:2 LJ92 variants: T.81 MCU ordering and differences, modulo 2^16. +/// At the first column, the top luma row predicts from the previous MCU row's top +/// sample (two image rows above), and the bottom row from the current top sample. +/// Observed black-box: using the immediately preceding image row introduces tile seams. +pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result { + let h = parse_header(d)?; + let vertical = check_subsampled(&h)?; + let pixels = h.width.checked_mul(h.height).ok_or_else(|| err("frame too large"))?; + let chroma = pixels / (2 * vertical); + let total = pixels.checked_add(chroma.checked_mul(2).ok_or_else(|| err("frame too large"))?).ok_or_else(|| err("frame too large"))?; + if total > max_samples { + return Err(RawError::Limit("lossless JPEG frame larger than expected")); + } + let entropy = &d[h.scan_start..]; + if (entropy.len() as u64 + 64) * 8 < total as u64 { + return Err(err("entropy data too short for frame")); + } + let tables: Vec<&Huffman> = h + .table_for + .iter() + .map(|&t| h.tables.get(t).and_then(Option::as_ref).ok_or_else(|| err("missing Huffman table"))) + .collect::>()?; + let mut planes = [vec![0u16; pixels], vec![0u16; chroma], vec![0u16; chroma]]; + let mut br = BitReader::new(entropy); + let init = 1i32 << (h.precision - h.pt - 1); + for my in 0..h.height / vertical { + for mx in 0..h.width / 2 { + for (c, plane) in planes.iter_mut().enumerate() { + let horizontal_factor = if c == 0 { 2 } else { 1 }; + let vertical_factor = if c == 0 { vertical } else { 1 }; + let width = h.width * horizontal_factor / 2; + for dy in 0..vertical_factor { + for dx in 0..horizontal_factor { + let (x, y) = (mx * horizontal_factor + dx, my * vertical_factor + dy); + let i = y * width + x; + let pred = if x > 0 { + plane[i - 1] as i32 + } else if c == 0 && dy == 0 && y >= vertical { + plane[i - vertical * width] as i32 + } else if y > 0 { + plane[i - width] as i32 + } else { + init + }; + let ssss = tables[c].decode(&mut br)?; + if ssss > 16 { + return Err(err("invalid difference category")); + } + plane[i] = ((pred + diff_value(&mut br, ssss)) & 0xffff) as u16; + } + } + } + } + if br.overrun > br.bits { + return Err(err("entropy data exhausted")); + } + } + if h.pt > 0 { + for plane in &mut planes { + for v in plane { + *v <<= h.pt; + } + } + } + Ok(FrameSubsampled { width: h.width, height: h.height, vertical_subsampling: vertical, planes }) +} + /// Decode a lossless JPEG stream. `max_samples` bounds the allocation (hostile headers). pub fn decode(d: &[u8], max_samples: usize) -> Result { let h = parse_header(d)?; + check_full_resolution(&h)?; let (w, ht, nc) = (h.width, h.height, h.comps.len()); if w == 0 || ht == 0 { return Err(err("zero frame size")); @@ -701,9 +811,70 @@ pub fn encode(data: &[u16], width: usize, height: usize, components: usize, prec } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + /// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order. + pub(crate) fn fixture_420() -> Vec { + fixture_subsampled(4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2]) + } + + fn fixture_subsampled(height: u8, sampling: u8, differences: &[i32]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, 4, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0]; + out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]); + let mut counts = [0; 16]; + counts[4] = 17; + out.extend_from_slice(&counts); + out.extend(0..=16); + out.extend_from_slice(&[0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 1, 0, 0]); + let mut bw = BitWriter { out, acc: 0, n: 0 }; + for &d in differences { + let s = ssss_of(d); + bw.put(s as u32, 5); + if s > 0 && s < 16 { + let v = if d < 0 { d - 1 } else { d }; + bw.put(v as u32 & ((1 << s) - 1), s as u32); + } + } + bw.flush(); + bw.out.extend_from_slice(&[0xff, 0xd9]); + bw.out + } + + #[test] + fn horizontal_only_subsampling_preserves_rows_and_chroma() { + // Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU. + let enc = fixture_subsampled(2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + let frame = decode_subsampled(&enc, 16).unwrap(); + assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 24).is_err()); + assert!(matches!(decode_subsampled(&enc, 15), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 16).is_err()); + } + + #[test] + fn subsampled_mcu_order_preserves_each_plane() { + let enc = fixture_420(); + let frame = decode_subsampled(&enc, 24).unwrap(); + assert_eq!((frame.width, frame.height), (4, 4)); + assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling. + assert!(matches!(decode_subsampled(&enc, 23), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 24).is_err()); + let mut bad = enc.clone(); + bad[10] = 3; // odd frame width + assert!(frame_info_subsampled(&bad).is_err()); + let sos = bad.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + bad = enc.clone(); + bad[sos + 11] = 2; // unsupported predictor + assert!(frame_info_subsampled(&bad).is_err()); + } + fn noise(n: usize, bits: u32, seed: u64) -> Vec { let mut s = seed; (0..n) diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index fc019743e..acaacf0b5 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -37,6 +37,8 @@ const BLACK_LEVEL: u16 = 0x7310; const WB_RGGB: u16 = 0x7313; const CROP_TOP_LEFT: u16 = 0x74c7; const CROP_SIZE: u16 = 0x74c8; +const YCBCR_COEFFICIENTS: u16 = 529; +const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). const MN_TAG2010: u16 = 0x2010; const MN_FULL_IMAGE_SIZE: u16 = 0xb02b; @@ -300,6 +302,71 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result> { Ok(out) } +/// 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). +fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Result> { + let total = check_image(bytes, info)?; + if info.samples_per_pixel != 3 || info.planar != 1 || !matches!(info.layout, Layout::Tiles { .. }) { + return Err(RawError::Unsupported("Sony linear YCbCr tile layout".into())); + } + let chunks = info.chunks(bytes.len() as u64); + let first = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("missing YCbCr tile".into()))?; + ljpeg::frame_info_subsampled(first)?; + let coefficients = raw.f64s(YCBCR_COEFFICIENTS).unwrap_or_else(|| vec![0.299, 0.587, 0.114]); + let [kr, kg, kb] = coefficients.as_slice() else { return Err(RawError::Corrupt("YCbCr coefficients".into())) }; + if !coefficients.iter().all(|v| v.is_finite() && *v > 0.0 && *v < 1.0) || (kr + kg + kb - 1.0).abs() > 0.001 { + return Err(RawError::Corrupt("invalid YCbCr coefficients".into())); + } + let references = raw.f64s(REFERENCE_BLACK_WHITE).unwrap_or_else(|| vec![0.0, 16383.0, 16384.0, 16384.0, 16384.0, 16384.0]); + let [yblack, ywhite, cbzero, cbwhite, crzero, crwhite] = references.as_slice() else { + return Err(RawError::Corrupt("YCbCr reference levels".into())); + }; + if !references.iter().all(|v| v.is_finite() && (0.0..=65535.0).contains(v)) + || *yblack != 0.0 + || *ywhite != 16383.0 + || cbzero != cbwhite + || crzero != crwhite + { + return Err(RawError::Unsupported("Sony YCbCr reference levels".into())); + } + if mode == Mode::Header { + return Ok(Vec::new()); + } + let decoded: Vec<(Chunk, ljpeg::FrameSubsampled)> = chunks + .par_iter() + .map(|c| { + let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("YCbCr tile outside file".into()))?; + let limit = (c.width as usize).checked_mul(c.height as usize).and_then(|n| n.checked_mul(3)).ok_or(RawError::Limit("tile too large"))?; + let f = ljpeg::decode_subsampled(src, limit)?; + if f.width != c.width as usize || f.height != c.height as usize { + return Err(RawError::Corrupt("Sony YCbCr tile dimensions".into())); + } + Ok((*c, f)) + }) + .collect::>()?; + let (w, h) = (info.width as usize, info.height as usize); + let mut out = vec![0u16; total]; + for (c, f) in decoded { + for y in 0..f.height.min(h.saturating_sub(c.y as usize)) { + for x in 0..f.width.min(w.saturating_sub(c.x as usize)) { + let luma = f.planes[0][y * f.width + x] as f64; + let ci = (y / f.vertical_subsampling) * (f.width / 2) + x / 2; + let cb = f.planes[1][ci] as f64 - cbzero; + let cr = f.planes[2][ci] as f64 - crzero; + let r = luma + (2.0 - 2.0 * kr) * cr; + let b = luma + (2.0 - 2.0 * kb) * cb; + let g = (luma - kr * r - kb * b) / kg; + let dst = (((c.y as usize + y) * w) + c.x as usize + x) * 3; + for (s, value) in [r, g, b].into_iter().enumerate() { + out[dst + s] = value.round().clamp(0.0, 65535.0) as u16; + } + } + } + } + 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; @@ -384,9 +451,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { return Err(RawError::Limit("image too large")); } let bits = info.bits() as u32; + let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; let chunks = info.chunks(bytes.len() as u64); let strip_len: u64 = chunks.iter().map(|c| c.len).sum(); let (data, out_bits) = match info.compression { + 7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14), 32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 && mode == Mode::Header => { chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; (RawData::U16(Vec::new()), 14) @@ -434,12 +503,17 @@ 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([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)), }; - let white = 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)); + let white = if linear_rgb { + 16383.0 + } 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)) + }; let model = ifd0.string(t::MODEL).unwrap_or_default(); let mn = tiff .exif() @@ -464,9 +538,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { format: RawFormat::Arw, width: w, height: h, - cpp: 1, + cpp: if linear_rgb { 3 } else { 1 }, data, - cfa: Some(cfa), + cfa: if linear_rgb { None } else { Some(cfa) }, bits: out_bits, black, white: vec![white], @@ -474,7 +548,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), color: ColorData::default(), - wb_multipliers: wb, + // Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again + // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. + wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, opcodes: OpcodeLists::default(), metadata, @@ -487,6 +563,38 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { mod tests { use super::*; + #[test] + fn downsized_lossless_is_linear_rgb_not_cfa() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7M4".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(TONE_CURVE, Value::Short(vec![0; 4])); + raw.set(BLACK_LEVEL, Value::Short(vec![512; 4])); + raw.set(WB_RGGB, Value::Short(vec![2048, 1024, 1024, 2048])); + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![ljpeg::tests::fixture_420()] }); + let file = TiffWriter::default().write(&[raw]).unwrap(); + let header = decode(&file, Mode::Header).unwrap(); + let full = decode(&file, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!((full.width, full.height, full.cpp), (4, 4, 3)); + assert!(full.cfa.is_none()); + assert_eq!(full.data.len(), 48); + assert_eq!(full.wb_multipliers, Some([1.0; 3])); + let RawData::U16(ref pixels) = full.data else { + panic!("integer ARW"); + }; + assert_eq!(&pixels[..3], &[1140, 929, 1000]); + let developed = full.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (4, 4)); + } + /// Encode one 16-value set as an ARW2 block (the paper's scheme, used here to test the decoder). pub(crate) fn encode_block(v: &[u16; 16]) -> [u8; 16] { let (imax, &max) = v.iter().enumerate().max_by_key(|(i, x)| (**x, usize::MAX - *i)).unwrap(); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index ad1870323..d58095f37 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -16,8 +16,6 @@ fn corpus_root() -> PathBuf { /// Variants known not to decode yet (see the crate docs): matched against the lower-case file name. const KNOWN_UNSUPPORTED: &[&str] = &[ "cr3-", // CR3 / CRX (M11.1) - "arw-sony-a7m4-lossless-m", // Sony lossless compressed M/S: subsampled (YCbCr) lossless JPEG - "arw-sony-a7m4-lossless-s", // " "raf-fuji-xt20-compressed", // Fujifilm compressed RAF "rw2-panasonic-gh5.", // Panasonic raw format 4 (quantised) "rw2-panasonic-gx80", // " diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a6b8fc432..597cb3814 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -514,18 +514,29 @@ mod tests { fn keyword_list_filters_renames_and_suggests() { // tall: the demo library's own keywords come first in the list let mut h = demo([1300.0, 1800.0]); + // Host folders arrive asynchronously above Keywords. Keep this keyword fixture's + // geometry independent of their existence and the background stat timing. + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + h.app.ui.hidden_locations.extend( + ["Pictures", "Desktop", "Downloads", ""] + .map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }), + ); + } let t = Duration::from_secs(10); let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10)); ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]})); ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]})); h.request("ui.set", json!({"leftPanel": true}), t); + // Coordinate-based clicks need the updated tree and panel layout to settle first. + assert!(h.settle(SETTLE), "keyword tree did not settle"); let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel")); assert_eq!(h.app.session.visible_cloned().len(), 3); // open the level, filter by the child h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t); + assert!(h.settle(SETTLE), "expanded keyword tree did not settle"); let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.session.visible_cloned().len(), 2); @@ -540,6 +551,7 @@ mod tests { h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t); ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]})); h.request("ui.set", json!({"right": "keywords"}), t); + assert!(h.settle(SETTLE), "keyword suggestions did not settle"); let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t); assert_eq!(r["ok"], true, "{r}"); assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string())); @@ -816,6 +828,12 @@ mod tests { #[test] fn local_location_can_be_hidden_and_restored() { let mut h = demo([1300.0, 900.0]); + // Windows' temp directory is usually below Home; hiding a location does not + // remove it from an expanded ancestor tree. Hide that host root in this fixture. + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + h.app.ui.hidden_locations.push(home); + } + let initially_hidden = h.app.ui.hidden_locations.len(); let t = Duration::from_secs(10); let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -829,9 +847,10 @@ mod tests { h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); h.settle(SETTLE); assert!(ids(&mut h).contains(&format!("source:local:{path}"))); - assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden")); + assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), initially_hidden > 0); let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.hidden_locations.len(), initially_hidden + 1); h.settle(SETTLE); let after = ids(&mut h); assert!(!after.contains(&format!("source:local:{path}")), "{after:?}"); @@ -857,8 +876,12 @@ mod tests { std::fs::create_dir_all(base.join(d)).unwrap(); } let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); - let (photos, day1, day2, other) = - (s(base.join("Photos")), s(base.join("Photos/2026/20260101")), s(base.join("Photos/2026/20260114")), s(base.join("Other"))); + let (photos, day1, day2, other) = ( + s(base.join("Photos")), + s(base.join("Photos").join("2026").join("20260101")), + s(base.join("Photos").join("2026").join("20260114")), + s(base.join("Other")), + ); let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); let rects = |h: &mut Headless| -> std::collections::HashMap { let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 1e4c0d143..9ef5233fb 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -371,10 +371,18 @@ fn local_folder_tree_expands_and_browses() { let base_s = base.to_string_lossy().to_string(); let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T); assert_eq!(r["ok"], true, "{r}"); - for _ in 0..3 { - h.step(); - } let trip = base.join("Trip").to_string_lossy().to_string(); + // Listing runs on a worker: three frames do not guarantee its reply has arrived. + let trip_widget = format!("source:local:{trip}"); + let started = std::time::Instant::now(); + loop { + let widgets = h.request("ui.widgets", json!({"filter": trip_widget}), T); + if widgets["result"].as_array().is_some_and(|ws| ws.iter().any(|w| w["id"] == trip_widget)) { + break; + } + assert!(started.elapsed() < T, "subfolder listing did not arrive: {widgets}"); + std::thread::sleep(Duration::from_millis(1)); + } let r = h.request("ui.clickWidget", json!({"id": format!("source:local:{trip}")}), T); assert_eq!(r["ok"], true, "the subfolder is listed: {r}"); assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(trip.clone()), "clicking it browses it"); diff --git a/docs/parity.md b/docs/parity.md index 135b6ba12..b5650b3d8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -122,7 +122,7 @@ 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; 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` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), 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), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, 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), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), 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, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, 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), HEIC/AVIF decode | | 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: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) 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). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 12984b9a0..e9f2fecce 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,10 @@ ## October 2026 +### RAW decoding +- Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB, + preserving RAW editing & full-resolution export instead of using embedded JPEG previews. + ### Presets and profiles - Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip` bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.