From 04e8aea57729a9ade361b0095d4c4216e5d6f367 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Thu, 8 Oct 2026 21:39:09 -0400 Subject: [PATCH] LR-IMP-FORMATS: CR3 photos render in the aspect ratio set in the camera The Canon maker note's AspectInfo (tag 0x009a: aspect code, cropped width, height, left, top) holds the rectangle of the shot, measured from the recommended image crop. Use it as the CR3 default crop, so 3:2, 4:3, 16:9 and 1:1 shots develop in the shape the photographer chose. The tag is ignored when it is missing, does not fit, or the recommended crop itself is inconsistent. RENDER_CACHE_VERSION 15 -> 16. The M50 C-RAW corpus sample (raw.pixls.us 2663) was shot at 1:1 and now develops at 4000 x 4000. --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/cr3.rs | 102 +++++++++++++++++++++++++++++---- crates/raw/tests/cr3_corpus.rs | 31 +++++++--- docs/cr3.md | 6 ++ docs/parity.md | 2 +- 5 files changed, 122 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1ed89e47..d5ae4b9e 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 = 15; +pub const RENDER_CACHE_VERSION: u64 = 16; /// 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/cr3.rs b/crates/raw/src/vendor/cr3.rs index c51f8e30..b5442790 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -7,6 +7,15 @@ //! //! `IAD1` stores the valid sensor area separately from the recommended image crop. Both use inclusive //! sensor offsets. The CFA is anchored at the full sensor's origin; cropping shifts its phase later. +//! +//! The in-camera aspect ratio (3:2, 4:3, 16:9, 1:1 ...) is not applied to the sensor data: the raw always +//! holds the whole image. The maker note's `AspectInfo` (tag `0x009a`; ExifTool names `AspectRatio`, +//! `CroppedImageWidth`, `CroppedImageHeight`, `CroppedImageLeft`, `CroppedImageTop`) holds the shot's rectangle +//! as five 32-bit values, measured from the top-left of the recommended image crop (the "Canon image", whose +//! size is ExifTool's `CanonImageWidth` x `CanonImageHeight`). That rectangle replaces the crop. Established +//! on the CC0 PowerShot SX70 HS, EOS 250D, PowerShot G5 X Mark II and EOS M6 Mark II samples shot at 3:2, 4:3, +//! 16:9 and 1:1: the rectangle is centred, has the shot's aspect, and files shot at the sensor's own aspect +//! store the whole image. //! Unknown camera metadata versions are ignored rather than assigned a guessed white balance. use super::{black_from_columns, crx, white_from_data}; @@ -17,6 +26,7 @@ use lightcraft_tiff::{Ifd, Tiff}; const SENSOR_INFO: u16 = 0x00e0; const COLOR_DATA: u16 = 0x4001; const LENS_MODEL: u16 = 0x0095; +const ASPECT_INFO: u16 = 0x009a; /// Pick the first largest raw track. Equal-sized later tracks can be the Dual Pixel delta, which /// must never replace the main sensor image. An invalid full-size descriptor is reported, not @@ -83,16 +93,26 @@ fn sensor_crop(maker: Option<&Ifd>, width: usize, height: usize) -> Option ) } +/// The shot's aspect-ratio rectangle from `AspectInfo`, placed inside `image` (the recommended crop). `None` +/// when the tag is absent or malformed, or its rectangle doesn't fit inside `image`. +fn aspect_crop(maker: Option<&Ifd>, image: Rect) -> Option { + let data = maker?.u64s(ASPECT_INFO)?; + let [width, height, left, top] = [1usize, 2, 3, 4].map(|i| data.get(i).and_then(|&v| usize::try_from(v).ok())); + let (width, height, left, top) = (width?, height?, left?, top?); + if width < 2 || height < 2 || left.checked_add(width)? > image.width || top.checked_add(height)? > image.height { + return None; + } + Some(Rect::new(image.x.checked_add(left)?, image.y.checked_add(top)?, width, height)) +} + fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) { let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height); let sensor = sensor_crop(maker, width, height); let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height)); - let crop = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)).unwrap_or(Rect::new( - 0, - 0, - active.width, - active.height, - )); + // The recommended crop, when the file gives a consistent one. `AspectInfo` is measured from its origin, so it + // is only applied on top of that; without it (the crop doesn't fit the valid area) the whole active area stays. + let recommended = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)); + let crop = recommended.map(|c| aspect_crop(maker, c).unwrap_or(c)).unwrap_or(Rect::new(0, 0, active.width, active.height)); (active, crop) } @@ -266,6 +286,10 @@ mod tests { } fn synthetic_header() -> Vec { + synthetic_header_with(None) + } + + fn synthetic_header_with(aspect: Option<[u32; 5]>) -> Vec { use lightcraft_tiff::{ByteOrder, TiffWriter, tags as t}; let tiff = |b: IfdBuilder| TiffWriter::new(ByteOrder::Little, false).write(&[b]).unwrap(); let cmt1 = tiff( @@ -275,11 +299,13 @@ mod tests { .with(t::ORIENTATION, Value::Short(vec![6])), ); let cmt2 = tiff(IfdBuilder::new().with(t::ISO_SPEED, Value::Short(vec![800]))); - let cmt3 = tiff( - IfdBuilder::new() - .with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45])) - .with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into())), - ); + let mut cmt3 = IfdBuilder::new() + .with(SENSOR_INFO, Value::Short(vec![34, 64, 48, 1, 1, 6, 4, 61, 45])) + .with(LENS_MODEL, Value::Ascii("RF-S18-45mm F4.5-6.3 IS STM".into())); + if let Some(a) = aspect { + cmt3 = cmt3.with(ASPECT_INFO, Value::Long(a.to_vec())); + } + let cmt3 = tiff(cmt3); let mut canon = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; canon.extend([box_bytes(b"CMT1", &cmt1), box_bytes(b"CMT2", &cmt2), box_bytes(b"CMT3", &cmt3)].concat()); let mut coding = vec![0u8; 52]; @@ -374,6 +400,60 @@ mod tests { assert!(decode(&bytes[..4200], Mode::Header).is_err()); } + #[test] + fn aspect_info_replaces_the_crop() { + // 16:9 inside the 56 x 42 recommended crop at (2, 2): 56 x 30, 6 rows down. + let bytes = synthetic_header_with(Some([7, 56, 30, 0, 6])); + let image = decode(&bytes, Mode::Header).unwrap(); + assert_eq!(image.active_area, Rect::new(4, 2, 60, 46)); + assert_eq!(image.crop, Rect::new(2, 8, 56, 30)); + assert_eq!((image.metadata.width, image.metadata.height), (Some(56), Some(30))); + // 1:1 with a left offset. + let image = decode(&synthetic_header_with(Some([1, 42, 42, 7, 0])), Mode::Header).unwrap(); + assert_eq!(image.crop, Rect::new(9, 2, 42, 42)); + // The whole image, as stored for a shot at the sensor's own aspect, changes nothing. + let image = decode(&synthetic_header_with(Some([0, 56, 42, 0, 0])), Mode::Header).unwrap(); + assert_eq!(image.crop, Rect::new(2, 2, 56, 42)); + } + + #[test] + fn aspect_info_that_does_not_fit_is_ignored() { + for bad in [[7, 57, 30, 0, 6], [7, 56, 30, 0, 13], [7, 56, 30, 1, 6], [7, 1, 30, 0, 0], [7, 56, 0, 0, 0], [7, u32::MAX, 30, 0, 0]] { + let image = decode(&synthetic_header_with(Some(bad)), Mode::Header).unwrap(); + assert_eq!(image.crop, Rect::new(2, 2, 56, 42), "{bad:?}"); + } + } + + /// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their valid area. The + /// whole active area stays then, and `AspectInfo`, which is measured from the recommended crop, is not applied. + #[test] + fn aspect_info_needs_a_consistent_recommended_crop() { + let aspect = lightcraft_tiff::IfdBuilder::new().with(ASPECT_INFO, Value::Long(vec![13, 5088, 3392, 0, 0])); + let bytes = lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[aspect]).unwrap(); + let maker = Tiff::parse(&bytes).unwrap().ifds.remove(0); + let area = + |crop| Cr3ImageArea { width: 5376, height: 3574, crop, active: Some([132, 160, 5243, 3567]), masked_left: [0; 4], masked_top: None }; + // crop right edge 5359 > active right edge 5243: not a crop of the active area + assert_eq!(geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574).1, Rect::new(0, 0, 5112, 3408)); + // consistent crop: AspectInfo applies from its origin + assert_eq!(geometry(Some(&area([140, 172, 5227, 3563])), Some(&maker), 5376, 3574).1, Rect::new(8, 12, 5088, 3392)); + } + + /// `AspectInfo` as the SX70 HS 16:9 sample stores it, byte for byte (little-endian LONG x 5), read through + /// the TIFF parser: AspectRatio 7, 5184 x 2912 at (0, 488). + #[test] + fn aspect_info_known_bytes() { + let mut tiff = b"II*".to_vec(); + tiff.extend_from_slice(&[0x9a, 0x00, 0x04, 0x00, 0x05, 0x00, 0x00, 0x00, 0x1a, 0x00, 0x00, 0x00]); + tiff.extend_from_slice(&[0, 0, 0, 0]); + tiff.extend_from_slice(&[0x07, 0, 0, 0, 0x40, 0x14, 0, 0, 0x60, 0x0b, 0, 0, 0, 0, 0, 0, 0xe8, 0x01, 0, 0]); + let parsed = Tiff::parse(&tiff).unwrap(); + let maker = parsed.ifds.first(); + assert_eq!(aspect_crop(maker, Rect::new(132, 40, 5184, 3888)), Some(Rect::new(132, 528, 5184, 2912))); + assert_eq!(aspect_crop(maker, Rect::new(0, 0, 5184, 2000)), None); + assert_eq!(aspect_crop(None, Rect::new(0, 0, 5184, 3888)), None); + } + fn prepend_tracks(bytes: &[u8], tracks: &[u8]) -> Vec { let ftyp_end = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; let moov_len = u32::from_be_bytes(bytes[ftyp_end..ftyp_end + 4].try_into().unwrap()) as usize; diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index 6e270576..6cd28c3b 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -15,7 +15,7 @@ fn corpus() -> PathBuf { std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) } -fn check(name: &str, hash: u64, sensor: (usize, usize)) { +fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize)) { let path = corpus().join("raw").join(name); let Ok(bytes) = std::fs::read(&path) else { eprintln!("skip: {} absent", path.display()); @@ -25,7 +25,7 @@ fn check(name: &str, hash: u64, sensor: (usize, usize)) { assert_eq!(raw.format, RawFormat::Cr3); raw.validate().unwrap(); assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1)); - assert_eq!(raw.info().developed_size(), (6000, 4000)); + assert_eq!(raw.info().developed_size(), developed); assert_eq!(raw.info(), probe_info(&bytes).unwrap()); let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; let actual = @@ -39,40 +39,55 @@ fn check(name: &str, hash: u64, sensor: (usize, usize)) { fn canon_m50_lossless_sensor_is_exact() { // raw.pixls.us/getfile.php/4657; SHA256 // 1ba9ad6b51b315b88820eb0fe5fd7f52bc136ba37b1a5ca76825ebfe25b18058 - check("cr3-canon-m50-raw.cr3", 0x6226_1f0b_a81c_fcd2, (6288, 4056)); + check("cr3-canon-m50-raw.cr3", 0x6226_1f0b_a81c_fcd2, (6288, 4056), (6000, 4000)); } #[test] fn canon_r100_lossless_sensor_is_exact() { // raw.pixls.us/getfile.php/7896; SHA256 // 6d83217d58a5e6d2dabcf470430e91a16676b60531c025b81fa7453fc74b0521 - check("cr3-canon-r100-raw.cr3", 0xcbe5_299a_b9c5_2630, (6288, 4056)); + check("cr3-canon-r100-raw.cr3", 0xcbe5_299a_b9c5_2630, (6288, 4056), (6000, 4000)); } #[test] fn canon_m50_craw_sensor_is_exact() { // raw.pixls.us/getfile.php/2663; SHA256 // 15384b775867ec4c42b11882837f1e368cedc0561832ffab271221e6bb80be4c - check("cr3-canon-m50-craw.cr3", 0x9aac_bfe6_6f50_5e1b, (6288, 4056)); + // The camera was set to 1:1: the file's AspectInfo is a 4000 x 4000 square at x = 1000 inside the 6000 x 4000 + // image (the archive's "3:2" label is wrong). The sensor samples are unchanged; only the crop is. + check("cr3-canon-m50-craw.cr3", 0x9aac_bfe6_6f50_5e1b, (6288, 4056), (4000, 4000)); } #[test] fn canon_r100_craw_sensor_is_exact() { // raw.pixls.us/getfile.php/7897; SHA256 // 0b66842b2fe00329ebc05fe8ab9357ddbbe1a0a2d2e69a2893102de7fdf5c942 - check("cr3-canon-r100-craw.cr3", 0x341c_706b_37c3_8bbf, (6288, 4056)); + check("cr3-canon-r100-craw.cr3", 0x341c_706b_37c3_8bbf, (6288, 4056), (6000, 4000)); } #[test] fn canon_r8_lossless_sensor_is_exact() { // raw.pixls.us/getfile.php/6585; SHA256 // 7d5c6dbb11ff7e6ee58715d90a20f5d801e8b103f4711ff6ea329ceafcef254c - check("cr3-canon-r8-raw.cr3", 0x63c1_c6d8_d1eb_2312, (6188, 4120)); + check("cr3-canon-r8-raw.cr3", 0x63c1_c6d8_d1eb_2312, (6188, 4120), (6000, 4000)); } #[test] fn canon_r8_craw_sensor_is_exact() { // raw.pixls.us/getfile.php/6587; SHA256 // df33cf394573645ce03dea1b2e9f0b5cc2b7734e9e3391ee7114151414cf2812 - check("cr3-canon-r8-craw.cr3", 0xf836_a663_a795_a375, (6188, 4120)); + check("cr3-canon-r8-craw.cr3", 0xf836_a663_a795_a375, (6188, 4120), (6000, 4000)); +} + +#[test] +fn canon_m50_craw_crop_is_the_shot_aspect() { + // raw.pixls.us/getfile.php/2663: the 1:1 square starts 1000 px right of the recommended crop's origin, + // which is (12, 12) inside the valid sensor area. + let path = corpus().join("raw").join("cr3-canon-m50-craw.cr3"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let raw = decode(&bytes).unwrap(); + assert_eq!((raw.crop.x, raw.crop.y, raw.crop.width, raw.crop.height), (1012, 12, 4000, 4000)); } diff --git a/docs/cr3.md b/docs/cr3.md index 692c209d..c89d3c30 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -5,6 +5,12 @@ The CR3 adapter reads the CRX Bayer track, `CMP1` compression descriptor, black/white levels and the default crop come from the file. A Dual Pixel delta track is never substituted for the primary Bayer track. +The in-camera aspect ratio (3:2, 4:3, 16:9, 1:1) is not applied to the sensor data. The maker note's +`AspectInfo` (tag `0x009a`: aspect code, cropped width, height, left, top) gives the shot's rectangle inside the +recommended crop, and the decoder uses it as the default crop, so the developed image has the shape the photographer +framed (PowerShot SX70 HS, EOS 250D, PowerShot G5 X Mark II, EOS M6 Mark II and EOS M50 samples). A rectangle that +is missing or does not fit inside the crop is ignored. The embedded JPEG still shows the whole frame. + The decoder is independent Rust code shared by desktop, CLI and WebAssembly; it has no operating-system imaging dependency or external decoder. Its sources are the public prose descriptions and patents cited in diff --git a/docs/parity.md b/docs/parity.md index 90dc8a74..2eaae48b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -137,7 +137,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 (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` | 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), 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`). 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; 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, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | 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), 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`). 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, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | | LR-IMP-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 |