From 169a4ed059b2227e320c4c432654f8f7cfc41a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Huba=C5=99?= Date: Thu, 8 Oct 2026 11:01:44 +0200 Subject: [PATCH] LR-IMP-FORMATS: ORF colour-filter layout from the file's Exif CFAPattern Split out of #240 without the compressed decoder (crates/raw/src/vendor/orfc.rs is not part of this change; compressed ORF stays preview-only). - The layout is read from Exif CFAPattern (0xa302, repeat counts in either byte order) when it describes a 2x2 Bayer cell. BGGR and GBRG were not reachable before. - Without a usable tag the green diagonal is still found from the samples, now from totals of 32x32 blocks instead of single-pixel differences, so fine texture no longer outweighs a small red/blue difference. The old version indexed the samples directly; this one reads them with get(). - probe_info no longer unpacks 12-bit packed ORFs that state their layout (XZ-2: under 0.1 ms; it ran the same unpack as a decode before, which takes 11 ms). 16-bit files still read their samples to find the bit depth. Tests: stated layouts in both byte orders, unusable tags falling back to the samples, the texture case, hostile areas for the fallback, probe_info equal to decode; a corpus test comparing the E-1, E-400 and XZ-2 mosaics with their embedded JPEGs under every layout. No user-visible change yet for the bodies whose layout the old guess got wrong (E-M1 and E-620 are BGGR): they write compressed ORF. Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/lib.rs | 8 +- crates/raw/src/vendor/orf.rs | 202 ++++++++++++++++++++++++++++++----- crates/raw/tests/corpus.rs | 29 +++++ docs/parity.md | 2 +- 4 files changed, 212 insertions(+), 29 deletions(-) diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index f67649de8..3e17bd363 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -159,9 +159,9 @@ pub fn decode(bytes: &[u8]) -> Result { /// measure them from the samples). /// /// A few uncompressed vendor formats derive part of this from the samples themselves (Nikon -/// NEF: optically masked trailing columns; Olympus ORF: the CFA phase and bit depth; Pentax PEF -/// without crop tags: dark borders); for those the samples are read (unpacked, nothing to -/// decompress) and dropped. +/// NEF: optically masked trailing columns; Olympus ORF: the bit depth of 16-bit files, and the CFA +/// phase of files without an Exif `CFAPattern`; Pentax PEF without crop tags: dark borders); for +/// those the samples are read (unpacked, nothing to decompress) and dropped. pub fn probe_info(bytes: &[u8]) -> Result { decode_with(bytes, Mode::Header).map(RawImage::into_info) } @@ -185,7 +185,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Raf => vendor::raf::decode(bytes, mode), RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), - RawFormat::Orf => vendor::orf::decode(bytes), + RawFormat::Orf => vendor::orf::decode(bytes, mode), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 52f597d2c..59c5f14c4 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -1,10 +1,10 @@ //! Olympus ORF — uncompressed variants. //! -//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), the ExifTool -//! Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — `0x0101/0x0102` -//! PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` BlackLevel2, -//! `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from raw.pixls.us -//! (E-1, E-400, XZ-2): +//! Sources: TIFF 6.0 (the `IIRO`/`MMOR` container is a TIFF with a different magic number), Exif 2.3 (`CFAPattern`, +//! tag `0xa302`), the ExifTool Olympus tag-name documentation (maker-note sub-directories `0x2020` CameraSettings — +//! `0x0101/0x0102` PreviewImageStart/Length — and `0x2040` ImageProcessing — `0x0100` WB_RBLevels, `0x0600` +//! BlackLevel2, `0x0612–0x0615` CropLeft/Top/Width/Height) and our own black-box analysis of CC0 samples from +//! raw.pixls.us (E-1, E-400, XZ-2): //! //! - 16 bits per sample: little-endian words; some bodies (E-1, E-400) store 12-bit values in the top bits (the low //! four bits zero in more than 99% of samples), which we shift down. @@ -12,14 +12,16 @@ //! candidate bit orders for the smoothest image). //! - Olympus's compressed ORF (most interchangeable-lens bodies since ~2008) is not decoded: no permissively //! licensed description exists. It reports [`RawError::Unsupported`]; the embedded preview still works. -//! - The files carry no CFA tag. The green diagonal is found from the data (the diagonal whose two samples differ -//! least); bodies with greens on the main diagonal (E-1, E-400) are GRBG, the others (XZ-2) RGGB — verified by -//! colour renders. +//! - The colour-filter layout is the file's Exif `CFAPattern`: GRBG on the E-1 and E-400, RGGB on the XZ-2, where +//! the colours of the decoded mosaic follow the embedded JPEG best with exactly that layout +//! (`corpus_orf_cfa_patterns`). The samples' active areas start at even offsets, so they can't tell whether the +//! pattern is anchored at the sensor origin (assumed) or at the active area. Files without a usable tag fall back +//! to the green diagonal found from the data, which can't tell red from blue (GRBG or RGGB is assumed). use super::white_from_data; use crate::tiffraw::{Packing, read_image}; use crate::unpack::unpack_msb; -use crate::{BlackLevel, Cfa, ColorData, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::makernote::MakerNote; @@ -33,6 +35,8 @@ pub(crate) const PREVIEW_LENGTH: u16 = 0x0102; const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; +/// Exif `CFAPattern`. +const EXIF_CFA_PATTERN: u16 = 0xa302; /// The Olympus maker note. pub(crate) fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option { @@ -66,23 +70,52 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { unpack_msb(&swapped, bits, out); } -/// GRBG when the greens sit on the main diagonal of the 2×2 cell at the sensor origin, else RGGB. +/// The colour-filter layout from the Exif `CFAPattern` tag (`0xa302`: two 16-bit repeat counts, found in either +/// byte order, then one byte per site: 0 = red, 1 = green, 2 = blue), when it describes a 2×2 Bayer cell. +fn cfa_from_exif(tiff: &Tiff) -> Option { + let &[c0, c1, r0, r1, s0, s1, s2, s3] = tiff.exif()?.bytes(EXIF_CFA_PATTERN)? else { return None }; + let two = |a: u8, b: u8| matches!((a, b), (2, 0) | (0, 2)); + let name = match [s0, s1, s2, s3] { + [0, 1, 1, 2] => "RGGB", + [2, 1, 1, 0] => "BGGR", + [1, 0, 2, 1] => "GRBG", + [1, 2, 0, 1] => "GBRG", + _ => return None, + }; + (two(c0, c1) && two(r0, r1)).then(|| Cfa::bayer_static(name)) +} + +/// The layout found from the samples, for files without the Exif tag: GRBG when the greens sit on the main +/// diagonal of the 2×2 cell at the sensor origin, else RGGB. Over 32×32-pixel blocks of `a` (every fourth in both +/// directions) it compares the block totals of the two sites on each diagonal: the two greens of a block see the +/// same light, red and blue rarely do. Totals rather than single pixels, so that fine texture, which makes +/// neighbouring greens differ, doesn't outweigh a small difference between red and blue. pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa { - let (x0, y0) = (a.x + ((a.width / 8) & !1), a.y + ((a.height / 8) & !1)); - let (x1, y1) = (a.x + a.width * 7 / 8, a.y + a.height * 7 / 8); + const BLOCK: usize = 32; + let (x0, y0) = (a.x.saturating_add(1) & !1, a.y.saturating_add(1) & !1); + let across = a.x.saturating_add(a.width).min(w).saturating_sub(x0) / BLOCK; + let down = a.y.saturating_add(a.height).saturating_sub(y0) / BLOCK; let (mut main, mut anti) = (0u64, 0u64); - for y in (y0..y1.saturating_sub(1)).step_by(16) { - for x in (x0..x1.saturating_sub(1)).step_by(8) { - let (x, y) = (x & !1, y & !1); - let p = |dx: usize, dy: usize| d[(y + dy) * w + x + dx] as i64; - main += (p(0, 0) - p(1, 1)).unsigned_abs(); - anti += (p(1, 0) - p(0, 1)).unsigned_abs(); + for by in (0..down).step_by(4) { + for bx in (0..across).step_by(4) { + let mut sums = [0i64; 4]; + for y in 0..BLOCK { + let row = (y0 + by * BLOCK + y).checked_mul(w).and_then(|r| r.checked_add(x0 + bx * BLOCK)); + // rows past the end of the samples: nothing more to read + let Some(row) = row.and_then(|start| d.get(start..start.checked_add(BLOCK)?)) else { break }; + for (x, &v) in row.iter().enumerate() { + sums[(y & 1) * 2 + (x & 1)] += v as i64; + } + } + let [s0, s1, s2, s3] = sums; + main += (s0 - s3).unsigned_abs(); + anti += (s1 - s2).unsigned_abs(); } } Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" }) } -pub(crate) fn decode(bytes: &[u8]) -> Result { +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let info = ifd0.image()?; @@ -90,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; let chunks = info.chunks(bytes.len() as u64); let total: u64 = chunks.iter().map(|c| c.len).sum(); + let stated = cfa_from_exif(&tiff); let (mut data, bits) = if info.compression != 1 { return Err(RawError::Unsupported(format!("ORF compression {}", info.compression))); } else if total >= (n as u64) * 2 { @@ -97,9 +131,16 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { (d, 16) } else if total * 8 >= (n as u64) * 12 && total * 8 < (n as u64) * 13 && chunks.len() == 1 { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?; - let stride = src.len() / h; - let mut d = vec![0u16; n]; - d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row)); + // the depth is fixed here, so a header-only probe needs the samples only when the file doesn't state its + // colour-filter layout + let d = if mode == Mode::Full || stated.is_none() { + let stride = src.len() / h; + let mut d = vec![0u16; n]; + d.par_chunks_mut(w).enumerate().for_each(|(y, row)| unpack_row_le32_msb(&src[y * stride..(y + 1) * stride], 12, row)); + d + } else { + Vec::new() + }; (RawData::U16(d), 12) } else { return Err(RawError::Unsupported("Olympus compressed ORF".into())); @@ -128,7 +169,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { Some([Some(x), Some(y), Some(cw), Some(ch)]) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= h => Rect::new(x, y, cw, ch), _ => Rect::new(0, 0, w, h), }; - let cfa = cfa_from_data(samples, w, active); + let cfa = stated.unwrap_or_else(|| cfa_from_data(samples, w, active)); let black = match ip.as_ref().and_then(|i| i.f64s(BLACK)).as_deref() { Some(v @ [_, _, _, _]) => { let a = cfa.shifted(active.x, active.y); @@ -165,7 +206,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { opcodes: OpcodeLists::default(), metadata, }; - img.validate()?; + img.validate_for(mode)?; Ok(img) } @@ -185,6 +226,11 @@ mod tests { use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter}; fn orf(w: u32, h: u32, bits: u16, strip: Vec) -> Vec { + orf_with(w, h, bits, strip, None) + } + + /// A minimal ORF with one strip in IFD0 and, optionally, an Exif `CFAPattern`. + fn orf_with(w: u32, h: u32, bits: u16, strip: Vec, cfa_pattern: Option<&[u8]>) -> Vec { let mut ifd = IfdBuilder::new(); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); @@ -193,12 +239,120 @@ mod tests { ifd.set(t::PHOTOMETRIC, Value::Short(vec![1])); ifd.set(t::MAKE, Value::Ascii("OLYMPUS IMAGING CORP.".into())); ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); + if let Some(p) = cfa_pattern { + let mut exif = IfdBuilder::new(); + exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + } let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); b[2] = b'R'; b[3] = b'O'; b } + /// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy). + fn mosaic(w: usize, h: usize, site: impl Fn(usize, usize) -> [u16; 4]) -> Vec { + (0..w * h).flat_map(|i| site(i % w / 2, i / w / 2)[((i / w) & 1) * 2 + ((i % w) & 1)].to_le_bytes()).collect() + } + + /// Smooth scene texture shared by the four sites of a cell. + fn shade(cx: usize, cy: usize) -> u16 { + ((cx * 7 + cy * 13) % 23) as u16 + } + + const BGGR: &[u8] = &[2, 0, 2, 0, 2, 1, 1, 0]; + const GRBG: &[u8] = &[2, 0, 2, 0, 1, 0, 2, 1]; + // repeat counts in the other byte order + const RGGB: &[u8] = &[0, 2, 0, 2, 0, 1, 1, 2]; + const GBRG: &[u8] = &[0, 2, 0, 2, 1, 2, 0, 1]; + + fn layout(bytes: &[u8]) -> String { + let r = crate::decode(bytes).unwrap(); + assert_eq!(crate::probe_info(bytes).unwrap(), r.info()); + r.cfa.unwrap().name() + } + + #[test] + fn exif_cfa_pattern_states_the_layout() { + let (w, h) = (160usize, 144usize); + // samples whose green diagonal alone would say RGGB + let px = mosaic(w, h, |cx, cy| [600, 1000, 1000, 300].map(|v| v + shade(cx, cy))); + for (tag, name) in [(BGGR, "BGGR"), (GRBG, "GRBG"), (RGGB, "RGGB"), (GBRG, "GBRG")] { + assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name); + } + // not a 2×2 Bayer cell (four greens, greens side by side, a fourth colour), other repeat counts, wrong + // lengths: the samples decide + let unusable: [&[u8]; 8] = [ + &[2, 0, 2, 0, 1, 1, 1, 1], + &[2, 0, 2, 0, 1, 1, 0, 2], + &[2, 0, 2, 0, 0, 1, 1, 3], + &[3, 0, 3, 0, 0, 1, 1, 2], + &[2, 2, 2, 0, 0, 1, 1, 2], + &[2, 0, 2, 0, 1, 0], + &[2, 0, 2, 0, 2, 1, 1, 0, 0], + &[], + ]; + for (site, name) in [([600, 1000, 1000, 300], "RGGB"), ([1000, 600, 300, 1000], "GRBG")] { + let px = mosaic(w, h, |cx, cy| site.map(|v| v + shade(cx, cy))); + assert_eq!(layout(&orf(w as u32, h as u32, 16, px.clone())), name); + for tag in unusable { + assert_eq!(layout(&orf_with(w as u32, h as u32, 16, px.clone(), Some(tag))), name, "{tag:?}"); + } + } + } + + #[test] + fn packed_12_bit_probe_agrees_with_decode() { + let (w, h) = (16usize, 4usize); + let strip: Vec = (0..w * h * 3 / 2).map(|i| (i * 37 % 251) as u8).collect(); + for tag in [Some(BGGR), None] { + let bytes = orf_with(w as u32, h as u32, 12, strip.clone(), tag); + let r = crate::decode(&bytes).unwrap(); + assert_eq!((r.bits, r.data.len()), (12, w * h)); + assert_eq!(r.cfa.as_ref().unwrap().name(), if tag.is_some() { "BGGR" } else { "RGGB" }); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + } + } + + #[test] + fn data_fallback_looks_past_fine_texture() { + let (w, h) = (160usize, 144usize); + // greens on the main diagonal, red and blue close; a checkerboard of detail makes the two greens of a cell + // differ by far more than red differs from blue + let detail = |cx: usize, cy: usize| if (cx + cy) & 1 == 0 { 150 } else { -150i32 }; + let site = |cx: usize, cy: usize| { + let (g, d) = (1000 + shade(cx, cy) as i32, detail(cx, cy)); + [(g + d) as u16, 500, 520, (g - d) as u16] + }; + assert_eq!(layout(&orf(w as u32, h as u32, 16, mosaic(w, h, site))), "GRBG"); + // the same scene with the greens on the other diagonal + let px = mosaic(w, h, |cx, cy| { + let [g0, r, b, g1] = site(cx, cy); + [r, g0, g1, b] + }); + assert_eq!(layout(&orf(w as u32, h as u32, 16, px)), "RGGB"); + } + + #[test] + fn data_fallback_survives_any_area() { + let d = vec![100u16; 64 * 64]; + let big = usize::MAX; + for a in [ + Rect::new(0, 0, 64, 64), + Rect::new(0, 0, 0, 0), + Rect::new(0, 0, 31, 31), + Rect::new(63, 63, 1, 1), + Rect::new(0, 0, 4096, 4096), + Rect::new(big, big, big, big), + Rect::new(0, big - 40, 64, 40), + ] { + for w in [64, 0, 1, big] { + assert_eq!(cfa_from_data(&d, w, a).name(), "RGGB", "{a:?} width {w}"); + assert_eq!(cfa_from_data(&[], w, a).name(), "RGGB", "{a:?} width {w}"); + } + } + } + #[test] fn word16_shifted_and_packed12() { let (w, h) = (16usize, 4usize); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index e4761eaef..bce60a5da 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -427,3 +427,32 @@ fn corpus_panasonic_encodings() { } eprintln!("Panasonic encodings checked on {seen} files"); } + +/// Olympus ORF (`crates/raw/src/vendor/orf.rs`): the colour-filter layout comes from the file's Exif `CFAPattern`. +/// With it, the decoded mosaic's colours follow the camera's own JPEG better than with any other layout. +#[test] +fn corpus_orf_cfa_patterns() { + let dir = corpus_root().join("raw"); + let cases = [("orf-olympus-e1.orf", "GRBG"), ("orf-olympus-e400.orf", "GRBG"), ("orf-olympus-xz2.orf", "RGGB")]; + let mut seen = 0; + for (name, cfa) 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}")); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout"); + assert_eq!(green_on_main_diagonal(&img), cfa.starts_with('G'), "{name}: mosaic statistics disagree with {cfa}"); + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no embedded preview")); + let (gw, gh) = (24, 18); + let (sites, jpeg) = (raw_sites(&img, gw, gh), jpeg_cells(&jpeg, gw, gh)); + let agree = |l: &str| chroma_agreement(&sites, &jpeg, &lightcraft_raw::Cfa::bayer(l).unwrap().pattern); + let best_other = ["RGGB", "GRBG", "GBRG", "BGGR"].into_iter().filter(|l| *l != cfa).map(agree).fold(f64::MIN, f64::max); + let own = agree(cfa); + let a = img.active_area; + eprintln!("{name:32} {cfa}, active area at ({}, {}): colour agreement {own:.3} (others ≤ {best_other:.3})", a.x, a.y); + assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})"); + seen += 1; + } + eprintln!("ORF CFA layouts checked on {seen} files"); +} diff --git a/docs/parity.md b/docs/parity.md index d5a31ea9c..1ab441c12 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -124,7 +124,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; 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 / 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. 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, 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 / 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`). 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, 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: ~55 CC0 corpus files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). 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 |