From 04e8aea57729a9ade361b0095d4c4216e5d6f367 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Thu, 8 Oct 2026 21:39:09 -0400 Subject: [PATCH 001/344] 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 | From 82f904495f6608e8ff16e7cea3ed30486f8beab1 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Thu, 8 Oct 2026 23:16:10 -0400 Subject: [PATCH 002/344] LR-IMP-FORMATS: decode lossy JPEG XL DNG tiles completely and without clipping Decode every JPEG XL tile with wide (32-bit) modular buffers: the modular_16bit_buffers header flag does not hold on real lossy files (68 of 108 tiles failed with narrow buffers). Lossy (XYB) tiles are taken from the decoder as linear sRGB primaries and restored to the declared primaries with a plain 3x3 matrix, instead of the decoder's gamut-mapped, [0, 1]-clipped conversion. Lossy tiles whose declared encoding cannot be restored faithfully (ICC profile, non-linear transfer function, non-D65 white point, intensity target above 255 nits, single plane) are unsupported. Lossless output is unchanged. A JPEG XL tile that fails to decode is now an error for the image (the file falls back to its embedded preview) instead of a zero tile. Render cache version 17. --- ROADMAP.md | 2 +- crates/engine/src/media.rs | 2 +- crates/raw/src/jxl.rs | 223 +++++++++++++++++++++++++++++++++++-- crates/raw/src/tiffraw.rs | 4 + crates/raw/tests/dng.rs | 20 ++++ docs/parity.md | 2 +- docs/whats-new.md | 3 +- 7 files changed, 240 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f816f7d1..07900bf4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,7 +175,7 @@ CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pix Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files, -lossy ones decode but no real file has verified them), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered +lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e3ed5815..ca219203 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 = 16; +pub const RENDER_CACHE_VERSION: u64 = 17; /// 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/jxl.rs b/crates/raw/src/jxl.rs index 9e5bfc5f..5bfbb22d 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -9,17 +9,24 @@ //! tiles decode bit-exactly, whatever bit depth the encoder declared relative to the IFD's //! `BitsPerSample`. Float samples are returned as decoded. //! -//! We never request an output colour encoding, so jxl-oxide renders in the codestream's *original* -//! colour encoding: for lossless (non-XYB) data that is the identity, keeping the values in the raw IFD's -//! sample space. Lossy (XYB) tiles are converted from XYB back to that signalled encoding by jxl-oxide; -//! [INFERENCE] DNG writers signal a linear encoding matching the raw data there, so the result is the raw -//! sample space up to the codec's loss — not verified against a real lossy JXL DNG. +//! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity, +//! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear +//! sRGB primaries; the codestream's declared primaries are then restored with a plain 3×3 matrix. We +//! never let jxl-oxide convert to the declared encoding itself: that step gamut-maps and clips to +//! [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of 32768 puts them far above) and +//! desaturates. The result is the linear sample space the DNG's `WhiteLevel` refers to, up to the +//! codec's loss, and can exceed 1.0 or dip below 0. +//! +//! Every tile is decoded with wide (32-bit) modular buffers. A lossy tile whose header sets +//! `modular_16bit_buffers` can still carry values that need more than 16 bits; the narrow buffers then +//! fail with `InvalidAnsStream` or `UnexpectedEof`. use crate::tiffraw::ChunkPx; use crate::{RawError, Result}; +use jxl_oxide::color::{ColourEncoding, ColourSpace, Primaries, TransferFunction, WhitePoint}; use jxl_oxide::frame::Encoding; use jxl_oxide::image::BitDepth; -use jxl_oxide::{AllocTracker, JxlImage, JxlThreadPool}; +use jxl_oxide::{AllocTracker, EnumColourEncoding, JxlImage, JxlThreadPool, RenderingIntent}; /// Decoder memory (bytes) allowed per sample of a tile. jxl-oxide 0.12 peaks at about three 32-bit /// buffers per sample: measured ≤ 10.3 bytes for lossless (modular) tiles and ≤ 13.5 bytes per @@ -29,6 +36,99 @@ const BYTES_PER_SAMPLE: usize = 16; /// on a 16 × 16 tile. const MARGIN_BYTES: usize = 64 << 10; +/// Linear sRGB primaries with a D65 white point (the space jxl-oxide decodes XYB to) and the matrix that +/// takes a linear colour in it to the codestream's declared primaries (row-major, D65 white point). +type Matrix = [[f64; 3]; 3]; + +const D65: [f64; 2] = [0.3127, 0.3290]; +const SRGB: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; +const BT2100: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; +const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]]; + +fn inverse(m: Matrix) -> Matrix { + let c = + |i: usize, j: usize| m[(i + 1) % 3][(j + 1) % 3] * m[(i + 2) % 3][(j + 2) % 3] - m[(i + 1) % 3][(j + 2) % 3] * m[(i + 2) % 3][(j + 1) % 3]; + let det = m[0][0] * c(0, 0) + m[0][1] * c(0, 1) + m[0][2] * c(0, 2); + let mut out = [[0.0; 3]; 3]; + for (i, row) in out.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = c(j, i) / det; + } + } + out +} + +fn mul(a: Matrix, b: Matrix) -> Matrix { + let mut out = [[0.0; 3]; 3]; + for (i, row) in out.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + out +} + +/// Linear RGB → XYZ for primaries `p` (xy) and the D65 white point. +fn rgb_to_xyz(p: [[f64; 2]; 3]) -> Matrix { + let xyz = |[x, y]: [f64; 2]| [x / y, 1.0, (1.0 - x - y) / y]; + let cols = [xyz(p[0]), xyz(p[1]), xyz(p[2])]; + let prim = [[cols[0][0], cols[1][0], cols[2][0]], [1.0, 1.0, 1.0], [cols[0][2], cols[1][2], cols[2][2]]]; + let w = xyz(D65); + let inv = inverse(prim); + let s: [f64; 3] = std::array::from_fn(|i| (0..3).map(|k| inv[i][k] * w[k]).sum()); + std::array::from_fn(|i| std::array::from_fn(|j| prim[i][j] * s[j])) +} + +/// How a chunk is rendered by jxl-oxide. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Output { + /// In the codestream's own colour encoding (non-XYB: the identity). + AsCoded, + /// XYB (lossy) data: linear sRGB primaries from the decoder, then `Some(matrix)` to the declared + /// primaries (`None` when those are sRGB's). + FromXyb(Option<[[f32; 3]; 3]>), +} + +/// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour +/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is a +/// linear RGB one with a D65 white point (what a raw DNG writer signals) and no HDR tone mapping would +/// apply, otherwise the samples cannot be restored faithfully. +fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result { + if !xyb { + return Ok(Output::AsCoded); + } + let unsupported = |what: &str| RawError::Unsupported(format!("lossy JPEG XL tile with {what}")); + if planes != 3 { + return Err(unsupported("a single colour plane")); + } + let ColourEncoding::Enum(e) = encoding else { + return Err(unsupported("an ICC profile instead of a colour encoding")); + }; + if e.colour_space != ColourSpace::Rgb || e.tf != TransferFunction::Linear { + return Err(unsupported("a declared encoding that is not linear RGB")); + } + if e.white_point != WhitePoint::D65 { + return Err(unsupported("a white point other than D65")); + } + if intensity_target > 255.0 { + return Err(unsupported("an intensity target above 255 nits")); + } + let primaries = match e.primaries { + Primaries::Srgb => return Ok(Output::FromXyb(None)), + Primaries::Bt2100 => BT2100, + Primaries::P3 => P3, + Primaries::Custom { red, green, blue } => [red, green, blue].map(|c| c.as_float().map(f64::from)), + }; + if primaries.iter().any(|p| p[1] <= 0.0) { + return Err(unsupported("invalid primaries")); + } + let m = mul(inverse(rgb_to_xyz(primaries)), rgb_to_xyz(SRGB)); + if m.iter().flatten().any(|v| !v.is_finite()) { + return Err(unsupported("degenerate primaries")); + } + Ok(Output::FromXyb(Some(m.map(|r| r.map(|v| v as f32))))) +} + fn corrupt(e: impl std::fmt::Display) -> RawError { RawError::Corrupt(format!("JPEG XL tile: {e}")) } @@ -37,7 +137,7 @@ fn corrupt(e: impl std::fmt::Display) -> RawError { /// nominal `cw × ch`, or the part of it inside the image `vw × vh` (edge tiles), `cpp` colour planes, /// a sample type matching the IFD. Returns the decoder and the codestream's integer maximum (0 for float). #[allow(clippy::too_many_arguments)] -fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<(JxlImage, f32)> { +fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<(JxlImage, f32, Output)> { if !matches!(cpp, 1 | 3) { return Err(RawError::Unsupported(format!("JPEG XL with {cpp} samples per pixel"))); } @@ -49,8 +149,9 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits let pixels = cw.saturating_mul(ch); let budget = |planes: usize| pixels.saturating_mul(planes).saturating_mul(BYTES_PER_SAMPLE); let tracker = AllocTracker::with_limit(budget(cpp).saturating_add(MARGIN_BYTES)); - let img = JxlImage::builder() + let mut img = JxlImage::builder() .pool(JxlThreadPool::none()) // chunks are already decoded in parallel + .force_wide_buffers(true) // see the module documentation .alloc_tracker(tracker.clone()) .read(std::io::Cursor::new(src)) .map_err(corrupt)?; @@ -75,7 +176,11 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits BitDepth::FloatSample { .. } if float => 0.0, d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))), }; - Ok((img, max)) + let output = output_for(header.metadata.xyb_encoded, &header.metadata.colour_encoding, header.metadata.tone_mapping.intensity_target, planes)?; + if matches!(output, Output::FromXyb(_)) { + img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative)); + } + Ok((img, max, output)) } /// Check the first chunk's headers ([`open`]) without decoding pixels (header-only probing). @@ -88,7 +193,7 @@ pub(crate) fn check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: /// in-image size `vw × vh` fills the top-left of that, the rest stays 0. #[allow(clippy::too_many_arguments)] pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result { - let (img, max) = open(src, cw, ch, vw, vh, cpp, bits, float)?; + let (img, max, output) = open(src, cw, ch, vw, vh, cpp, bits, float)?; if img.num_loaded_keyframes() == 0 { return Err(corrupt("no complete frame")); } @@ -100,22 +205,116 @@ pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp } let n = cw.saturating_mul(ch).saturating_mul(cpp); let rows = fb.buf().chunks_exact(jw * fc); + // lossy data: restore the declared primaries (see the module documentation) + let matrix = match output { + Output::FromXyb(m) => m, + Output::AsCoded => None, + }; + let restore = |s: &[f32], d: &mut [f32]| match matrix { + Some(m) => { + for (d, row) in d.iter_mut().zip(&m) { + *d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2]; + } + } + None => d.copy_from_slice(&s[..cpp]), + }; Ok(if float { let mut out = vec![0f32; n]; for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) { for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) { - d.iter_mut().zip(s).for_each(|(d, s)| *d = *s); + restore(s, d); } } ChunkPx::F32(out) } else { let mut out = vec![0u16; n]; + let mut px = [0f32; 3]; for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) { for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) { + restore(s, &mut px[..cpp]); // `as` saturates (NaN → 0): lossy overshoot clips to the integer range - d.iter_mut().zip(s).for_each(|(d, s)| *d = (s * max).round() as u16); + d.iter_mut().zip(&px).for_each(|(d, s)| *d = (s * max).round() as u16); } } ChunkPx::U16(out) }) } + +#[cfg(test)] +mod tests { + use super::*; + use jxl_oxide::color::Customxy; + + fn linear(primaries: Primaries) -> ColourEncoding { + ColourEncoding::Enum(EnumColourEncoding { + colour_space: ColourSpace::Rgb, + white_point: WhitePoint::D65, + primaries, + tf: TransferFunction::Linear, + rendering_intent: RenderingIntent::Perceptual, + }) + } + + fn matrix(o: Output) -> [[f32; 3]; 3] { + match o { + Output::FromXyb(Some(m)) => m, + other => panic!("expected a matrix, got {other:?}"), + } + } + + #[test] + fn data_that_is_not_xyb_is_rendered_as_coded() { + // lossless tiles: whatever the header declares, the decoder must not convert anything + for e in + [linear(Primaries::Srgb), linear(Primaries::Bt2100), ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative))] + { + assert_eq!(output_for(false, &e, 255.0, 3).unwrap(), Output::AsCoded); + assert_eq!(output_for(false, &e, 10_000.0, 1).unwrap(), Output::AsCoded); + } + } + + #[test] + fn lossy_data_with_srgb_primaries_needs_no_matrix() { + assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb(None)); + } + + #[test] + fn lossy_data_is_restored_to_the_declared_primaries() { + // linear sRGB to linear BT.2020/BT.2100 primaries (D65), as published in ITU-R BT.2087 + let expect = [[0.6274, 0.3293, 0.0433], [0.0691, 0.9195, 0.0114], [0.0164, 0.0880, 0.8956]]; + let m = matrix(output_for(true, &linear(Primaries::Bt2100), 255.0, 3).unwrap()); + for (r, e) in m.iter().zip(&expect) { + for (v, e) in r.iter().zip(e) { + assert!((v - e).abs() < 1e-3, "{m:?}"); + } + // the white point maps to itself: rows sum to one + assert!((r.iter().sum::() - 1.0).abs() < 1e-5, "{m:?}"); + } + // custom primaries equal to the named ones give the same matrix + let xy = |[x, y]: [f64; 2]| Customxy { x: (x * 1e6) as i32, y: (y * 1e6) as i32 }; + let custom = Primaries::Custom { red: xy(BT2100[0]), green: xy(BT2100[1]), blue: xy(BT2100[2]) }; + let c = matrix(output_for(true, &linear(custom), 255.0, 3).unwrap()); + assert!(c.iter().flatten().zip(m.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?} {m:?}"); + // the sRGB primaries lie inside the wider P3 gamut: the full-saturation sRGB green is a valid P3 colour + let inv = matrix(output_for(true, &linear(Primaries::P3), 255.0, 3).unwrap()); + let srgb_green_in_p3 = [inv[0][1], inv[1][1], inv[2][1]]; + assert!(srgb_green_in_p3.iter().all(|v| (0.0..=1.0).contains(v)), "sRGB green lies inside P3"); + } + + #[test] + fn lossy_data_that_cannot_be_restored_is_unsupported() { + let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_))); + assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey"); + assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map"); + assert!(bad(3, ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative)), 255.0), "non-linear"); + let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); + e.white_point = WhitePoint::Dci; + assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "white point"); + e.white_point = WhitePoint::D65; + e.primaries = + Primaries::Custom { red: Customxy { x: 0, y: 0 }, green: Customxy { x: 300_000, y: 600_000 }, blue: Customxy { x: 150_000, y: 60_000 } }; + assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "degenerate primaries"); + e.colour_space = ColourSpace::Grey; + assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "grey declared"); + } +} diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index c28b8b64..676bb619 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -108,6 +108,10 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack let (c, px) = match r { Ok(v) => v, Err(e) => { + // a JPEG XL tile that cannot be decoded is not a partial image: the file stays preview-only + if info.compression == comp::JPEG_XL { + return Err(e); + } first_err.get_or_insert(e); continue; } diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index bba5ac81..44527cf9 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -669,6 +669,26 @@ fn malformed_jpeg_xl_tiles_are_errors() { assert!(matches!(lightcraft_raw::probe_info(&one(good, 3)), Err(RawError::Corrupt(_)))); } +/// One undecodable tile makes the whole image an error: it is never returned with a black tile. +#[test] +fn a_jpeg_xl_tile_that_fails_is_an_error_for_the_image() { + use lightcraft_raw::RawError; + let (w, h) = (32, 16); + let px = pattern(w, h, 1, 12); + let tile = |x0: usize| -> Vec { (0..16).flat_map(|y| px[y * w + x0..y * w + x0 + 16].to_vec()).collect() }; + let two = |second: Vec| { + let mut raw = base_ifd(w, h, 12, 1, true); + raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL])); + raw.set_image(ImageData::Tiles { tile_width: 16, tile_height: 16, tiles: vec![jxl_encode(&tile(0), 16, 16, 1, false), second] }); + dng(raw, ByteOrder::Little) + }; + assert_eq!(decode(&two(jxl_encode(&tile(16), 16, 16, 1, false))).unwrap().data, RawData::U16(px.clone())); + // the second tile is cut short; the first one alone is not an image + let good = jxl_encode(&tile(16), 16, 16, 1, false); + assert!(matches!(decode(&two(good[..good.len() / 2].to_vec())), Err(RawError::Corrupt(_)))); + assert!(matches!(decode(&two(vec![0xff, 0x0a, 1, 2, 3])), Err(RawError::Corrupt(_)))); +} + /// The decoder's memory is bounded by the tile: a grey tile that also codes an alpha channel needs /// about twice what one plane may use, and is an error rather than an allocation the header asks /// for. The same samples without the alpha channel decode. diff --git a/docs/parity.md b/docs/parity.md index ed95faab..d3e298f5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -142,7 +142,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`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 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", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from 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", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | | LR-IMP-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 a2229750..138b2b79 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -18,7 +18,8 @@ - Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered. - JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files); - lossy tiles decode too, but no real file has checked them yet. A JPEG XL preview stored in the DNG is used like an + lossy tiles decode too, keeping the raw values above 1.0 instead of clipping them (checked on one real file). A tile that cannot be decoded makes the file open from its embedded + preview, with the reason, instead of showing a black tile. A JPEG XL preview stored in the DNG is used like an embedded JPEG. - Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is exported or converted to DNG. It is not applied to the render: Lightroom Classic renders ProRAW without it. From 79b19fd8869e82adac977e035f2a9ccb4a448723 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 00:19:04 -0400 Subject: [PATCH 003/344] LR-IMP-FORMATS: decode Hasselblad 3FR files with uncompressed raw data TIFF raws that describe a full-resolution CFA IFD with DNG's tags (levels, crop, AsShotNeutral) but carry no DNG version tag are read by the DNG reader (RawFormat::CfaTiff, dng::decode_as). RGGB when the file states no CFAPattern. Lossless JPEG scans with a predictor selection value above 7 are rejected with that reason and stay preview-only. .3fr and .fff are imported from folders. RENDER_CACHE_VERSION 17. --- crates/engine/src/import.rs | 2 +- crates/engine/src/media.rs | 2 +- crates/mcp/src/headless.rs | 2 +- crates/raw/src/dng.rs | 43 ++++++++++- crates/raw/src/lib.rs | 137 ++++++++++++++++++++++++++++++++++++ crates/raw/src/ljpeg.rs | 7 +- docs/parity.md | 2 +- 7 files changed, 187 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 282d93b7..17fb7236 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -34,7 +34,7 @@ use crate::media::ProbeInfo; pub const EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", ]; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e3ed5815..ca219203 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 = 16; +pub const RENDER_CACHE_VERSION: u64 = 17; /// 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/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 7347d33b..6bdf06a1 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -14,7 +14,7 @@ use crate::backend::Backend; pub const PHOTO_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", ]; /// Headless backend: a [`Session`] with filesystem hooks. diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 63ddf91a..fedc033b 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -89,6 +89,39 @@ fn gain_table_map(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> Option Result { + decode_as(bytes, mode, RawFormat::Dng) +} + +/// Whether a TIFF that is not a DNG describes a raw image the way DNG does: a full-resolution CFA IFD +/// and DNG's own statement of the white balance (`AsShotNeutral`, in IFD0 or the raw IFD), in a coding +/// [`decode_as`] reads: uncompressed, or lossless JPEG whose scan header the lossless decoder accepts. +/// Such files (Hasselblad 3FR and FFF) carry no DNG version tag, so they are not DNGs, but nothing else +/// in them needs a maker-specific reader. Without the white-balance tag nothing in the file says how +/// to read its colour, and the file stays an undecodable raw. +pub(crate) fn is_plain_cfa_tiff(tiff: &Tiff, bytes: &[u8]) -> bool { + let Some(raw) = raw_ifd(tiff) else { return false }; + if !raw.contains(t::AS_SHOT_NEUTRAL) && !tiff.ifds.first().is_some_and(|i| i.contains(t::AS_SHOT_NEUTRAL)) { + return false; + } + let Ok(info) = raw.image() else { return false }; + if info.samples_per_pixel != 1 || !(1..=16).contains(&info.bits()) || info.sample_format == 3 { + return false; + } + match info.compression { + t::compression::NONE => true, + t::compression::JPEG => info + .chunks(bytes.len() as u64) + .first() + .and_then(|c| lightcraft_tiff::image::chunk_bytes(bytes, c)) + .is_some_and(|src| crate::ljpeg::frame_info(src).is_ok()), + _ => false, + } +} + +/// [`decode`] for the TIFF-based raws that follow the DNG layout without being DNGs (`format` says +/// which). They differ in one respect: when the raw IFD states no `CFAPattern`, the layout is RGGB +/// anchored at the sensor origin. +pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?; @@ -152,7 +185,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { [r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize), _ => return Err(RawError::Corrupt("bad CFARepeatPatternDim".into())), }; - let pat = raw.bytes(t::CFA_PATTERN_EP).ok_or_else(|| RawError::Corrupt("missing CFAPattern".into()))?; + // a TIFF that is not a DNG may leave the layout out: RGGB from the sensor origin + let assumed = [0u8, 1, 1, 2]; + let pat = match raw.bytes(t::CFA_PATTERN_EP) { + Some(p) => p, + None if format != RawFormat::Dng && (rows, cols) == (2, 2) => &assumed, + None => return Err(RawError::Corrupt("missing CFAPattern".into())), + }; if pat.len() != rows * cols { return Err(RawError::Corrupt("CFAPattern size mismatch".into())); } @@ -178,7 +217,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { metadata.width = Some(crop.width as u32); metadata.height = Some(crop.height as u32); let img = RawImage { - format: RawFormat::Dng, + format, width: w, height: h, cpp, diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 6732ad92..5a513d49 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -96,6 +96,10 @@ pub enum RawFormat { Mrw, /// Sigma / Foveon X3F (`FOVb`). X3f, + /// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a + /// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`): + /// Hasselblad 3FR and FFF. Read by the DNG reader. + CfaTiff, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } @@ -114,6 +118,7 @@ impl RawFormat { | RawFormat::Raf | RawFormat::Rw2 | RawFormat::Pef + | RawFormat::CfaTiff ) } } @@ -168,6 +173,9 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("SAMSUNG") { return Some(RawFormat::Srw); } + if dng::is_plain_cfa_tiff(&t, bytes) { + return Some(RawFormat::CfaTiff); + } if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) { return Some(RawFormat::OtherTiff); } @@ -256,6 +264,15 @@ fn thumbnail_shell(t: &Tiff, len: usize) -> Option { /// Why [`decode`] gives up on a file [`probe`] called [`RawFormat::OtherTiff`]. fn other_tiff_reason(bytes: &[u8]) -> String { let t = Tiff::parse_with(bytes, &lightcraft_tiff::ParseOptions { max_ifds: 256, ..Default::default() }).ok(); + // a DNG-style CFA IFD whose lossless JPEG the lossless decoder rejects: say why + if let Some(t) = &t + && let Some(info) = dng::raw_ifd(t).and_then(|i| i.image().ok()) + && info.compression == lightcraft_tiff::tags::compression::JPEG + && let Some(src) = info.chunks(bytes.len() as u64).first().and_then(|c| lightcraft_tiff::image::chunk_bytes(bytes, c)) + && let Err(e) = ljpeg::frame_info(src) + { + return format!("raw image coded as lossless JPEG that is not decoded yet ({e})"); + } match t.as_ref().filter(|t| !has_raw_ifd(t)).and_then(|t| thumbnail_shell(t, bytes.len())) { Some(s) => format!( "{}x{} raw image in a private block, not decoded yet (the file's first image is a {}x{} reduced copy)", @@ -305,6 +322,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), + RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } @@ -869,6 +887,125 @@ mod tests { assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); } + // --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- + + /// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. + fn cfa_samples() -> Vec { + (0..48u16).map(|i| 1000 + 100 * (i / 8) + i % 8).collect() + } + + /// A small raw in the layout of the Hasselblad 3FR/FFF family: IFD0 is a reduced RGB image with the + /// colour tags, a SubIFD holds the full-resolution CFA image (no `CFAPattern`, DNG-style levels and + /// crop). `strip` is the CFA image's data and `compression` its coding. + fn plain_cfa_tiff(compression: u16, strip: Vec, pattern: Option<[u8; 4]>) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![60000])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Short(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Short(vec![4, 2])); + if let Some(p) = pattern { + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); + } + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![strip] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(5, 10), (-1, 10), (0, 10), (-5, 10), (12, 10), (3, 10), (-1, 10), (2, 10), (6, 10)])); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (3, 5)])); + ifd0.add_sub_ifd(raw); + write(&[ifd0]) + } + + fn le_words(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() + } + + /// Known answer: samples, black and white level, crop and colour come from the file's own tags; the + /// layout it does not state is RGGB. + #[test] + fn uncompressed_cfa_tiff_without_a_dng_version_decodes() { + let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::CfaTiff, 8, 6, 16)); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!((r.black.mean(), r.white.clone()), (256.0, vec![60000.0])); + assert_eq!(r.crop, Rect::new(2, 2, 4, 2)); + assert!(color::has_matrix(&r.color)); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.6])); + // headers only: the same description without samples + let i = probe_info(&bytes).unwrap(); + assert_eq!((i.format, i.cfa.as_ref().map(Cfa::name), i.crop), (RawFormat::CfaTiff, Some("RGGB".to_string()), Rect::new(2, 2, 4, 2))); + } + + /// Without DNG's white-balance tag the file is left alone (a Kodak or Sinar TIFF with a CFA IFD, say). + #[test] + fn cfa_tiff_without_a_white_balance_tag_is_not_claimed() { + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![le_words(&cfa_samples())] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.add_sub_ifd(raw); + assert_eq!(probe(&write(&[ifd0])), Some(RawFormat::OtherTiff)); + } + + /// A stated `CFAPattern` wins over the RGGB default. + #[test] + fn cfa_tiff_keeps_a_stated_pattern() { + let bytes = plain_cfa_tiff(1, le_words(&cfa_samples()), Some([2, 1, 1, 0])); + assert_eq!(decode(&bytes).unwrap().cfa.unwrap().name(), "BGGR"); + } + + /// The lossless-JPEG coding of the same layout goes through the lossless decoder (one strip for the + /// whole image, as the 3FR/FFF files have it). + #[test] + fn lossless_jpeg_cfa_tiff_decodes() { + let strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16(cfa_samples())); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); + } + + /// A lossless-JPEG scan whose predictor selection value is outside 0 to 7 (the 3FR/FFF files use 8) is + /// not a coding the lossless decoder knows: the file stays an undecodable raw, with that as the reason, + /// and keeps the stand-in preview an unsupported container gets. + #[test] + fn cfa_tiff_with_an_unknown_predictor_stays_undecodable() { + let mut strip = ljpeg::encode(&cfa_samples(), 8, 6, 1, 16, 1, 0); + let sos = strip.windows(2).position(|w| w == [0xff, 0xda]).unwrap(); + let ss = sos + 2 + 2 + 1 + 2; + assert_eq!(strip[ss], 1, "selection value byte"); + strip[ss] = 8; + assert!(matches!(ljpeg::decode(&strip, 1 << 20), Err(RawError::Unsupported(w)) if w.contains("selection value 8"))); + let bytes = plain_cfa_tiff(7, strip, None); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + assert!(!RawFormat::OtherTiff.is_supported()); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("lossless JPEG") && why.contains("selection value 8"), "{why}"); + } + + /// Other codings of a CFA IFD (here Deflate) are not claimed by the plain reader. + #[test] + fn cfa_tiff_with_another_coding_is_not_claimed() { + let bytes = plain_cfa_tiff(8, vec![0; 96], None); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + } + #[test] fn probe_rejects_junk() { assert_eq!(probe(b""), None); diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index c8087a8e..eca3a59a 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -325,8 +325,11 @@ fn parse_header(d: &[u8]) -> Result { } h.predictor = seg[1 + 2 * ns]; h.pt = seg[3 + 2 * ns] & 15; - if !(0..=7).contains(&h.predictor) { - return Err(err("bad predictor")); + if h.predictor > 7 { + return Err(RawError::Unsupported(format!( + "lossless JPEG scan with predictor selection value {} (T.81 defines 0 to 7)", + h.predictor + ))); } if h.pt >= h.precision { return Err(err("bad point transform")); diff --git a/docs/parity.md b/docs/parity.md index ed95faab..87a90e35 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -142,7 +142,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`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 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", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`), Hasselblad 3FR with uncompressed raw data (X1D, X1D II, X2D, CFV 100C: a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`; the lossless-JPEG 3FR and FFF files use scan selection value 8 and stay preview-only). 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", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | | LR-IMP-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 | From 5c1dcd4f970de8e307e2bdd9f546c09fb1a38b97 Mon Sep 17 00:00:00 2001 From: onemannoplan <96282233+onemannoplan@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:53:21 -0500 Subject: [PATCH 004/344] fix(macos): prevent SIGILL crash on exit by replacing viewport close with clean process exit ### Summary When selecting "Quit LightCraft" from the native macOS menu bar, the application occasionally crashes with an `EXC_BAD_INSTRUCTION` (SIGILL) error report during shutdown. ### Root Cause 1. The native menu action triggered `ctx.send_viewport_cmd(egui::ViewportCommand::Close)`. 2. Forcing an explicit viewport close via the command queue while macOS and AppKit are initiating their own termination sequence creates a race condition with `winit`. 3. This conflict during native window, view, and observer teardown leads to an illegal instruction fault. ### Solution - Replaced the manual `ViewportCommand::Close` trigger on `QUIT` with a clean `std::process::exit(0)`. - This bypasses the problematic `winit`/AppKit teardown race condition on macOS, allowing the application to shut down instantly and cleanly without generating crash reports. --- apps/lightcraft/src/native_menu.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 647f7e27..6c6d2f2b 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -360,8 +360,7 @@ impl NativeMenu { lightcraft_ui_egui::i18n::set_language(app.ui.language); while let Ok(key) = self.rx.try_recv() { if key == QUIT { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - continue; + std::process::exit(0); } if key == "app.about" || key == SETTINGS { let _ = app.run(&key, serde_json::json!({})); From 989422398c3044b39b640111dc10cafebf5ba92e Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 01:12:55 -0400 Subject: [PATCH 005/344] LR-IMP-FORMATS: read plain packed 12-bit PEF strips tagged as PackBits (older Pentax, Samsung GX-1L) Compression 32773 is accepted in the PEF reader only when every strip has exactly the packed size of its rows at the declared bit depth, and is then read as uncompressed MSB-first samples. Any other 32773 size stays unsupported. Uncompressed PEFs take their colour filter from the Exif CFAPattern as seen from the image area origin. A Samsung-branded TIFF whose maker note has the Pentax layout is probed as PEF. RENDER_CACHE_VERSION 16 to 17. --- crates/engine/src/media.rs | 2 +- crates/raw/src/lib.rs | 27 ++++++++++++ crates/raw/src/vendor/mod.rs | 21 ++++++++- crates/raw/src/vendor/orf.rs | 20 +-------- crates/raw/src/vendor/pef.rs | 84 +++++++++++++++++++++++++++++++++++- 5 files changed, 132 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index a4d2cdb7..5b892250 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 = 16; +pub const RENDER_CACHE_VERSION: u64 = 17; /// 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/lib.rs b/crates/raw/src/lib.rs index d38b2fc6..3ea36e50 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -165,6 +165,10 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("PENTAX") || make.starts_with("RICOH") { return Some(RawFormat::Pef); } + // a Samsung-branded body built on a Pentax design writes a Pentax-style maker note (the note's own magic) + if make.starts_with("SAMSUNG") && has_pentax_maker_note(&t, bytes) { + return Some(RawFormat::Pef); + } if make.starts_with("SAMSUNG") { return Some(RawFormat::Srw); } @@ -174,6 +178,12 @@ pub fn probe(bytes: &[u8]) -> Option { None } +/// Whether the Exif maker note starts with the signature of the Pentax layouts (`AOC\0` or `PENTAX \0`). +fn has_pentax_maker_note(t: &Tiff, bytes: &[u8]) -> bool { + let Some(e) = t.exif().and_then(|e| e.get(lightcraft_tiff::tags::MAKER_NOTE)) else { return false }; + bytes.get(e.offset as usize..).is_some_and(|n| n.starts_with(b"AOC\0") || n.starts_with(b"PENTAX \0")) +} + /// Whether some IFD is marked as raw: CFA photometric, or a raw-only compression value (99 is not a /// registered TIFF compression; Leaf MOS files use it for their tiled 16-bit lossless-JPEG raw). fn has_raw_ifd(t: &Tiff) -> bool { @@ -919,6 +929,23 @@ mod tests { assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); } + /// A Samsung-branded body whose maker note has a Pentax layout is read by the Pentax reader; another + /// Samsung file stays with the Samsung format. + #[test] + fn samsung_make_with_a_pentax_maker_note_is_pef() { + let with_note = |note: &[u8]| { + let mut ifd = rgb_ifd(16, 12); + ifd.set(t::MAKE, Value::Ascii("SAMSUNG TECHWIN".into())); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + write(&[ifd]) + }; + assert_eq!(probe(&with_note(b"AOC\0MM\0\x01\0\0\0\0\0\0")), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"PENTAX \0MM\0\0\0\0\0\0\0")), Some(RawFormat::Pef)); + assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0")), Some(RawFormat::Srw)); + } + #[test] fn probe_rejects_junk() { assert_eq!(probe(b""), None); diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index fbbc18eb..02165582 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -13,9 +13,28 @@ pub mod raf; mod rafc; pub mod rw2; -use crate::{BlackLevel, Rect}; +use crate::{BlackLevel, Cfa, Rect}; +use lightcraft_tiff::Tiff; use std::ops::Range; +/// Exif `CFAPattern`. +pub(crate) const EXIF_CFA_PATTERN: u16 = 0xa302; + +/// 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. +pub(crate) 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)) +} + /// Black level per 2×2 CFA position (anchored at the active area origin) from masked sensor columns `cols` over /// rows `rows`. Falls back to 0 when the region is empty. pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range, rows: Range, active: Rect) -> BlackLevel { diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 59c5f14c..893ee64a 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -18,7 +18,7 @@ //! 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 super::{cfa_from_exif, white_from_data}; use crate::tiffraw::{Packing, read_image}; use crate::unpack::unpack_msb; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; @@ -35,8 +35,6 @@ 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 { @@ -70,21 +68,6 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { unpack_msb(&swapped, bits, out); } -/// 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 @@ -223,6 +206,7 @@ pub(crate) fn preview(bytes: &[u8]) -> Option<&[u8]> { #[cfg(test)] mod tests { use super::*; + use crate::vendor::EXIF_CFA_PATTERN; use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter}; fn orf(w: u32, h: u32, bits: u16, strip: Vec) -> Vec { diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index c9421d3b..5b80967e 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -15,12 +15,12 @@ //! (starting at 0). Found by trying candidate predictors and checking the decoded rows are continuous. //! - Maker note `0x0038`/`0x0039`: the image area's left/top and width/height (verified against the data). -use super::{black_from_columns, white_from_data}; +use super::{black_from_columns, cfa_from_exif, white_from_data}; use crate::tiffraw::{Packing, read_image_in}; 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::image::{ImageInfo, chunk_bytes}; use lightcraft_tiff::{ByteOrder, Tiff, makernote, tags as t}; const CROP_ORIGIN: u16 = 0x0038; @@ -188,6 +188,19 @@ fn dark_trimmed(d: &[u16], w: usize, h: usize) -> Rect { Rect::new(left, 0, (right - left) & !1, h) } +/// Whether every strip or tile of a one-sample-per-pixel image holds exactly the bytes that its rows need when +/// the samples are packed MSB-first at the declared bit depth, rows starting on byte boundaries (TIFF 6.0 +/// section 7). A real PackBits stream of such an image has a different length (almost always shorter), so the +/// exact match is what tells plain samples under a compression tag that says otherwise. +fn is_exactly_packed(info: &ImageInfo, file_len: u64) -> bool { + let bits = u64::from(info.bits()); + if info.samples_per_pixel != 1 || info.planar != 1 || !(1..=16).contains(&bits) || info.byte_counts.len() != info.offsets.len() { + return false; + } + let chunks = info.chunks(file_len); + !chunks.is_empty() && chunks.iter().all(|c| c.len == (u64::from(c.width) * bits).div_ceil(8) * u64::from(c.height)) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; @@ -226,6 +239,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let packing = if strip >= (w * hgt * 2) as u64 { Packing::Word16 } else { Packing::Msb }; read_image_in(read, bytes, &info, tiff.order, packing)? } + // Some bodies tag plain packed samples as PackBits (32773). Only a strip that holds exactly the packed + // size of its rows is read, as the uncompressed MSB-first samples it is; any other size stays unsupported. + 32773 if is_exactly_packed(&info, bytes.len() as u64) => { + let packed = ImageInfo { compression: 1, ..info.clone() }; + read_image_in(read, bytes, &packed, tiff.order, Packing::Msb)? + } c => return Err(RawError::Unsupported(format!("PEF compression {c}"))), }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; @@ -236,6 +255,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + // The uncompressed bodies state their pattern in the Exif `CFAPattern`, as seen from the first pixel of the + // image area (checked against each file's camera JPEG); the Huffman-compressed ones don't agree with their + // pictures (a 10 MP body says RGGB and renders correctly as BGGR), so they keep the assumed layout. + _ if matches!(info.compression, 1 | 32773) => { + cfa_from_exif(&tiff).map(|c| c.shifted(active.x, active.y)).unwrap_or_else(|| Cfa::bayer_static("BGGR")) + } _ => Cfa::bayer_static("BGGR"), }; let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).as_deref() { @@ -343,4 +368,59 @@ mod tests { assert!(decode_huffman(&src[..src.len() / 3], &h, w, hgt, 14).is_err()); assert!(table(&tb[..20], ByteOrder::Big).is_err()); } + + /// A big-endian one-strip PEF-style TIFF of `w × h` 12-bit samples with the given compression tag and strip + /// byte count, around `payload`. + fn tiny_pef(w: u16, h: u16, compression: u16, strip_len: u32, payload: &[u8]) -> Vec { + let entries: [(u16, u16, u32); 9] = [ + (0x0100, 3, w as u32 * 0x1_0000), // ImageWidth + (0x0101, 3, h as u32 * 0x1_0000), // ImageLength + (0x0102, 3, 12 * 0x1_0000), // BitsPerSample + (0x0103, 3, compression as u32 * 0x1_0000), // Compression + (0x0106, 3, 32803 * 0x1_0000), // Photometric: CFA + (0x0111, 4, 8 + 2 + 9 * 12 + 4), // StripOffsets + (0x0115, 3, 0x1_0000), // SamplesPerPixel + (0x0116, 3, h as u32 * 0x1_0000), // RowsPerStrip + (0x0117, 4, strip_len), // StripByteCounts + ]; + let mut f = b"MM\0*\0\0\0\x08".to_vec(); + f.extend_from_slice(&9u16.to_be_bytes()); + for (tag, ty, val) in entries { + f.extend_from_slice(&tag.to_be_bytes()); + f.extend_from_slice(&ty.to_be_bytes()); + f.extend_from_slice(&1u32.to_be_bytes()); + f.extend_from_slice(&val.to_be_bytes()); + } + f.extend_from_slice(&0u32.to_be_bytes()); // no next IFD + f.extend_from_slice(payload); + f + } + + /// Eight 12-bit samples, two rows of four, packed MSB-first: 0x123 0x456 0x789 0xabc / 0xdef 0x012 0x345 0x678. + const PACKED: [u8; 12] = [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78]; + + #[test] + fn packed_samples_under_the_packbits_tag_are_read_when_the_strip_has_exactly_the_packed_size() { + let file = tiny_pef(4, 2, 32773, 12, &PACKED); + let img = decode(&file, Mode::Full).unwrap(); + let RawData::U16(v) = &img.data else { panic!("integer samples") }; + assert_eq!(v, &[0x123, 0x456, 0x789, 0xabc, 0xdef, 0x012, 0x345, 0x678]); + assert_eq!((img.width, img.height, img.bits), (4, 2, 12)); + // the same bytes tagged as plain uncompressed give the same samples + let plain = decode(&tiny_pef(4, 2, 1, 12, &PACKED), Mode::Full).unwrap(); + assert_eq!(plain.data, img.data); + // header-only mode accepts it too + assert!(decode(&file, Mode::Header).is_ok()); + } + + #[test] + fn a_packbits_tagged_strip_of_any_other_size_stays_unsupported() { + for len in [11u32, 13, 6, 24] { + let mut payload = PACKED.to_vec(); + payload.resize(24, 0x55); + let file = tiny_pef(4, 2, 32773, len, &payload); + let err = decode(&file, Mode::Full).unwrap_err(); + assert!(matches!(&err, RawError::Unsupported(m) if m.contains("32773")), "strip of {len} bytes: {err:?}"); + } + } } From 40652fc5f91ffa4ba7d8b099286762c9f6741e05 Mon Sep 17 00:00:00 2001 From: Hanmo Date: Fri, 9 Oct 2026 13:35:02 +0800 Subject: [PATCH 006/344] M6: move radial masks by dragging inside their ellipses --- crates/ui-egui/src/panels/detail.rs | 24 +++++++++- crates/ui-egui/src/tests_masking.rs | 72 +++++++++++++++++++++++++++++ docs/parity.md | 6 ++- docs/whats-new.md | 5 ++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ea3d782b..2e708da6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1434,6 +1434,22 @@ fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &Canvas } } +/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units, +/// so undo the same scaling and rotation used to draw its outline before testing containment. +fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { + let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false }; + if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 { + return false; + } + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let x = (dx * co + dy * s) / rx; + let y = (-dx * s + dy * co) / ry; + x * x + y * y <= 1.0 +} + /// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). @@ -1608,8 +1624,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { - // anywhere on the photo drags the selected linear gradient let m = d.masks.iter().find(|m| Some(m.id) == active)?; + // Handles take precedence; otherwise grab the selected radial component under + // the press, including components after the first in compound masks. + let at = map.norm(q); + if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) { + return Some((m.id, ci, 0)); + } + // anywhere on the photo drags the selected linear gradient matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) }); if let Some((mask, comp, handle)) = grip diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 0729a854..6a5f59e1 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -39,6 +39,78 @@ fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { (*h.app.session.develop_of(id).unwrap_or_default()).clone() } +#[test] +fn radial_body_drag_moves_rotated_components_and_undoes_once() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "luminanceRange"})); + exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "add", "center": [0.45, 0.5], "rx": 0.22, "ry": 0.1, "angle": 40})); + let before = develop(&h); + // Well away from the pin and edge/rotation grips, inside a rotated ellipse. + pointer( + &mut h, + json!([{"kind": "down", "x": 0.51, "y": 0.56}, {"kind": "drag", "x": 0.54, "y": 0.58}, + {"kind": "drag", "x": 0.61, "y": 0.66}, {"kind": "up", "x": 0.61, "y": 0.66}]), + ); + let after = develop(&h); + let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &after.masks[0].components[1].shape else { panic!("radial") }; + assert!((center.x - 0.55).abs() < 0.005 && (center.y - 0.6).abs() < 0.005, "{center:?}"); + let MaskShape::Radial { rx: old_rx, ry: old_ry, angle: old_angle, feather: old_feather, invert: old_invert, .. } = + &before.masks[0].components[1].shape + else { + panic!("radial") + }; + assert_eq!((rx, ry, angle, feather, invert), (old_rx, old_ry, old_angle, old_feather, old_invert)); + assert_eq!(after.masks[0].components[0], before.masks[0].components[0], "only the hit component moves"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before, "one undo reverses the entire drag"); + exec(&mut h, "edit.redo", json!({})); + assert_eq!(develop(&h), after); + // The bounding-box corner is outside the ellipse, even for an inverted radial mask. + exec(&mut h, "mask.component", json!({"component": 1, "action": "invert"})); + let outside_before = develop(&h); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.35, "y": 0.75}, {"kind": "drag", "x": 0.4, "y": 0.8}, + {"kind": "up", "x": 0.4, "y": 0.8}]), + ); + assert_eq!(develop(&h), outside_before, "outside the ellipse must not move it"); + // Hiding pins does not disable dragging the selected ellipse body. + exec(&mut h, "view.maskPins", json!({"show": false})); + pointer( + &mut h, + json!([{"kind": "down", "x": 0.61, "y": 0.66}, {"kind": "drag", "x": 0.66, "y": 0.71}, + {"kind": "up", "x": 0.66, "y": 0.71}]), + ); + let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[1].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.6).abs() < 0.005 && (center.y - 0.65).abs() < 0.005, "{center:?}"); +} + +#[test] +fn radial_body_drag_keeps_resize_and_rotation_handles() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.18, "ry": 0.1})); + for handle in [1, 5] { + let widget = format!("maskHandle:1:0:{handle}"); + let rect = h.app.widgets.iter().find(|(id, _)| *id == widget).unwrap().1; + let image = h.app.image_rect.unwrap(); + let q = rect.center(); + let x = (q.x - image.left()) / image.width(); + let y = (q.y - image.top()) / image.height(); + pointer( + &mut h, + json!([{"kind": "down", "x": x, "y": y}, {"kind": "drag", "x": x + 0.03, "y": y + 0.03}, + {"kind": "up", "x": x + 0.03, "y": y + 0.03}]), + ); + let MaskShape::Radial { center, rx, angle, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.5).abs() < 0.005 && (center.y - 0.5).abs() < 0.005, "a handle must not translate the mask"); + if handle == 1 { + assert!(rx > 0.19, "resize grip changes radius"); + } else { + assert!(angle.abs() > 1.0, "rotation grip changes angle"); + } + } +} + #[test] fn mask_overlay_keys_and_pins() { use lightcraft_pipeline::{MaskView, Overlay}; diff --git a/docs/parity.md b/docs/parity.md index 066bd45a..df9d35f0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -59,6 +59,10 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). Take the first one nobody is working on. +Radial-gradient interaction: the selected ellipse can be moved by dragging anywhere inside its +outline (including a radial component of a compound mask); resize/rotation grips take precedence. +Regression coverage includes rotated ellipses, outside clicks, hidden pins and one-step undo. + 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF and Panasonic RW2 now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW and NEF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0); Nikon CropArea and untagged-preview ColorSpace metadata are respected; measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, Nikon 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate` for ARW, NEF and RAF; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12; RW2: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0; RAF: 23 of 24 references accepted with bundled X-H2S/X-T4 profiles; 150 withheld-file tests improve mean ΔE slightly over per-file fitting); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. @@ -354,7 +358,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | -| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | +| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | | LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | diff --git a/docs/whats-new.md b/docs/whats-new.md index 133770dc..54cb8a97 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## October 2026 +### Masking +- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated + ellipses and radial components in compound masks. Edge and rotation handles keep their existing + functions, and one Undo restores the entire drag. + ### Albums tree - Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords. - Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… From 5c16320f6bdff936c4a65111b035fd7f9ace15a8 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Thu, 8 Oct 2026 23:02:13 -0700 Subject: [PATCH 007/344] MCP: add shared core tools, hints and resources Keep documented helpers listed, reject unknown helper keys, and recover escaped backend panics. Cover core tools, resource snapshots and modern result metadata with regression tests. Co-Authored-By: OpenAI Codex --- crates/mcp/src/server.rs | 66 ++++++++++++++-- crates/mcp/src/tests.rs | 115 ++++++++++++++++++++++++++- crates/mcp/src/tools.rs | 167 +++++++++++++++++++++++++++++++++++++-- docs/mcp.md | 33 ++++++++ 4 files changed, 366 insertions(+), 15 deletions(-) diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index c3371be5..3f1f12f0 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -5,7 +5,7 @@ use std::io::{BufRead, Write}; use serde_json::{Value, json}; use crate::backend::Backend; -use crate::tools::{call_tool, tool_definitions}; +use crate::tools::{ToolResult, call_tool, check_args, doc_inspect, tool_definitions}; /// The MCP revision we implement. pub const PROTOCOL_VERSION: &str = "2025-06-18"; @@ -22,11 +22,13 @@ const RESOURCE_NOT_FOUND: i64 = -32002; const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \ Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \ Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \ -render_photo to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \ -coordinates 0..1 with the origin at the top-left. Every action is a command: list_commands shows ids and parameter \ -docs; run_command runs any of them. Edits are undoable (undo / redo)."; +render_preview to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \ +coordinates 0..1 with the origin at the top-left. Every action is a command: command_list shows ids and parameter \ +docs; command_run {id, params} runs any of them; command_batch runs steps in order. Inspect with doc_inspect. Edits are undoable (undo / redo)."; /// Resource URIs. +pub const DOCUMENT_URI: &str = "lightcraft://document"; +pub const COMMANDS_URI: &str = "lightcraft://commands"; pub const LIBRARY_URI: &str = "lightcraft://library"; pub const PHOTOS_URI: &str = "lightcraft://photos"; pub const PHOTO_URI: &str = "lightcraft://photo/active"; @@ -132,7 +134,10 @@ impl Server { return Some(error(id, INVALID_PARAMS, "`params` must be an object")); } Some(match self.request(method, ¶ms) { - Ok(r) => response(id, r), + Ok(mut r) => { + modern_result_fields(method, ¶ms, &mut r); + response(id, r) + } Err((code, m)) => error(id, code, m), }) } @@ -158,13 +163,20 @@ impl Server { })) } "ping" => Ok(json!({})), - "tools/list" => Ok(json!({"tools": tool_definitions(self.backend.as_mut(), self.command_tools)})), + "tools/list" => { + let command_tools = self.command_tools; + let tools = self.guarded(|b| tool_definitions(b, command_tools)).map_err(|e| (INTERNAL_ERROR, e))?; + Ok(json!({"tools": tools})) + } "tools/call" => { let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; let args = params.get("arguments").cloned().unwrap_or(Value::Null); - Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + check_args(name, &args).map_err(|e| (INVALID_PARAMS, e))?; + Ok(self.guarded(|b| call_tool(b, name, &args)).unwrap_or_else(ToolResult::error).to_value()) } "resources/list" => Ok(json!({"resources": [ + resource(DOCUMENT_URI, "document", "Library document", "Library state and counts (same as doc_inspect)"), + resource(COMMANDS_URI, "commands", "Command catalog", "Every command (same as command_list)"), resource(LIBRARY_URI, "library", "Library state", "Current source, filter, sort, selection, active photo/mask and undo/redo labels (library.state)"), resource(PHOTOS_URI, "photos", "Photos in view", "The photos in the current view with ids, ratings, flags and metadata (catalog.query)"), resource(PHOTO_URI, "active-photo", "Active photo", "Everything about the active photo: file, metadata, develop settings, albums, history (photo.inspect)"), @@ -175,6 +187,7 @@ impl Server { "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; let cmd = match uri { + DOCUMENT_URI | COMMANDS_URI => "", LIBRARY_URI => "library.state", PHOTOS_URI => "catalog.query", PHOTO_URI => "photo.inspect", @@ -182,10 +195,47 @@ impl Server { CONTROLS_URI => "develop.controls", _ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))), }; - let v = self.backend.call("engine.execute", json!({"command": cmd, "params": {}})).map_err(|e| (INTERNAL_ERROR, e))?; + let v = self + .guarded(|b| match uri { + DOCUMENT_URI => doc_inspect(b), + COMMANDS_URI => b.call("engine.commands", json!({})), + _ => b.call("engine.execute", json!({"command": cmd, "params": {}})), + }) + .and_then(|v| v) + .map_err(|e| (INTERNAL_ERROR, e))?; Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) } other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))), } } } + +impl Server { + /// Escaped backend panics become errors; subsequent requests can keep using the session. + fn guarded(&mut self, f: impl FnOnce(&mut dyn Backend) -> T) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self.backend.as_mut()))).map_err(|p| { + let message = + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + format!("internal error: {message}") + }) + } +} + +/// Per-request protocol metadata leaves initialize-based clients' result shapes unchanged. +fn modern_result_fields(method: &str, params: &Value, result: &mut Value) { + let modern = params.pointer("/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str).is_some_and(|v| v >= "2026-07-28"); + let ttl = match method { + "tools/list" | "resources/list" | "resources/templates/list" => Some(600_000), + "resources/read" => Some(0), + "tools/call" => None, + _ => return, + }; + let Some(obj) = result.as_object_mut().filter(|_| modern) else { + return; + }; + obj.insert("resultType".into(), json!("complete")); + if let Some(ttl) = ttl { + obj.insert("ttlMs".into(), json!(ttl)); + obj.insert("cacheScope".into(), json!("private")); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index ee17e1ad..b58f195b 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -215,7 +215,7 @@ fn resources() { let mut s = server(); let r = rpc(&mut s, 1, "resources/list", json!({})); let list = r["result"]["resources"].as_array().unwrap(); - assert_eq!(list.len(), 5); + assert_eq!(list.len(), 7); for (i, res) in list.iter().enumerate() { let uri = res["uri"].as_str().unwrap(); let r = rpc(&mut s, 10 + i as u64, "resources/read", json!({"uri": uri})); @@ -245,3 +245,116 @@ fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() { assert!(r.is_error, "{r:?}"); assert_eq!(b.session.active(), Some(first)); } + +#[test] +fn core_tools_have_hints_and_reject_unknown_keys() { + for compact in [false, true] { + let mut s = server().with_command_tools(!compact); + let list = rpc(&mut s, 1, "tools/list", json!({})); + let tools = list["result"]["tools"].as_array().unwrap(); + for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "list_commands", "run_command", "render_photo"] + { + assert!(tools.iter().any(|t| t["name"] == name), "missing {name}"); + } + for t in tools { + assert!(!t["title"].as_str().unwrap().is_empty()); + for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] { + assert!(t["annotations"][hint].is_boolean(), "{t}"); + } + } + for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "query_photos", "run_command"] { + let r = rpc(&mut s, 2, "tools/call", json!({"name":name,"arguments":{"misspelled":true}})); + assert_eq!(r["error"]["code"], -32602, "{r}"); + assert!(r["error"]["message"].as_str().unwrap().contains("misspelled"), "{r}"); + } + let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap(); + assert_eq!(preview["annotations"]["readOnlyHint"], true); + assert!(preview["inputSchema"]["properties"].get("path").is_none()); + let writer = tools.iter().find(|t| t["name"] == "render_photo").unwrap(); + assert_eq!(writer["annotations"]["readOnlyHint"], false); + let export = tools.iter().find(|t| t["name"] == "export").unwrap(); + for key in lightcraft_engine::export::OPTION_PARAMS.iter().chain(lightcraft_engine::export::TARGET_PARAMS) { + assert!(export["inputSchema"]["properties"].get(*key).is_some(), "missing export argument {key}"); + } + let before = rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"})); + let image = rpc(&mut s, 2, "tools/call", json!({"name":"render_preview","arguments":{"max_side":64}})); + assert_eq!(image["result"]["isError"], false, "{image}"); + assert!(image["result"]["content"].as_array().unwrap().iter().any(|c| c["type"] == "image")); + assert_eq!(before, rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"}))); + let batch = json!({"steps":[{"id":"catalog.stats"},{"id":"no.such"},{"id":"catalog.stats"}],"stop_on_error":false}); + let r = rpc(&mut s, 3, "tools/call", json!({"name":"command_batch","arguments":batch})); + assert_eq!(r["result"]["isError"], true); + assert_eq!(r["result"]["structuredContent"]["completed"], 2); + assert_eq!(r["result"]["structuredContent"]["failed"], 1); + let r = rpc(&mut s, 4, "tools/call", json!({"name":"command_batch","arguments":{"steps":[{"id":"no.such"},{"id":"catalog.stats"}]}})); + assert_eq!(r["result"]["structuredContent"]["completed"], 0); + assert_eq!(r["result"]["structuredContent"]["results"].as_array().unwrap().len(), 1); + } +} + +#[test] +fn core_resources_match_tools_after_parse_error() { + let mut s = server(); + let bad: Value = serde_json::from_str(&s.handle_line("{bad").unwrap()).unwrap(); + assert_eq!(bad["error"]["code"], -32700); + assert!(bad["id"].is_null()); + let list = rpc(&mut s, 1, "resources/list", json!({})); + for (uri, tool) in [("lightcraft://document", "doc_inspect"), ("lightcraft://commands", "command_list")] { + assert!(list["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri)); + let resource = rpc(&mut s, 2, "resources/read", json!({"uri":uri})); + let call = rpc(&mut s, 3, "tools/call", json!({"name":tool,"arguments":{}})); + let a: Value = serde_json::from_str(resource["result"]["contents"][0]["text"].as_str().unwrap()).unwrap(); + let b: Value = serde_json::from_str(call["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(a, b); + } + let old = rpc(&mut s, 4, "resources/read", json!({"uri":"lightcraft://library"})); + assert!(old["result"]["contents"].is_array(), "{old}"); +} + +#[test] +fn escaped_tool_panic_is_an_error_and_session_survives() { + struct Faulty { + inner: Headless, + fail: bool, + } + impl Backend for Faulty { + fn call(&mut self, method: &str, params: Value) -> Result { + if method == "engine.execute" && params["command"] == "catalog.stats" && std::mem::take(&mut self.fail) { + panic!("synthetic backend panic"); + } + self.inner.call(method, params) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "test".into() + } + } + let mut s = Server::new(Box::new(Faulty { inner: Headless::demo(), fail: true })); + let params = json!({"name":"run_command","arguments":{"command":"catalog.stats"}}); + let r = rpc(&mut s, 1, "tools/call", params.clone()); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r.to_string().contains("synthetic backend panic")); + assert_eq!(rpc(&mut s, 2, "tools/call", params)["result"]["isError"], false); + assert!(rpc(&mut s, 3, "ping", json!({}))["result"].is_object()); +} + +#[test] +fn modern_requests_receive_result_and_cache_fields() { + let mut s = server(); + let meta = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"}); + for (method, mut params) in [ + ("tools/list", json!({})), + ("resources/list", json!({})), + ("resources/templates/list", json!({})), + ("resources/read", json!({"uri":"lightcraft://library"})), + ] { + params["_meta"] = meta.clone(); + let r = rpc(&mut s, 1, method, params); + assert_eq!(r["result"]["resultType"], "complete", "{r}"); + assert_eq!(r["result"]["cacheScope"], "private"); + assert_eq!(r["result"]["ttlMs"], if method == "resources/read" { 0 } else { 600_000 }); + } + assert!(rpc(&mut s, 2, "tools/list", json!({}))["result"].get("resultType").is_none()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1b41d86a..929a0d29 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -66,7 +66,7 @@ fn tool(name: &str, title: &str, description: &str, properties: Value, required: "name": name, "title": title, "description": description, - "inputSchema": {"type": "object", "properties": properties, "required": required}, + "inputSchema": {"type": "object", "properties": properties, "required": required, "additionalProperties": false}, }) } @@ -78,6 +78,51 @@ fn ids_schema(what: &str) -> Value { pub fn helper_tools(has_ui: bool) -> Vec { let photo_id = json!({"type": "integer", "description": "Photo id (default: the active photo). Makes that photo active first."}); let mut v = vec![ + tool( + "command_list", + "List commands", + "Every command (engine + UI when connected) with id, label, menu, shortcut, parameter doc and whether it is enabled now. Each id is also callable as tool `cmd_` or via command_run.", + json!({ + "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)"}, + "enabled_only": {"type": "boolean", "description": "Only commands that are enabled now"} + }), + &[], + ), + tool( + "command_run", + "Run command", + "Run any LightCraft command by id with JSON params (see command_list for ids and parameter docs), e.g. {id: \"photo.rate\", params: {rating: 4}}.", + json!({"id": {"type": "string", "description": "Command id"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}), + &["id"], + ), + tool( + "command_batch", + "Run commands", + "Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}.", + json!({ + "steps": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}, "required": ["id"], "additionalProperties": false}}, + "stop_on_error": {"type": "boolean", "description": "Stop at the first failing step (default true)"} + }), + &["steps"], + ), + tool( + "doc_inspect", + "Inspect library", + "The library as JSON: current source, filter, sort, selection, active photo and undo/redo labels (library.state) plus counts (catalog.stats).", + json!({}), + &[], + ), + tool( + "render_preview", + "Render preview", + "Render a photo with its current develop settings (cropped) and return it as an image (`max_side` is the long edge; does not change selection or save a file).", + json!({ + "id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"}, + "max_side": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"}, + "format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"}, + }), + &[], + ), tool( "list_commands", "List commands", @@ -187,6 +232,13 @@ pub fn helper_tools(has_ui: bool) -> Vec { "ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"}, "path": {"type": "string"}, "dir": {"type": "string"}, + "preset": {"type": "string", "description": "Export preset id or name"}, + "background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"}, + "resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"}, + "subfolder": {"type": "string"}, + "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, + "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, + "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, @@ -213,6 +265,9 @@ pub fn helper_tools(has_ui: bool) -> Vec { ]; if has_ui { v.extend([ + tool("ui_inspect", "Inspect UI", "UI state and selection (same as inspect_ui).", json!({}), &[]), + tool("ui_screenshot", "Screenshot", "Return the window as an image without saving a file.", + json!({"maxSize": {"type": "integer"}, "format": {"type": "string", "enum": ["png", "jpeg"]}}), &[]), tool( "screenshot", "Screenshot", @@ -298,12 +353,60 @@ pub fn command_tools(backend: &mut dyn Backend) -> Vec { .collect() } +/// Conservative hints: generated command tools may edit; optional output paths make a helper a writer. +fn annotations(name: &str, title: &str) -> Value { + let read_only = matches!( + name, + "command_list" + | "list_commands" + | "doc_inspect" + | "render_preview" + | "query_photos" + | "list_controls" + | "get_develop" + | "inspect_ui" + | "list_widgets" + | "ui_inspect" + | "ui_screenshot" + ); + let files = matches!(name, "export" | "render_photo" | "screenshot"); + json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only && !files, + "idempotentHint": read_only || matches!(name, "render_photo" | "screenshot"), "openWorldHint": false}) +} + +/// Helpers have fixed keys; generated command tools retain the registry's free-form params. +pub(crate) fn check_args(name: &str, args: &Value) -> Result<(), String> { + if args.is_null() { + return Ok(()); + } + let Some(m) = args.as_object() else { + return Err("tool arguments must be an object".into()); + }; + let defs = helper_tools(true); + let Some(def) = defs.iter().find(|t| t["name"] == name) else { + return Ok(()); + }; + let Some(props) = def["inputSchema"]["properties"].as_object() else { + return Ok(()); + }; + if let Some(bad) = m.keys().find(|k| !props.contains_key(*k)) { + let accepted: Vec<&str> = props.keys().map(String::as_str).collect(); + return Err(format!("unknown argument \"{bad}\" for {name}; expected: {}", accepted.join(", "))); + } + Ok(()) +} + /// All tools: helpers, plus the generated command tools when `with_commands`. pub fn tool_definitions(backend: &mut dyn Backend, with_commands: bool) -> Vec { let mut v = helper_tools(backend.has_ui()); if with_commands { v.extend(command_tools(backend)); } + for t in &mut v { + let name = t["name"].as_str().unwrap_or_default().to_string(); + let title = t["title"].as_str().unwrap_or(&name).to_string(); + t["annotations"] = annotations(&name, &title); + } v } @@ -445,22 +548,67 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { return ToolResult::from(exec(b, &rest.replace('_', "."), args.clone())); } match name { - "list_commands" => { + "list_commands" | "command_list" => { let filter = s("filter").unwrap_or("").to_lowercase(); + let enabled_only = args.get("enabled_only").and_then(Value::as_bool).unwrap_or(false); ToolResult::from(b.call("engine.commands", json!({})).map(|v| { match v { - Value::Array(a) if !filter.is_empty() => Value::Array( + Value::Array(a) => Value::Array( a.into_iter() .filter(|c| { - c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter) + filter.is_empty() + || c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter) || c["label"].as_str().unwrap_or("").to_lowercase().contains(&filter) }) + .filter(|c| !enabled_only || c["enabled"].as_bool() != Some(false)) .collect(), ), v => v, } })) } + "command_run" => match s("id") { + Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))), + None => ToolResult::error("missing `id`"), + }, + "command_batch" => { + let Some(steps) = args.get("steps").and_then(Value::as_array) else { return ToolResult::error("missing `steps`") }; + let stop = args.get("stop_on_error").and_then(Value::as_bool).unwrap_or(true); + let (mut completed, mut failed, mut results) = (0, 0, Vec::new()); + for st in steps { + let Some(id) = st["id"].as_str() else { + failed += 1; + results.push(json!({"ok": false, "error": "step without `id`"})); + if stop { + break; + } + continue; + }; + match exec(b, id, st.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))) { + Ok(r) => { + completed += 1; + results.push(json!({"ok": true, "result": r})); + } + Err(e) => { + failed += 1; + results.push(json!({"ok": false, "error": e})); + if stop { + break; + } + } + } + } + let r = json!({"completed": completed, "failed": failed, "results": results}); + if failed > 0 { ToolResult { is_error: true, ..ToolResult::json(r) } } else { ToolResult::json(r) } + } + "doc_inspect" => ToolResult::from(doc_inspect(b)), + "render_preview" => { + let mut a = obj(args, &["id", "format"]); + if let Some(m) = args.get("max_side") { + a["size"] = m.clone(); + } + render_photo(b, &a) + } "run_command" => match s("command") { Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))), None => ToolResult::error("missing `command`"), @@ -585,8 +733,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { ), ) })), - "screenshot" => screenshot(b, args), - "inspect_ui" => ToolResult::from(b.call("ui.inspect", json!({}))), + "screenshot" | "ui_screenshot" => screenshot(b, args), + "inspect_ui" | "ui_inspect" => ToolResult::from(b.call("ui.inspect", json!({}))), "set_ui" => ToolResult::from(b.call("ui.set", args.get("state").cloned().unwrap_or(json!({})))), "list_widgets" => ToolResult::from(b.call("ui.widgets", obj(args, &["filter"]))), "click" => { @@ -606,3 +754,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { other => ToolResult::error(format!("unknown tool `{other}` (see tools/list)")), } } + +/// The canonical document resource and tool share the same snapshot. +pub(crate) fn doc_inspect(b: &mut dyn Backend) -> Result { + let library = exec(b, "library.state", json!({}))?; + let stats = exec(b, "catalog.stats", json!({}))?; + Ok(json!({"library": library, "stats": stats})) +} diff --git a/docs/mcp.md b/docs/mcp.md index 7e815406..24f53c65 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -71,6 +71,39 @@ Every stdio MCP client takes the same shape: a `command` plus `args`. For exampl During development you can also point the client at `cargo run --release -p lightcraft-cli -- mcp` (with `"cwd"` set to the repository), at the cost of a slower start. +## Shared core tools + +These follow the same conventions as [filmcraft #28](https://github.com/storytold/filmcraft/pull/28). +They are listed in both full and compact mode; existing documented helpers remain listed too. +There are no hidden compatibility aliases. + +| Tool | Arguments | Result | +|---|---|---| +| `command_list` | `filter?`, `enabled_only?` | Command catalog | +| `command_run` | `id`, `params?` | Command result | +| `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | Counts and per-step results; stops on error by default | +| `doc_inspect` | none | Library state and catalog counts | +| `render_preview` | `id?`, `max_side?`, `format?` | Image; neither changes selection nor saves a file | + +Each edit in a batch has its own undo step. Connect mode also lists `ui_inspect` and +`ui_screenshot` (the latter has no output-path argument). The existing `inspect_ui`, +`screenshot`, `list_commands`, `run_command` and `render_photo` keep their documented arguments. +Ports and connect mode are unchanged. + +Every listed tool has a title and all four MCP hints. Generated command tools are conservatively +marked as edits. Helpers with optional output paths are marked as writers; `export` is not +idempotent because the default conflict policy chooses a new filename on repeated calls. +Unknown helper argument keys return JSON-RPC `-32602` naming the key and accepted arguments. +Generated `cmd_*` tools and nested command params remain free-form: the registry has prose +parameter docs, not machine-readable schemas. Existing command validation (including strict +export options) is preserved. Escaped tool panics return `isError: true`, and the session keeps +serving; backend panics during resource reads or tool listing return an internal error. +The headless backend owns its session directly, without a session mutex. + +`lightcraft://document` and `lightcraft://commands` contain JSON matching `doc_inspect` and +`command_list`. Existing resources remain available. Requests declaring MCP 2026-07-28 in +per-request `_meta` receive `resultType: complete` and list/read cache hints; reads are not cached. + ## Tools ### Helpers From dedc0496925f412d88690fea09a49afd52d228f9 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Thu, 8 Oct 2026 23:02:21 -0700 Subject: [PATCH 008/344] MCP: add export progress and cancellation between photos Answer ping at photo boundaries, preserve queued requests and EOF completion, and suppress cancelled responses. Keep completed exports and the existing atomic file writer; cover cancellation, output preservation and transport poison recovery. Co-Authored-By: OpenAI Codex --- apps/lightcraft-cli/src/main.rs | 2 +- crates/mcp/src/backend.rs | 5 + crates/mcp/src/headless.rs | 37 +++++-- crates/mcp/src/lib.rs | 2 +- crates/mcp/src/progress.rs | 165 ++++++++++++++++++++++++++++++++ crates/mcp/src/server.rs | 47 ++++++--- crates/mcp/tests/e2e.rs | 112 ++++++++++++++++++++++ docs/mcp.md | 17 ++++ 8 files changed, 365 insertions(+), 22 deletions(-) create mode 100644 crates/mcp/src/progress.rs diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index f8ddf550..7c9d255e 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -342,7 +342,7 @@ fn mcp(args: &[String]) -> Result<(), String> { eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe()); let mut server = Server::new(backend).with_command_tools(!compact); let stdin = std::io::stdin(); - server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string()) + server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) } fn merge(args: &[String]) -> Result<(), String> { diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 5db2aaaa..4772363b 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -15,8 +15,13 @@ pub trait Backend { fn has_ui(&self) -> bool; /// Short human description ("headless", "remote 127.0.0.1:7980"). fn describe(&self) -> String; + /// Progress/cancellation for the next headless batch export. Other backends ignore it. + fn set_progress(&mut self, _hook: Option) {} } +/// `(completed photos, total photos, next filename)`; false stops before the next photo. +pub type ProgressHook = Box bool + Send>; + /// A running LightCraft app, reached through its loopback control port. pub struct Remote { addr: String, diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 837302d6..0817b39d 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -8,7 +8,7 @@ use lightcraft_engine::catalog::PhotoId; use lightcraft_raster::Rgba8; use serde_json::{Value, json}; -use crate::backend::Backend; +use crate::backend::{Backend, ProgressHook}; /// File extensions recognised as photos when expanding folders. pub const PHOTO_EXTENSIONS: &[&str] = &[ @@ -20,6 +20,7 @@ pub const PHOTO_EXTENSIONS: &[&str] = &[ /// Headless backend: a [`Session`] with filesystem hooks. pub struct Headless { pub session: Session, + progress: Option, } impl Drop for Headless { @@ -37,7 +38,7 @@ impl Default for Headless { impl Headless { pub fn new(session: Session) -> Self { - Self { session } + Self { session, progress: None } } /// A headless session with the procedurally generated demo library. @@ -60,7 +61,7 @@ impl Headless { /// (see `lightcraft_engine::export::ExportOptions::from_json`, plus `ids`, `dir`, `path`). /// With `path` and no `format`, the format follows the path's extension. fn export(&mut self, p: &Value) -> Result { - use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch}; + use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch}; let p = &self.session.export_params(p)?; let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; if !ExportOptions::has_size_param(p) { @@ -82,10 +83,28 @@ impl Headless { }; let dir = p.get("dir").and_then(Value::as_str).unwrap_or(""); let write = &mut lightcraft_engine::export::write_file; - let files = - export_batch(&mut self.session, &ids, &opts, &Destination { dir: dir.to_string(), exact: exact.map(str::to_string) }, write, &|path| { - Path::new(path).exists() - })?; + let items = prepare_batch(&mut self.session, &ids, &opts)?; + let total = items.len(); + let mut hook = self.progress.take(); + let mut cancelled = false; + let files = run_batch( + items, + &opts, + &Destination { dir: dir.to_string(), exact: exact.map(str::to_string) }, + write, + &|path| Path::new(path).exists(), + true, + &mut |done, name| { + cancelled = hook.as_mut().is_some_and(|h| !h(done, total, name)); + !cancelled + }, + )?; + if !cancelled && let Some(hook) = hook.as_mut() { + cancelled = !hook(total, total, ""); + } + if cancelled { + return Err("cancelled".into()); + } // Single-photo exports also report path/width/height at the top level (back-compat). let mut out = files.first().cloned().unwrap_or_else(|| json!({})); out["files"] = json!(files); @@ -94,6 +113,10 @@ impl Headless { } impl Backend for Headless { + fn set_progress(&mut self, hook: Option) { + self.progress = hook; + } + fn call(&mut self, method: &str, params: Value) -> Result { let p = if params.is_null() { json!({}) } else { params }; match method { diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index a1fb5510..b2417fe0 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -21,7 +21,7 @@ mod headless; mod server; mod tools; -pub use backend::{Backend, Remote}; +pub use backend::{Backend, ProgressHook, Remote}; pub use base64::{base64_decode, base64_encode}; pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image}; pub use server::{PROTOCOL_VERSION, Server}; diff --git a/crates/mcp/src/progress.rs b/crates/mcp/src/progress.rs new file mode 100644 index 00000000..2043dfd9 --- /dev/null +++ b/crates/mcp/src/progress.rs @@ -0,0 +1,165 @@ +//! Stdio progress and cancellation between photos. The backend keeps its session on the +//! serving thread; only stdin and stdout move to transport threads. Other calls wait in order. + +use std::collections::VecDeque; +use std::io::{BufRead, Write}; +use std::sync::atomic::Ordering; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use super::{Server, response}; +use crate::backend::ProgressHook; + +type Input = Option>; + +#[derive(Clone)] +pub(super) struct Wire { + inbox: Arc>>, + out: Sender, + deferred: Arc>>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +impl Server { + pub(super) fn serve_wire(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> { + std::thread::scope(|scope| { + let (in_tx, in_rx) = std::sync::mpsc::channel::(); + let (out_tx, out_rx) = std::sync::mpsc::channel::(); + let wire = Wire { inbox: Arc::new(Mutex::new(in_rx)), out: out_tx, deferred: Arc::new(Mutex::new(VecDeque::new())) }; + let writer = std::thread::Builder::new().name("mcp-output".into()).spawn_scoped(scope, move || -> std::io::Result<()> { + let mut output = output; + for line in out_rx { + output.write_all(line.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + Ok(()) + })?; + let reader = match std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || { + for line in input.lines() { + let failed = line.is_err(); + if in_tx.send(Some(line)).is_err() || failed { + return; + } + } + let _ = in_tx.send(None); + }) { + Ok(reader) => reader, + Err(error) => { + drop(wire); + let _ = writer.join(); + return Err(error); + } + }; + self.wire = Some(wire.clone()); + let served = (|| -> std::io::Result<()> { + loop { + let queued = lock(&wire.deferred).pop_front(); + let line = match queued { + Some(line) => line, + None => lock(&wire.inbox).recv().ok().flatten(), + }; + let Some(line) = line else { return Ok(()) }; + if let Some(reply) = self.handle_line(&line?) { + wire.out.send(reply).map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "MCP output closed"))?; + } + } + })(); + self.wire = None; + drop(wire); + let written = writer.join().map_err(|_| std::io::Error::other("MCP output thread panicked")).and_then(|r| r); + let read = reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked")); + served.and(written).and(read) + }) + } + + /// Called at photo boundaries, including completion. EOF finishes the pending export; + /// cancellation stops before the next photo. No file scan or output deletion is needed: + /// the existing writer replaces each whole file atomically. + pub(super) fn progress_hook(&self) -> Option { + let wire = self.wire.clone()?; + let (id, token) = self.current.clone()?; + let suppress = self.suppress.clone(); + let mut closed = false; + let mut last = None; + let mut reported: Option = None; + Some(Box::new(move |done, total, name| { + while !closed { + let next = lock(&wire.inbox).try_recv(); + let line = match next { + Ok(Some(Ok(line))) => line, + Ok(Some(Err(error))) => { + lock(&wire.deferred).push_back(Some(Err(error))); + closed = true; + break; + } + Ok(None) | Err(TryRecvError::Disconnected) => { + lock(&wire.deferred).push_back(None); + closed = true; + break; + } + Err(TryRecvError::Empty) => break, + }; + let msg: Value = serde_json::from_str(&line).unwrap_or(Value::Null); + match msg.get("method").and_then(Value::as_str) { + Some("notifications/cancelled") if msg.get("id").is_none() && msg["params"]["requestId"] == id => { + suppress.store(true, Ordering::Relaxed); + } + Some("ping") + if msg.get("id").is_some_and(|i| i.is_string() || i.is_number()) + && msg.get("params").is_none_or(|p| p.is_null() || p.is_object()) => + { + let _ = wire.out.send(response(msg["id"].clone(), json!({})).to_string()); + } + _ => lock(&wire.deferred).push_back(Some(Ok(line))), + } + } + if suppress.load(Ordering::Relaxed) { + return false; + } + if let Some(token) = &token + && total > 0 + && last.is_none_or(|last| done > last) + && (done == total || reported.is_none_or(|at| at.elapsed() >= Duration::from_millis(100))) + { + last = Some(done); + reported = Some(Instant::now()); + let message = if done < total { + format!("Exporting {name} ({} of {total})", done.saturating_add(1)) + } else { + format!("Exported {total} photo(s)") + }; + let notification = json!({"jsonrpc":"2.0","method":"notifications/progress", + "params":{"progressToken":token,"progress":done,"total":total,"message":message}}); + let _ = wire.out.send(notification.to_string()); + } + true + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transport_lock_recovers_after_panic() { + let state = Arc::new(Mutex::new(VecDeque::new())); + let held = state.clone(); + let _ = std::thread::spawn(move || { + held.lock().unwrap().push_back(7); + let _guard = held.lock().unwrap(); + panic!("synthetic transport panic"); + }) + .join(); + assert_eq!(lock(&state).pop_front(), Some(7)); + lock(&state).push_back(8); + assert_eq!(lock(&state).pop_front(), Some(8)); + } +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 3f1f12f0..ba64ef45 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -1,6 +1,11 @@ //! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods. use std::io::{BufRead, Write}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[path = "progress.rs"] +mod progress; use serde_json::{Value, json}; @@ -40,6 +45,9 @@ pub struct Server { backend: Box, initialized: bool, command_tools: bool, + wire: Option, + current: Option<(Value, Option)>, + suppress: Arc, } fn response(id: Value, result: Value) -> Value { @@ -56,7 +64,7 @@ fn resource(uri: &str, name: &str, title: &str, description: &str) -> Value { impl Server { pub fn new(backend: Box) -> Self { - Self { backend, initialized: false, command_tools: true } + Self { backend, initialized: false, command_tools: true, wire: None, current: None, suppress: Arc::new(AtomicBool::new(false)) } } /// Whether `tools/list` includes one `cmd_*` tool per registered command (default true). @@ -77,16 +85,8 @@ impl Server { /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is /// the protocol stream). - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if let Some(reply) = self.handle_line(&line) { - output.write_all(reply.as_bytes())?; - output.write_all(b"\n")?; - output.flush()?; - } - } - Ok(()) + pub fn serve(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> { + self.serve_wire(input, output) } /// Handle one line; returns the reply line (None for notifications and blank lines). @@ -133,7 +133,15 @@ impl Server { if !(params.is_object() || params.is_null()) { return Some(error(id, INVALID_PARAMS, "`params` must be an object")); } - Some(match self.request(method, ¶ms) { + let token = params.get("_meta").and_then(|m| m.get("progressToken")).filter(|t| t.is_string() || t.is_number()).cloned(); + self.current = Some((id.clone(), token)); + self.suppress.store(false, Ordering::Relaxed); + let result = self.request(method, ¶ms); + self.current = None; + if self.suppress.swap(false, Ordering::Relaxed) { + return None; + } + Some(match result { Ok(mut r) => { modern_result_fields(method, ¶ms, &mut r); response(id, r) @@ -172,7 +180,20 @@ impl Server { let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; let args = params.get("arguments").cloned().unwrap_or(Value::Null); check_args(name, &args).map_err(|e| (INVALID_PARAMS, e))?; - Ok(self.guarded(|b| call_tool(b, name, &args)).unwrap_or_else(ToolResult::error).to_value()) + let long = matches!(name, "export" | "cmd_app_export") + || (name == "command_run" && args["id"] == "app.export") + || (name == "run_command" && args["command"] == "app.export"); + let hook = if long && !self.backend.has_ui() { self.progress_hook() } else { None }; + let result = self.guarded(|b| { + b.set_progress(hook); + call_tool(b, name, &args) + }); + let clear = self.guarded(|b| b.set_progress(None)); + let result = match (result, clear) { + (Ok(result), Ok(())) => result, + (Err(error), _) | (_, Err(error)) => ToolResult::error(error), + }; + Ok(result.to_value()) } "resources/list" => Ok(json!({"resources": [ resource(DOCUMENT_URI, "document", "Library document", "Library state and counts (same as doc_inspect)"), diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/e2e.rs index ea27919c..ef2e49b6 100644 --- a/crates/mcp/tests/e2e.rs +++ b/crates/mcp/tests/e2e.rs @@ -153,3 +153,115 @@ fn remote_unreachable_is_a_tool_error() { assert_eq!(r[0]["result"]["isError"], true); assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable")); } + +/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation +/// between complete photos, unrelated output preservation, and a usable session afterwards. +#[test] +fn export_progress_and_cancel() { + let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (input, mut send) = std::io::pipe().unwrap(); + let (output, write) = std::io::pipe().unwrap(); + let server = std::thread::spawn(move || Server::new(Box::new(Headless::demo())).serve(BufReader::new(input), write).unwrap()); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); + if tx.send(value).is_err() { + break; + } + } + }); + let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress"); + writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); + let query = next(); + let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids.len(), 8); + let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"})); + request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"}); + writeln!(send, "{request}").unwrap(); + let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false); + loop { + let value = next(); + if value["method"] == "notifications/progress" { + assert_eq!(value["params"]["progressToken"], "photos"); + let progress = value["params"]["progress"].as_f64().unwrap(); + assert!(progress > last && progress <= 8.0, "{value}"); + assert_eq!(value["params"]["total"], 8); + last = progress; + notes += 1; + if !asked { + writeln!(send, "{}", json!({"jsonrpc":"2.0","id":3,"method":"ping"})).unwrap(); + asked = true; + } + } else if value["id"] == 3 { + answered = true; + } else { + assert_eq!(value["id"], 2, "{value}"); + assert_eq!(value["result"]["isError"], false, "{value}"); + assert_eq!(value["result"]["resultType"], "complete"); + break; + } + } + assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}"); + assert_eq!(last, 8.0); + let out = dir.join("cancelled"); + std::fs::create_dir_all(&out).unwrap(); + let decoy = out.join("photo999.png"); + std::fs::write(&decoy, b"unrelated existing output").unwrap(); + let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}})); + request["params"]["_meta"] = json!({"progressToken":44}); + writeln!(send, "{request}").unwrap(); + loop { + let value = next(); + assert_ne!(value["id"], 4, "must be cancellable before completion: {value}"); + assert_eq!(value["params"]["progressToken"], 44, "{value}"); + if value["params"]["progress"].as_f64().unwrap() > 0.0 { + break; + } + } + writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap(); + writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap(); + loop { + let value = next(); + if value["method"] == "notifications/progress" { + continue; + } + assert_eq!(value["id"], 5, "cancelled request must not reply: {value}"); + assert_eq!(value["result"]["isError"], false); + break; + } + assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output"); + let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect(); + assert!(!photos.is_empty() && photos.len() < ids.len(), "only completed photos remain: {photos:?}"); + for path in &photos { + lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file"); + } + assert_eq!(std::fs::read_dir(dir.join("complete")).unwrap().count(), 8, "earlier export retained"); + drop(send); + server.join().unwrap(); + reader.join().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn export_without_token_finishes_after_eof() { + let dir = std::env::temp_dir().join(format!("lc-mcp-eof-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut headless = Headless::demo(); + let ids: Vec<_> = headless.session.visible_cloned().iter().take(2).map(|id| id.0).collect(); + let request = call(1, "cmd_app_export", json!({"ids":ids,"dir":dir,"longEdge":64,"format":"png"})); + // Headless sessions remain movable to a worker after installing progress support. + let replies = std::thread::spawn(move || { + let mut s = Server::new(Box::new(headless)); + session(&mut s, &[request, call(2, "doc_inspect", json!({}))]) + }) + .join() + .unwrap(); + assert_eq!(replies.len(), 2, "no token means no notifications"); + assert_eq!(replies[0]["result"]["isError"], false, "{replies:?}"); + assert_eq!(replies[1]["id"], 2, "queued request survives EOF"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/docs/mcp.md b/docs/mcp.md index 24f53c65..3134468e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -219,3 +219,20 @@ lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (d (`Remote`) against a stand-in control server; also import → render → JPEG export of a real file. - `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`; `commands`. + +## Export progress and cancellation + +Headless direct `export`, `command_run` / `run_command` with `app.export`, and `cmd_app_export` +calls report photo-count progress when `params._meta.progressToken` is a string or number. +Notifications are strictly increasing, at most ten per second plus the final total. No token +means no notifications. `ping` is answered at photo boundaries; other requests wait in order +until the export ends. EOF lets a pending export finish and preserves queued requests. + +`notifications/cancelled` with `params.requestId` stops the matching export before its next +photo, suppressing its response. Unknown/completed request ids are ignored. A photo already +being processed finishes first: completed photos remain, each written with the existing atomic +file writer, and there are no partial files to delete. Unrelated outputs and earlier exports +are untouched. A failed export is an `isError` tool result. Transport mutexes recover poisoning. + +Connect mode and exports inside `command_batch` remain synchronous and do not report MCP +progress or cancellation. Use a direct headless export call for this behavior. From 35718bc92cc13c4e7679a1c0d5d5550cc70c695b Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:04:40 +0900 Subject: [PATCH 009/344] M1: keep deletion selection near the current photo --- crates/engine/src/cmd/library.rs | 14 ++++++++-- crates/engine/src/tests.rs | 43 +++++++++++++++++++++++++++++- crates/ui-egui/src/tests_scroll.rs | 27 +++++++++++++++++++ docs/parity.md | 2 +- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d900eacf..09d19f0d 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -661,9 +661,19 @@ pub fn specs() -> Vec { ), // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { + let before = s.visible_cloned(); + let at = before.iter().position(|id| Some(*id) == s.selection.active); let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; - let vis = s.visible_cloned(); - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); + let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); + s.selection.ids.retain(|id| remaining.contains(id)); + s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); + if s.selection.active.is_none() + && let Some(at) = at + { + // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. + let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); + s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); + } Ok(v) }), cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9806a81e..d26f5a0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,6 @@ use serde_json::{Value, json}; -use crate::{Session, command_specs}; +use crate::{Selection, Session, command_specs}; fn demo() -> Session { Session::with_demo() @@ -157,6 +157,47 @@ fn filters_and_delete_restore() { assert_eq!(s.visible().len(), 24); } +#[test] +fn deleting_photos_selects_a_neighbour_in_the_current_view() { + for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] { + for at_end in [false, true] { + let mut s = demo(); + s.execute("library.sort", ¶ms).unwrap(); + s.execute("library.filter", &json!({"rating": 2})).unwrap(); + let visible = s.visible_cloned(); + assert!(visible.len() > 5); + let at = if at_end { visible.len() - 2 } else { visible.len() / 2 }; + let deleted = &visible[at..at + 2]; + s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + let expected = if at_end { visible[at - 1] } else { visible[at + 2] }; + assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}"); + assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(deleted.iter().all(|id| !s.catalog.photo(*id).unwrap().deleted)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(deleted.iter().all(|id| s.catalog.photo(*id).unwrap().deleted)); + } + } + let mut s = demo(); + s.execute("library.selectAll", &json!({})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(s.visible().is_empty()); + assert_eq!(s.selection, Selection::default()); +} + +#[test] +fn deleting_explicit_targets_keeps_the_surviving_selection() { + let mut s = demo(); + let visible = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap(); + let before = s.selection.clone(); + s.execute("photo.delete", &json!({"ids": [visible[3]]})).unwrap(); + assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection"); + s.execute("photo.delete", &json!({"ids": [visible[9]]})).unwrap(); + assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor"); +} + #[test] fn masks_crop_and_render() { let mut s = demo(); diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests_scroll.rs index 455bd0d8..6ccb0d33 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests_scroll.rs @@ -84,6 +84,33 @@ fn grid_keeps_the_users_scroll_position() { assert!(grid_y(&h) < y2, "scrolled up to the second photo"); } +#[test] +fn deleting_photos_does_not_jump_the_grid_to_the_top() { + for view in ["photoGrid", "squareGrid"] { + for count in [1, 3] { + let mut h = demo(view); + let ids = h.app.session.visible_cloned(); + let at = ids.len() / 2; + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": &ids[at..at + count]}}), T); + assert_eq!(r["ok"], true, "{r}"); + idle(&mut h, 30); + let y = grid_y(&h); + assert!(y > 600.0, "selection is well below the top: {view}, {y}"); + let r = h.request("engine.execute", json!({"command": "photo.delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + idle(&mut h, 60); + let next = ids[at + count]; + assert!(grid_y(&h) > 600.0, "deleting scrolled to the top: {view}, {} → {}", y, grid_y(&h)); + assert_eq!(h.app.session.active(), Some(next), "{view}, count {count}"); + let canvas = h.app.canvas_rect.unwrap(); + assert!(widget(&h, &format!("thumb:{}", next.0)).intersects(canvas), "the next survivor is in view"); + let after = grid_y(&h); + idle(&mut h, 60); + assert_eq!(grid_y(&h), after, "idle frames preserve the new position"); + } + } +} + #[test] fn filmstrip_wheel_scrolls_and_keeps_its_position() { let mut h = demo("detail"); diff --git a/docs/parity.md b/docs/parity.md index c06dad1b..c32f5fbc 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -168,7 +168,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Deletion retains surviving selected photos; if none remain, selects the next visible survivor (previous at the end) in the current source/filter/sort instead of jumping to the first photo; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` skip / restore / fresh for files in the trash (`cmd:library.import`; the report flags `existingDeleted` and lists `restored`; the import review dialog offers Leave them / Restore them / Import as new for files that are in the trash; dropped files and Add Photos without a review still skip and point at Restore); no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | From 1206d3fd521e957c14b64245695e78672f0349e5 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 02:46:06 -0400 Subject: [PATCH 010/344] LR-IMP-FORMATS: decode Canon sRAW and mRAW CR2 from their YCbCr data sRAW and mRAW store subsampled YCbCr in a lossless-JPEG frame cut into slices of whole MCUs. Reuse the subsampled lossless decoder added for Sony M/S ARW with Canon's per-component sequential prediction, re-chop the stream into image planes, and convert to linear white-balanced RGB with chroma gains measured per file against the camera's own IFD2 RGB. Render cache version 17. --- crates/engine/src/media.rs | 2 +- crates/raw/src/ljpeg.rs | 97 +++-- crates/raw/src/vendor/arw.rs | 2 +- crates/raw/src/vendor/cr2.rs | 12 +- crates/raw/src/vendor/cr2/sraw.rs | 580 ++++++++++++++++++++++++++++++ 5 files changed, 661 insertions(+), 32 deletions(-) create mode 100644 crates/raw/src/vendor/cr2/sraw.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b0439390..4519e8f9 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 = 16; +pub const RENDER_CACHE_VERSION: u64 = 17; /// 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/ljpeg.rs b/crates/raw/src/ljpeg.rs index c8087a8e..7978f933 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -369,12 +369,25 @@ fn check_subsampled(h: &Header) -> Result { 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> { +/// Sony M/S tiles and Canon sRAW / mRAW: horizontal prediction, no restart markers. Keep subsampled planes +/// separate; expanding them into a mosaic would silently corrupt the existing CFA callers. +/// Returns (width, height, vertical subsampling of the chroma planes). +pub(crate) fn frame_info_subsampled(d: &[u8]) -> Result<(usize, usize, usize), RawError> { let h = parse_header(d)?; - check_subsampled(&h)?; - Ok((h.width, h.height)) + let vertical = check_subsampled(&h)?; + Ok((h.width, h.height, vertical)) +} + +/// How the first-column prediction and the running predictor of a subsampled frame are chosen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Prediction { + /// Sony: the left neighbour in the plane; at the first column the sample above, except that a top luma + /// row of an MCU row reads the previous MCU row's top luma sample. + Geometric, + /// Canon: every component is its own one-dimensional chain in decoding order (the previous sample of the + /// same component, so the second luma row of a 4:2:0 MCU continues from the end of the first); the first + /// sample of each MCU row starts from the first sample of the previous MCU row. + Sequential, } pub(crate) struct FrameSubsampled { @@ -385,11 +398,13 @@ pub(crate) struct FrameSubsampled { 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 +/// The 4:2:0 / 4:2:2 LJ92 variants of Sony (M/S ARW) and Canon (sRAW / mRAW): T.81 MCU ordering and differences, +/// modulo 2^16. [`Prediction`] selects the predictor each maker uses. +/// +/// Sony: 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 { +pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize, prediction: Prediction) -> 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"))?; @@ -410,6 +425,9 @@ pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result Result 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 pred = match prediction { + Prediction::Geometric if x > 0 => plane[i - 1] as i32, + Prediction::Geometric if c == 0 && dy == 0 && y >= vertical => plane[i - vertical * width] as i32, + Prediction::Geometric if y > 0 => plane[i - width] as i32, + Prediction::Geometric => init, + Prediction::Sequential if mx == 0 && dx == 0 && dy == 0 => row_first[c], + Prediction::Sequential => last[c], }; 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; + let value = ((pred + diff_value(&mut br, ssss)) & 0xffff) as u16; + plane[i] = value; + last[c] = i32::from(value); + if mx == 0 && dx == 0 && dy == 0 { + row_first[c] = i32::from(value); + } } } } @@ -816,11 +838,13 @@ pub(crate) mod tests { /// 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]) + fixture_subsampled(4, 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]; + /// A 16-bit three-component frame of `width × height` pixels with the given luma `sampling` (`0x21` / `0x22`) + /// and one shared 5-bit Huffman table, whose differences are written as given (MCU order). + pub(crate) fn fixture_subsampled(width: u8, height: u8, sampling: u8, differences: &[i32]) -> Vec { + let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, width, 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; @@ -844,28 +868,28 @@ pub(crate) mod tests { #[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(); + let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + let frame = decode_subsampled(&enc, 16, Prediction::Geometric).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()); + assert!(matches!(decode_subsampled(&enc, 15, Prediction::Geometric), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 16, Prediction::Geometric).is_err()); } #[test] fn subsampled_mcu_order_preserves_each_plane() { let enc = fixture_420(); - let frame = decode_subsampled(&enc, 24).unwrap(); + let frame = decode_subsampled(&enc, 24, Prediction::Geometric).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()); + assert!(matches!(decode_subsampled(&enc, 23, Prediction::Geometric), Err(RawError::Limit(_)))); + assert!(decode_subsampled(&enc[..enc.len() - 6], 24, Prediction::Geometric).is_err()); let mut bad = enc.clone(); bad[10] = 3; // odd frame width assert!(frame_info_subsampled(&bad).is_err()); @@ -875,6 +899,25 @@ pub(crate) mod tests { assert!(frame_info_subsampled(&bad).is_err()); } + #[test] + fn sequential_prediction_chains_each_component_in_decoding_order() { + // Canon sRAW / mRAW: the same stream as `subsampled_mcu_order_preserves_each_plane`, but each component + // continues from its previous sample in decoding order (the second luma row of an MCU follows the first), + // and a new MCU row starts from the first sample of the previous one. Worked out by hand from init 32768. + let enc = fixture_420(); + let frame = decode_subsampled(&enc, 24, Prediction::Sequential).unwrap(); + assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 4, 2)); + assert_eq!(frame.planes[0], [1000, 1001, 2003, 2004, 2001, 2002, 2005, 2006, 1100, 1101, 2103, 2104, 2101, 2102, 2105, 2106]); + assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]); + assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]); + // 4:2:2 has one luma row per MCU row, so both predictions agree + let enc = fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]); + assert_eq!( + decode_subsampled(&enc, 16, Prediction::Sequential).unwrap().planes, + decode_subsampled(&enc, 16, Prediction::Geometric).unwrap().planes + ); + } + 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 acaacf0b..156dfbc3 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -338,7 +338,7 @@ fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Re .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)?; + let f = ljpeg::decode_subsampled(src, limit, ljpeg::Prediction::Geometric)?; if f.width != c.width as usize || f.height != c.height as usize { return Err(RawError::Corrupt("Sony YCbCr tile dimensions".into())); } diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index d6529ece..4c7522da 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -17,7 +17,9 @@ //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known //! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED //! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first. -//! - sRAW / mRAW (YCbCr, subsampled) are not supported yet. +//! - sRAW / mRAW (raw IFD tag `0xc6c5` = 4) hold subsampled YCbCr instead of a mosaic: see [`sraw`]. + +mod sraw; use super::{black_from_columns, white_from_data}; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; @@ -27,6 +29,8 @@ use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t}; const CR2_SLICE: u16 = 0xc640; const SRAW_TYPE: u16 = 0xc6c5; +/// `SRAW_TYPE` value of the YCbCr frames of sRAW / mRAW (IFD2's preview image carries 3). +const SRAW_YCC: u32 = 4; const CR2_CFA_PATTERN: u16 = 0xc5e0; const SENSOR_INFO: u16 = 0x00e0; const COLOR_BALANCE: u16 = 0x4001; @@ -79,8 +83,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("CR2 without raw IFD".into()))?; - if raw.u32(SRAW_TYPE).is_some_and(|v| v != 1) { - return Err(RawError::Unsupported("Canon sRAW/mRAW".into())); + match raw.u32(SRAW_TYPE) { + Some(SRAW_YCC) => return sraw::decode(bytes, &tiff, raw, mode), + Some(v) if v != 1 => return Err(RawError::Unsupported(format!("Canon raw type {v}"))), + _ => {} } let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(t::STRIP_OFFSETS)))?; let len = raw.u64(t::STRIP_BYTE_COUNTS).unwrap_or(bytes.len() as u64 - off.min(bytes.len() as u64)); diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs new file mode 100644 index 00000000..df17d35b --- /dev/null +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -0,0 +1,580 @@ +//! Canon sRAW / mRAW: reduced-resolution YCbCr "raw" frames in CR2 (raw IFD tag `0xc6c5` = 4). +//! +//! What the files contain (measured on the 31 CC0 sRAW / mRAW samples of 16 models, see the evidence below): +//! +//! - The raw strip is one lossless-JPEG frame with three components. Luma is sampled 2×1 (sRAW, 4:2:2, `0x21`) or +//! 2×2 (mRAW, 4:2:0, `0x22`), both chroma components 1×1; precision 15, predictor 1, no restart markers. +//! The frame's width and height are only a factorisation of the sample count: they are not the image size. +//! - The decoded sample stream (MCU after MCU: `Y Y Cb Cr` or `Y Y Y Y Cb Cr`) is cut into vertical slices exactly +//! like the CFA data (`cr2_slice = [n, w, last]`, widths counted in samples). A slice row is a whole number of +//! MCUs, so the image is `Σw / MCU-size × 2` pixels wide, and one slice row is one pixel row (4:2:2) or two (4:2:0). +//! - Prediction: each component is a one-dimensional chain in decoding order (so the second luma row of a 4:2:0 MCU +//! continues from the end of the first), and the first sample of every frame row starts from the first sample of +//! the previous one ([`ljpeg::Prediction::Sequential`]). The Sony prediction (geometric) gives vertical stripes on +//! these files. +//! - The samples are linear. Luma is the BT.601 luma (0.299, 0.587, 0.114; also the TIFF 6.0 default +//! `YCbCrCoefficients`) of white-balanced camera RGB, about 7/8 of the black-subtracted sensor counts; chroma is +//! centred on 16384. White balance is baked in (neutral surfaces have neutral chroma); which of the maker note's +//! white-balance sets was used is not always the as-shot one, so it is not undone. +//! - Chroma is stored as a scaled colour difference, `R − Y = a·(Cr − 16384)` and `B − Y = b·(Cb − 16384)`, with +//! `G` from the luma equation. `a` and `b` are about 1.0 on bodies from 2012 on, but about 5.7 and 7.4 (the TIFF +//! 6.0 YCbCr ratio 1.402 : 1.772, scaled by about 4.1) on the 2008–2010 bodies (5D Mark II, 50D, 7D, 60D, 1D Mark IV). +//! Rather than keying a table on the model, the gains are measured per file against the 16-bit RGB image the +//! camera stores in IFD2 for these files (see [`chroma_gains`]). +//! +//! Only these files were used as evidence: the sRAW samples themselves, the same cameras' CFA files of the same +//! scenes (raw counts against the decoded samples), and the IFD2 image. The ExifTool tag documentation was used for +//! tag names. + +use super::{COLOR_BALANCE, CR2_SLICE, SENSOR_INFO, words}; +use crate::{BlackLevel, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::image::{Chunk, chunk_bytes}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; +use rayon::prelude::*; + +/// Chroma samples are centred on this value. +const CHROMA_CENTRE: f64 = 16384.0; +/// BT.601 luma weights, the TIFF 6.0 default `YCbCrCoefficients`. +const KR: f64 = 0.299; +const KG: f64 = 0.587; +const KB: f64 = 0.114; +/// Decoded luma relative to black-subtracted sensor counts (white-balanced): 0.877, 0.877, 0.896, 0.880, 0.871, 0.851 +/// and 0.846 on the 7D Mark II, 5DS R, 6D Mark II, 5DS, 5D Mark II, 1D X Mark II and 50D (CFA and sRAW files of the +/// same scene), so 7/8. It sets the white level, so that a file renders about as bright as the CFA file of the same +/// scene. +const LUMA_GAIN: f32 = 0.875; +/// Full scale of a 14-bit sensor, the white level of a CFA CR2 without a saturation plateau. +const SENSOR_FULL: f32 = 16383.0; +/// Mid-tone limit (IFD2 units) of the pixels used for the chroma fit: above about a quarter of full scale the +/// camera's own RGB image rolls off. +const FIT_LIMIT: f64 = 4096.0; + +/// How the decoded frame maps to the image. +#[derive(Debug, PartialEq)] +pub(super) struct Layout { + pub width: usize, + pub height: usize, + /// Vertical subsampling of the chroma planes: 1 (4:2:2, sRAW) or 2 (4:2:0, mRAW). + pub vertical: usize, + /// Slice widths in samples. + widths: Vec, + /// Slice height in MCU rows. + rows: usize, + /// Samples per MCU. + mcu: usize, + /// Frame size from the SOF3 header. + frame_width: usize, + frame_height: usize, +} + +impl Layout { + /// The layout of a `frame_width × frame_height` frame with `vertical` chroma subsampling and `cr2_slice` tag + /// values `slices` (`[n, w, last]`). + pub(super) fn new(frame_width: usize, frame_height: usize, vertical: usize, slices: &[u64]) -> Result { + let &[n, w, last] = slices else { return Err(RawError::Corrupt("sRAW without slice layout".into())) }; + if n > 64 || w == 0 || last == 0 { + return Err(RawError::Corrupt("sRAW slice layout".into())); + } + let mcu = 2 * vertical + 2; + let mut widths = vec![w as usize; n as usize]; + widths.push(last as usize); + if widths.iter().any(|w| w % mcu != 0) { + return Err(RawError::Corrupt("sRAW slice is not a whole number of MCUs".into())); + } + let samples = + frame_width.checked_mul(frame_height).and_then(|p| p.checked_add(p / vertical)).ok_or(RawError::Limit("sRAW frame too large"))?; + let row: usize = widths.iter().sum(); + if samples == 0 || samples % row != 0 { + return Err(RawError::Corrupt(format!("sRAW slices ({row} samples per row) do not divide the frame ({samples} samples)"))); + } + let rows = samples / row; + let (width, height) = (row / mcu * 2, rows * vertical); + if width.checked_mul(height).and_then(|p| p.checked_mul(3)).is_none_or(|s| s > crate::MAX_SAMPLES) { + return Err(RawError::Limit("sRAW image too large")); + } + Ok(Layout { width, height, vertical, widths, rows, mcu, frame_width, frame_height }) + } +} + +/// Planar YCbCr at image geometry: luma `width × height`, chroma `width/2 × height/vertical`. +struct Planes { + width: usize, + height: usize, + vertical: usize, + y: Vec, + cb: Vec, + cr: Vec, +} + +impl Planes { + fn chroma_width(&self) -> usize { + self.width / 2 + } + + /// Re-chop the decoded MCU stream into image planes. + fn arrange(frame: &ljpeg::FrameSubsampled, layout: &Layout) -> Planes { + let (w, h, v, m) = (layout.width, layout.height, layout.vertical, layout.mcu); + let cw = w / 2; + let mut p = Planes { width: w, height: h, vertical: v, y: vec![0; w * h], cb: vec![0; cw * (h / v)], cr: vec![0; cw * (h / v)] }; + let mcus_per_frame_row = layout.frame_width / 2; + let mut x0 = 0; + for &sw in &layout.widths { + let per_row = sw / m; + let first = layout.rows * (x0 / m); + for r in 0..layout.rows { + for k in 0..per_row { + let q = first + r * per_row + k; + let (qy, qx) = (q / mcus_per_frame_row, q % mcus_per_frame_row); + let px = (x0 / m + k) * 2; + for dy in 0..v { + let src = (qy * v + dy) * layout.frame_width + qx * 2; + let dst = (r * v + dy) * w + px; + p.y[dst..dst + 2].copy_from_slice(&frame.planes[0][src..src + 2]); + } + p.cb[r * cw + px / 2] = frame.planes[1][qy * mcus_per_frame_row + qx]; + p.cr[r * cw + px / 2] = frame.planes[2][qy * mcus_per_frame_row + qx]; + } + } + x0 += sw; + } + p + } + + /// Mean luma / Cb / Cr over the pixel window `[x0, x1) × [y0, y1)` (non-empty, inside the image). + fn window_mean(&self, x0: usize, x1: usize, y0: usize, y1: usize) -> (f64, f64, f64) { + let (mut sy, mut ny) = (0f64, 0f64); + for y in y0..y1 { + let row = &self.y[y * self.width + x0..y * self.width + x1]; + sy += row.iter().map(|&v| f64::from(v)).sum::(); + ny += row.len() as f64; + } + let (cx0, cx1) = (x0 / 2, x1.div_ceil(2).max(x0 / 2 + 1)); + let (cy0, cy1) = (y0 / self.vertical, y1.div_ceil(self.vertical).max(y0 / self.vertical + 1)); + let (mut sb, mut sr, mut nc) = (0f64, 0f64, 0f64); + for y in cy0..cy1.min(self.height / self.vertical) { + for x in cx0..cx1.min(self.chroma_width()) { + sb += f64::from(self.cb[y * self.chroma_width() + x]); + sr += f64::from(self.cr[y * self.chroma_width() + x]); + nc += 1.0; + } + } + let nc = nc.max(1.0); + (sy / ny.max(1.0), sb / nc - CHROMA_CENTRE, sr / nc - CHROMA_CENTRE) + } + + /// Linear RGB (`width × height × 3`): `R = Y + a·Cr`, `B = Y + b·Cb`, `G` from the luma equation. + fn to_rgb(&self, a: f64, b: f64) -> Vec { + let (gr, gb) = ((KR / KG * a) as f32, (KB / KG * b) as f32); + let (a, b) = (a as f32, b as f32); + let cw = self.chroma_width(); + let centre = CHROMA_CENTRE as f32; + let mut out = vec![0u16; self.width * self.height * 3]; + out.par_chunks_mut(self.width * 3).enumerate().for_each(|(y, row)| { + let luma = &self.y[y * self.width..(y + 1) * self.width]; + let chroma = (y / self.vertical) * cw; + for (x, px) in row.as_chunks_mut::<3>().0.iter_mut().enumerate() { + let (cb, cr) = (f32::from(self.cb[chroma + x / 2]) - centre, f32::from(self.cr[chroma + x / 2]) - centre); + let l = f32::from(luma[x]); + px[0] = (l + a * cr).round().clamp(0.0, 65535.0) as u16; + px[1] = (l - gr * cr - gb * cb).round().clamp(0.0, 65535.0) as u16; + px[2] = (l + b * cb).round().clamp(0.0, 65535.0) as u16; + } + }); + out + } +} + +/// The 16-bit RGB image in IFD2 (`SRawType` 3): the camera's own rendering of the same frame at a fraction of the +/// size, white-balanced and linear below about a quarter of full scale. +struct Preview { + width: usize, + height: usize, + rgb: Vec, +} + +fn read_preview(bytes: &[u8], tiff: &Tiff) -> Option { + let info = tiff.ifds.get(2)?.image().ok()?; + let (w, h) = (info.width as usize, info.height as usize); + if info.samples_per_pixel != 3 || info.bits_per_sample != [16, 16, 16] || info.compression != 1 || info.planar != 1 || info.sample_format != 1 { + return None; + } + let n = w.checked_mul(h)?.checked_mul(3)?; + if n == 0 || n > 1 << 24 { + return None; + } + let mut raw: Vec = Vec::with_capacity(n * 2); + for c in info.chunks(bytes.len() as u64) { + raw.extend_from_slice(chunk_bytes(bytes, &c)?); + if raw.len() >= n * 2 { + break; + } + } + if raw.len() < n * 2 { + return None; + } + let order: ByteOrder = tiff.order; + let rgb = raw.as_chunks::<2>().0.iter().take(n).map(|&c| order.u16(c)).collect(); + Some(Preview { width: w, height: h, rgb }) +} + +#[derive(Default)] +struct Regression { + n: f64, + sx: f64, + st: f64, + sxx: f64, + sxt: f64, + stt: f64, +} + +impl Regression { + fn add(&mut self, x: f64, t: f64) { + self.n += 1.0; + self.sx += x; + self.st += t; + self.sxx += x * x; + self.sxt += x * t; + self.stt += t * t; + } + + /// Least-squares slope of `t` on `x` (with intercept) and its residual sum of squares. + fn slope(&self) -> Option<(f64, f64)> { + let var = self.n * self.sxx - self.sx * self.sx; + if self.n < 500.0 || var <= 1e-9 * self.n * self.n { + return None; + } + let slope = (self.n * self.sxt - self.sx * self.st) / var; + let icpt = (self.st - slope * self.sx) / self.n; + let rss = + self.stt - 2.0 * slope * self.sxt - 2.0 * icpt * self.st + slope * slope * self.sxx + 2.0 * slope * icpt * self.sx + self.n * icpt * icpt; + Some((slope, rss.max(0.0))) + } +} + +/// How the IFD2 image sits on the full-size planes: its pixel `(i, j)` covers the window starting at +/// `(x0 + i·sx, y0 + j·sy)` and `sx × sy` pixels large. +struct Grid { + x0: f64, + y0: f64, + sx: f64, + sy: f64, +} + +/// The chroma gains `(a, b)` of this file (`R − Y = a·Cr`, `B − Y = b·Cb`), by least squares of the IFD2 image +/// against the planes averaged to its size. Where IFD2 sits on the frame depends on the model (the whole frame, +/// the sensor's active area, or a whole or half-integer reduction of the frame that overhangs it by a few pixels), +/// so these are tried and the best fit is kept. `None` when the file has no usable IFD2 or the fit is not well +/// conditioned. +fn chroma_gains(planes: &Planes, preview: &Preview, active: Rect) -> Option<(f64, f64)> { + let (pw, ph) = (preview.width as f64, preview.height as f64); + let (w, h) = (planes.width as f64, planes.height as f64); + let grids = [ + Grid { x0: 0.0, y0: 0.0, sx: w / pw, sy: h / ph }, + Grid { x0: active.x as f64, y0: active.y as f64, sx: active.width as f64 / pw, sy: active.height as f64 / ph }, + Grid { x0: 0.0, y0: 0.0, sx: (2.0 * w / pw).round() / 2.0, sy: (2.0 * h / ph).round() / 2.0 }, + ]; + let mut best: Option<(f64, f64, f64)> = None; + for g in &grids { + if !(g.sx >= 1.0 && g.sy >= 1.0) { + continue; + } + let (mut fr, mut fb) = (Regression::default(), Regression::default()); + for j in 0..preview.height { + let y0 = (g.y0 + j as f64 * g.sy) as usize; + let y1 = ((g.y0 + (j + 1) as f64 * g.sy) as usize).min(planes.height); + for i in 0..preview.width { + let p = &preview.rgb[(j * preview.width + i) * 3..][..3]; + let [r, gr, b] = [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]; + if r.max(gr).max(b) >= FIT_LIMIT || r.min(gr).min(b) <= 0.0 { + continue; + } + let x0 = (g.x0 + i as f64 * g.sx) as usize; + let x1 = ((g.x0 + (i + 1) as f64 * g.sx) as usize).min(planes.width); + if x1 <= x0 || y1 <= y0 { + continue; + } + let (y, cb, cr) = planes.window_mean(x0, x1, y0, y1); + if y < 150.0 { + continue; + } + fr.add(cr, r - y); + fb.add(cb, b - y); + } + } + if let (Some((a, ra)), Some((b, rb))) = (fr.slope(), fb.slope()) { + let cost = (ra + rb) / (fr.n + fb.n); + if a.is_finite() && b.is_finite() && best.is_none_or(|(_, _, c)| cost < c) { + best = Some((a, b, cost)); + } + } + } + best.map(|(a, b, _)| (a, b)).filter(|(a, b)| (0.25..=16.0).contains(a) && (0.25..=16.0).contains(b)) +} + +/// Word index of `AverageBlackLevel` (four equal-ish values) in the maker note's `ColorData` by `ColorDataVersion` +/// (word 0), located by searching the CC0 samples for the values ExifTool reports. +fn average_black_at(version: i16) -> Option { + Some(match version { + 3 => 163, + 6 | 7 | 9 => 231, + 10 | 11 => 276, + 12 | 13 | 15 => 326, + _ => return None, + }) +} + +/// The sensor's black level in raw counts and the black pedestal carried by the luma samples, from `ColorData`. +/// The pedestal is 0 on every sample but the 40D's (`ColorDataVersion` 3), whose luma floor is 512 in the file (the +/// darkest pixels read 524–544, and the maker note repeats 512 in words 663–673). +fn colour_data_levels(words: &[u64]) -> (Option, f32) { + let version = words.first().map_or(0, |&v| v as u16 as i16); + let pedestal = if version == 3 { 512.0 } else { 0.0 }; + let black = average_black_at(version).and_then(|at| words.get(at..at + 4)).and_then(|q| { + let (lo, hi) = (q.iter().min()?, q.iter().max()?); + ((256..=4096).contains(lo) && hi - lo <= 8).then(|| q.iter().sum::() as f32 / 4.0) + }); + (black, pedestal) +} + +/// Decode a Canon sRAW / mRAW CR2 into linear white-balanced camera RGB (`cpp` 3). +pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result { + let ifd0 = &tiff.ifds[0]; + let off = raw.u64(t::STRIP_OFFSETS).ok_or(RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(t::STRIP_OFFSETS)))?; + let len = raw.u64(t::STRIP_BYTE_COUNTS).unwrap_or(bytes.len() as u64 - off.min(bytes.len() as u64)); + let chunk = Chunk { index: 0, x: 0, y: 0, width: 0, height: 0, plane: 0, offset: off, len }; + let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; + let (fw, fh, vertical) = ljpeg::frame_info_subsampled(src)?; + let slices = raw.u64s(CR2_SLICE).unwrap_or_default(); + let layout = Layout::new(fw, fh, vertical, &slices)?; + + let make = ifd0.string(t::MAKE).unwrap_or_default(); + let mn = + tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); + let (w, h) = (layout.width, layout.height); + let mut active = Rect::new(0, 0, w, h); + if let Some(si) = mn.as_ref().and_then(|m| m.ifd.u64s(SENSOR_INFO)).filter(|v| v.len() >= 9) { + let (l, tp, r, b) = (si[5] as usize, si[6] as usize, si[7] as usize, si[8] as usize); + if r > l && b > tp && r < w && b < h { + active = Rect::new(l, tp, r - l + 1, b - tp + 1); + } + } + let colour_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))); + let (black_counts, pedestal) = colour_data.as_deref().map_or((None, 0.0), colour_data_levels); + // 1.0 = a CFA CR2 of the same body at full 14-bit scale; see LUMA_GAIN + let white = pedestal + LUMA_GAIN * (SENSOR_FULL - black_counts.unwrap_or(0.0)); + + let data = match mode { + Mode::Header => Vec::new(), + Mode::Full => { + let samples = fw * fh + fw * fh / vertical; + let frame = ljpeg::decode_subsampled(src, samples.min(crate::MAX_SAMPLES), ljpeg::Prediction::Sequential)?; + let planes = Planes::arrange(&frame, &layout); + drop(frame); + let (a, b) = read_preview(bytes, tiff).and_then(|p| chroma_gains(&planes, &p, active)).unwrap_or((1.0, 1.0)); + planes.to_rgb(a, b) + } + }; + + let mut metadata = lightcraft_meta::from_tiff(tiff); + metadata.width = Some(active.width as u32); + metadata.height = Some(active.height as u32); + let img = RawImage { + format: RawFormat::Cr2, + width: w, + height: h, + cpp: 3, + data: RawData::U16(data), + cfa: None, + bits: 14, + black: BlackLevel::uniform(pedestal), + white: vec![white], + active_area: active, + crop: Rect::new(0, 0, active.width, active.height), + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + color: ColorData::default(), + // the camera's white balance is already applied, as in Sony's linear YCbCr + wb_multipliers: Some([1.0; 3]), + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + + const SRAW_TYPE_TAG: u16 = 0xc6c5; + + /// The 4:2:2 stream of `ljpeg`'s hand-coded fixture (4 × 2 frame, two MCUs per frame row) decodes to luma + /// 1000 1001 / 1002 1003 / 1100 1101 / 1102 1103 in MCU order, Cb 16384 16385 16484 16485, Cr 16484 16486 + /// 16584 16586. + fn stream_422() -> Vec { + ljpeg::tests::fixture_subsampled(4, 2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]) + } + + /// A CR2 whose raw IFD holds `stream` cut into the slices `slices` (IFD2 = a 2 × 2 16-bit RGB image when + /// `preview` is given). + fn sraw_file(stream: Vec, slices: [u16; 3], sraw_type: u32, preview: Option<[u16; 12]>) -> Vec { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Canon".into())); + ifd0.set(t::MODEL, Value::Ascii("Canon EOS Test".into())); + let mut ifd2 = IfdBuilder::new(); + if let Some(p) = preview { + ifd2.set(t::COMPRESSION, Value::Short(vec![1])); + ifd2.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd2.set(t::IMAGE_WIDTH, Value::Long(vec![2])); + ifd2.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd2.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd2.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd2.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![p.iter().flat_map(|v| v.to_le_bytes()).collect()] }); + } else { + ifd2.set(1, Value::Short(vec![0])); + } + let mut ifd3 = IfdBuilder::new(); + ifd3.set(t::COMPRESSION, Value::Short(vec![6])); + ifd3.set(SRAW_TYPE_TAG, Value::Long(vec![sraw_type])); + ifd3.set(CR2_SLICE, Value::Short(slices.to_vec())); + ifd3.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![stream] }); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), ifd2, ifd3]).unwrap() + } + + #[test] + fn slices_are_cut_back_into_image_columns_and_converted() { + // slices [1, 4, 4]: two slices of one MCU per row; slice 0 holds MCUs 0 and 1 (image rows 0 and 1), slice 1 + // holds MCUs 2 and 3, so the image is 4 × 2 with luma 1000 1001 1100 1101 / 1002 1003 1102 1103. + let bytes = sraw_file(stream_422(), [1, 4, 4], 4, None); + let img = crate::decode(&bytes).unwrap(); + assert_eq!((img.width, img.height, img.cpp), (4, 2, 3)); + assert!(img.cfa.is_none()); + assert_eq!(img.wb_multipliers, Some([1.0; 3])); + let RawData::U16(px) = &img.data else { panic!("integer data") }; + // no IFD2: chroma gains 1; R = Y + Cr', B = Y + Cb', G from the BT.601 luma + let expect = |y: f64, cb: f64, cr: f64| { + let (r, b) = (y + cr, y + cb); + [r, (y - KR * r - KB * b) / KG, b].map(|v| v.round() as i32) + }; + let cases = [ + ((0, 0), expect(1000.0, 0.0, 100.0)), + ((1, 0), expect(1001.0, 0.0, 100.0)), + ((2, 0), expect(1100.0, 100.0, 200.0)), + ((3, 0), expect(1101.0, 100.0, 200.0)), + ((0, 1), expect(1002.0, 1.0, 102.0)), + ((3, 1), expect(1103.0, 101.0, 202.0)), + ]; + for ((x, y), want) in cases { + let got: Vec = px[(y * 4 + x) * 3..][..3].iter().map(|&v| i32::from(v)).collect(); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "({x},{y}): {got:?} vs {want:?}"); + } + // header-only decoding reports the same image + assert_eq!(crate::probe_info(&bytes).unwrap(), img.info()); + assert!(img.develop(crate::Method::Bilinear).is_ok()); + } + + #[test] + fn a_preview_too_small_to_fit_leaves_the_gains_at_one() { + // IFD2 of 2 × 2 pixels cannot support a fit (it needs hundreds of samples): the file still decodes + let with = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, Some([900; 12]))).unwrap(); + let without = crate::decode(&sraw_file(stream_422(), [1, 4, 4], 4, None)).unwrap(); + assert_eq!(with.data, without.data); + } + + #[test] + fn only_ycbcr_frames_take_this_path() { + // other raw types (3 is the preview image's) stay unsupported, as before + for kind in [0, 2, 3, 5] { + let bytes = sraw_file(stream_422(), [1, 4, 4], kind, None); + assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_))), "type {kind}"); + } + } + + #[test] + fn slice_layouts_must_fit_the_frame() { + let ok = Layout::new(4, 2, 1, &[1, 4, 4]).unwrap(); + assert_eq!((ok.width, ok.height), (4, 2)); + // 4:2:0: six samples per MCU, one slice row is two image rows + let l = Layout::new(8, 4, 2, &[1, 12, 12]).unwrap(); + assert_eq!((l.width, l.height), (8, 4)); + assert!(Layout::new(4, 2, 1, &[1, 3, 5]).is_err()); // not whole MCUs + assert!(Layout::new(4, 2, 1, &[1, 4, 8]).is_err()); // rows do not divide the frame + assert!(Layout::new(4, 2, 1, &[1, 0, 4]).is_err()); + assert!(Layout::new(4, 2, 1, &[4, 4]).is_err()); // not three values + assert!(Layout::new(4, 2, 1, &[65, 4, 4]).is_err()); + let bytes = sraw_file(stream_422(), [1, 4, 8], 4, None); + assert!(crate::decode(&bytes).is_err()); + } + + /// Textured luma and smooth chroma on `w × h` planes. + fn test_planes(w: usize, h: usize, vertical: usize) -> Planes { + let y: Vec = (0..w * h).map(|i| (600 + ((i % w) * 13 + (i / w) * 29) % 1500) as u16).collect(); + let cw = w / 2; + let ch = h / vertical; + let cb = (0..cw * ch).map(|i| (16384 + (i % cw) as i32 * 7 - (i / cw) as i32 * 3 - 150) as u16).collect(); + let cr = (0..cw * ch).map(|i| (16384 + (i / cw) as i32 * 5 + (i % cw) as i32 * 2 - 120) as u16).collect(); + Planes { width: w, height: h, vertical, y, cb, cr } + } + + /// An IFD2 image of `pw × ph` pixels at `scale` per pixel, built the way the files relate it to the planes. + fn preview_of(p: &Planes, pw: usize, ph: usize, scale: usize, a: f64, b: f64, pedestal: f64) -> Preview { + let mut rgb = Vec::new(); + for j in 0..ph { + for i in 0..pw { + let (y, cb, cr) = p.window_mean(i * scale, (i + 1) * scale, j * scale, (j + 1) * scale); + let (r, bl) = (y + a * cr, y + b * cb); + let g = (y - KR * r - KB * bl) / KG; + rgb.extend([r, g, bl].map(|v| (v + pedestal).round().clamp(0.0, 65535.0) as u16)); + } + } + Preview { width: pw, height: ph, rgb } + } + + #[test] + fn chroma_gains_are_measured_against_the_preview() { + for vertical in [1, 2] { + for (a, b) in [(1.0, 1.0), (5.7, 7.4), (1.13, 1.12)] { + let planes = test_planes(160, 96, vertical); + // IFD2 is a plain 1/4 reduction, optionally with a black pedestal (some bodies add one) + for pedestal in [0.0, 520.0] { + let preview = preview_of(&planes, 40, 24, 4, a, b, pedestal); + let (ga, gb) = + chroma_gains(&planes, &preview, Rect::new(0, 0, 160, 96)).unwrap_or_else(|| panic!("no fit for {a}, {b}, {vertical}")); + assert!((ga / a - 1.0).abs() < 0.02 && (gb / b - 1.0).abs() < 0.02, "vertical {vertical}: ({a}, {b}) measured as ({ga}, {gb})"); + } + } + } + // the preview overhangs the frame by a few pixels: the whole-frame scale (4.1) is wrong, 4 is right + let planes = test_planes(164, 96, 1); + let mut preview = preview_of(&planes, 40, 24, 4, 5.7, 7.4, 0.0); + let (ga, gb) = chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).unwrap(); + assert!((ga / 5.7 - 1.0).abs() < 0.02 && (gb / 7.4 - 1.0).abs() < 0.02, "({ga}, {gb})"); + // a preview of something else gives no usable fit rather than a wild gain + preview.rgb.iter_mut().enumerate().for_each(|(i, v)| *v = (1000 + (i * 7919) % 3000) as u16); + assert!( + chroma_gains(&planes, &preview, Rect::new(0, 0, 164, 96)).is_none_or(|(a, b)| (0.25..=16.0).contains(&a) && (0.25..=16.0).contains(&b)) + ); + } + + #[test] + fn colour_data_levels_by_version() { + let mut w = vec![0u64; 400]; + w[0] = 7; + w[231..235].copy_from_slice(&[2048, 2047, 2048, 2048]); + assert_eq!(colour_data_levels(&w), (Some(2047.75), 0.0)); + w[0] = 12; + assert_eq!(colour_data_levels(&w).0, None); // the black level of another version lives elsewhere + w[326..330].copy_from_slice(&[512, 512, 511, 512]); + assert_eq!(colour_data_levels(&w).0, Some(511.75)); + w[326..330].copy_from_slice(&[512, 512, 100, 512]); // implausible + assert_eq!(colour_data_levels(&w).0, None); + w[0] = 3; + assert_eq!(colour_data_levels(&w).1, 512.0); // the 40D's luma floor + w[0] = 65533; // -3 + assert_eq!(colour_data_levels(&w), (None, 0.0)); + assert_eq!(colour_data_levels(&[]), (None, 0.0)); + assert_eq!(colour_data_levels(&[7, 1, 2]), (None, 0.0)); + } +} From d9fd2f8ae0ad375aa4223a5cb79e997202fede22 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 02:02:08 -0400 Subject: [PATCH 011/344] LR-IMP-FORMATS: decode Samsung SRW files with uncompressed raw data Samsung SRW raws whose raw IFD (private compression 32769 or 32770) stores the samples uncompressed are decoded from the sensor data: 12-bit samples packed two in three bytes (bit order told apart from the samples; NX5, NX10, NX11 and NX20, NX200, NX210, NX1000, NX1100) and 16-bit little-endian words (EX1, WB2000). CFA layout from the raw IFD's CFAPattern; framing, white balance, black and saturation levels from the maker note (value offsets relative to the note; the vectors are stored offset by its EncryptionKey); a one-direction Exif picture size crops to the centre of the framing; black from dark rows or columns when the file states none; saturation plateau below the stated level wins. Compressed codings (32770 with fewer bytes, 32772, 32773) stay preview only with that reason. srw is imported from folders; SRW takes the file-local camera look. RENDER_CACHE_VERSION 17. --- README.md | 2 +- ROADMAP.md | 8 +- crates/catalog/src/model.rs | 5 +- crates/engine/src/camera_preview.rs | 22 +- crates/engine/src/import.rs | 4 +- crates/engine/src/media.rs | 2 +- crates/mcp/src/headless.rs | 4 +- crates/raw/src/lib.rs | 2 + crates/raw/src/vendor/mod.rs | 1 + crates/raw/src/vendor/srw.rs | 541 ++++++++++++++++++++++++++++ crates/raw/tests/corpus.rs | 58 +++ docs/camera-preview-colour.md | 7 +- docs/parity.md | 4 +- docs/whats-new.md | 3 + xtask/src/assets.rs | 4 +- xtask/src/main.rs | 4 + 16 files changed, 652 insertions(+), 19 deletions(-) create mode 100644 crates/raw/src/vendor/srw.rs diff --git a/README.md b/README.md index e93683c6..bca63f8a 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | | Camera colour: DNG files use their own matrices | ✅ DNG · 🟡 own Sony/Fujifilm profiles; measured calibration database missing | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | -| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF decode ⬜ | +| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ | | Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | | Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | | Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ | diff --git a/ROADMAP.md b/ROADMAP.md index 4a6b101a..abd1a2d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| | **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | -| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | +| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | | **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | @@ -47,8 +47,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*. 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use - matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 - and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet + matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, + Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet checked on the corpus); then ORF, then validate fidelity. 2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, @@ -188,7 +188,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis). - **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**. -**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged +**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged `matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 957f5b68..b2a1a696 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -336,13 +336,13 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera + /// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). pub fn relative_wb(&self) -> bool { self.develops_raw() - && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) + && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). @@ -450,6 +450,7 @@ mod edited_tests { ("a.raw", "RAW", true), ("a.raf", "rAf", true), ("a.cr3", "cr3", true), + ("a.srw", "SRW", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 55ea2d80..fb1f37c4 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -33,7 +33,15 @@ pub(crate) const PROFILE_PROXY: usize = 192; pub(crate) fn file_local_look(format: RawFormat) -> bool { matches!( format, - RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef + RawFormat::Arw + | RawFormat::Nef + | RawFormat::Nrw + | RawFormat::Rw2 + | RawFormat::Raf + | RawFormat::Cr3 + | RawFormat::Cr2 + | RawFormat::Pef + | RawFormat::Srw ) } @@ -1021,7 +1029,17 @@ mod tests { #[test] fn supported_raws_get_a_file_local_look() { // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) - let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef]; + let local = [ + RawFormat::Arw, + RawFormat::Nef, + RawFormat::Nrw, + RawFormat::Rw2, + RawFormat::Raf, + RawFormat::Cr3, + RawFormat::Cr2, + RawFormat::Pef, + RawFormat::Srw, + ]; assert!(local.into_iter().all(file_local_look)); // DNG carries its own colour model assert!(!file_local_look(RawFormat::Dng)); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 282d93b7..b66ee737 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -32,8 +32,8 @@ use crate::media::ProbeInfo; /// File extensions LightCraft imports (lower case). pub const EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", - "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", + "jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", ]; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b0439390..4519e8f9 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 = 16; +pub const RENDER_CACHE_VERSION: u64 = 17; /// 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/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 837302d6..5bb87f6f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -12,8 +12,8 @@ use crate::backend::Backend; /// File extensions recognised as photos when expanding folders. pub const PHOTO_EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif", - "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl", + "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", ]; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d38b2fc6..6fac33f8 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -114,6 +114,7 @@ impl RawFormat { | RawFormat::Raf | RawFormat::Rw2 | RawFormat::Pef + | RawFormat::Srw ) } } @@ -305,6 +306,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), + RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index fbbc18eb..81212681 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,6 +12,7 @@ pub mod pef; pub mod raf; mod rafc; pub mod rw2; +pub mod srw; use crate::{BlackLevel, Rect}; use std::ops::Range; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs new file mode 100644 index 00000000..f64b451f --- /dev/null +++ b/crates/raw/src/vendor/srw.rs @@ -0,0 +1,541 @@ +//! Samsung SRW: the variants that store the sensor samples uncompressed. +//! +//! Sources: TIFF 6.0 (container: a big- or little-endian TIFF whose IFD0 points at sub-IFDs, one of them the raw +//! image), Exif 2.3 (`CFAPattern`, `PixelXDimension`, `PixelYDimension`; maker note `0x927c`), the ExifTool Samsung +//! tag-name documentation (`SensorAreas` `0xa010`, `EncryptionKey` `0xa020`, `WB_RGGBLevelsUncorrected` `0xa021`, +//! `WB_RGGBLevelsBlack` `0xa028`, `RawData` `0xa048`) and our own black-box analysis of CC0 samples from raw.pixls.us +//! (NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1, WB2000; every number below was measured on them): +//! +//! - The raw image is the IFD whose `Compression` is a private value (32769..=32773); its `CFAPattern` (`0x828e`) +//! names the 2x2 layout, anchored at the first sample of the stored array. Checked on the files above: the colours +//! of the decoded mosaic follow the camera's JPEG best with exactly that layout (`corpus_samsung_srw`). +//! - Compression 32769 (EX1, WB2000): one 16-bit little-endian word per sample (the TIFF is big-endian, the words are +//! not); the strip holds exactly `width * height * 2` bytes. +//! - Compression 32770 with exactly `width * height * 12 / 8` strip bytes (NX5, NX10, NX11, NX20, NX200, NX210, NX1000, +//! NX1100): 12-bit samples, two in three bytes, rows without padding. The NX5/NX10/NX11 files read them MSB-first, +//! the others LSB-first; the file doesn't say which, the image does: in the wrong order neighbouring same-colour +//! samples differ 5 to 11 times more (10 files, every one decided by that margin), see [`lsb_is_smoother`]. +//! - Every other shape of those and of the other private values (32770 with fewer bytes: NX30, NX300, NX2000, +//! EK-GN120; 32772: NX mini, NX3000, NX3300; 32773: NX1, NX500) is a compressed coding: reported as unsupported. +//! - The maker note is a plain IFD whose value offsets count from the start of the note, not of the TIFF. Several +//! vectors are stored offset by `EncryptionKey`, 11 integers in every file (element `i` of a vector uses key +//! `i mod 11`): `WB_RGGBLevelsUncorrected` and `WB_RGGBLevelsBlack` hold `value + key`, `RawData` holds +//! `value - key`, found by trying both signs and keeping the one that gives plausible numbers (green levels +//! 4096 in all but one file, saturation levels 4095 or 16383, black levels 0 to 3, the dark-column level). +//! - `SensorAreas`: the second rectangle (left, top, right, bottom) is the camera JPEG's framing (its size equals the +//! JPEG's in every file but WB2000's), used as the active area. Registered against the JPEG its centre agrees to +//! within 2 pixels (NX10 family; the NX20 family's JPEG is lens-corrected, edges differ). +//! - The Exif picture size is smaller than the framing in one direction only on the WB2000 (16:9 from a 4:3 sensor): +//! the JPEG is the centre of the framing (registration: top offset 366 = framing top 22 + (2736 - 2048) / 2). +//! - `RawData`: four black levels, then saturation levels (R, G1, G2, B twice). The EX1 saturates at 15881, not at its +//! stated 16383 (85786 samples sit on the plateau), so a plateau below the stated level wins. +//! - Black: `WB_RGGBLevelsBlack` when it states one (NX5, NX10, NX11: 2 to 3, the right-hand dark columns measure +//! 3.5 to 4.5); otherwise the dark rows or columns outside the framing (EX1: eight rows at 2.3); otherwise 0. +//! - `WB_RGGBLevelsUncorrected` divided by its mean green is the as-shot gain: the renders of the 12 files match the +//! camera JPEG at median ΔE76 2.2 to 7.6 (10.7 on the backlit WB2000 sample, 5.7 after registration). +use super::{black_from_columns, white_from_data}; +use crate::unpack::{read_u16s, unpack_lsb, unpack_msb}; +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::{ByteOrder, Ifd, Tiff, tags as t}; +use rayon::prelude::*; + +const SENSOR_AREAS: u16 = 0xa010; +const ENCRYPTION_KEY: u16 = 0xa020; +const WB_UNCORRECTED: u16 = 0xa021; +const WB_BLACK: u16 = 0xa028; +const RAW_DATA: u16 = 0xa048; + +/// The private `Compression` values of the raw image IFD. +const COMPRESSIONS: std::ops::RangeInclusive = 32769..=32773; + +/// The maker-note IFD (offsets relative to the note's own start). +fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option { + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let opts = lightcraft_tiff::ParseOptions { max_ifds: 4, max_depth: 1, follow_children: false, ..Default::default() }; + lightcraft_tiff::parse_ifd_at(bytes, e.offset, tiff.order, e.offset, false, &opts).ok().map(|(i, _)| i) +} + +/// A key-offset vector: the entries of `tag` shifted by the maker note's key (`sign` is +1 or -1 times the key). +fn keyed(mn: &Ifd, tag: u16, sign: i64) -> Option> { + let key = mn.u64s(ENCRYPTION_KEY).filter(|k| k.len() == 11)?; + let vals = mn.f64s(tag)?; + // 32-bit entries: the stored value wraps modulo 2^32 (a negative number stored as a LONG) + Some(vals.iter().enumerate().map(|(i, &v)| i64::from((v as i64).wrapping_add(sign * key[i % 11] as i64) as i32)).collect()) +} + +/// The raw image's IFD: the one with a private compression value. +fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { + tiff.all_ifds().into_iter().find(|i| i.u16(t::COMPRESSION).is_some_and(|c| COMPRESSIONS.contains(&c)) && i.contains(t::IMAGE_WIDTH)) +} + +/// How the samples of an IFD are stored, when uncompressed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Coding { + /// One little-endian 16-bit word per sample. + Words, + /// 12-bit samples, two in three bytes: read MSB-first (the NX5, NX10 and NX11 files) or LSB-first (the later + /// bodies' files); the file does not say which, the smoother image does ([`lsb_is_smoother`]). + Packed12, +} + +fn coding(compression: u16, w: usize, h: usize, strip: u64) -> Option { + let n = (w as u64).checked_mul(h as u64)?; + match compression { + 32769 if strip == n.checked_mul(2)? => Some(Coding::Words), + 32770 if w.is_multiple_of(2) && strip == n.checked_mul(12)? / 8 => Some(Coding::Packed12), + _ => None, + } +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("SRW without IFDs".into()))?; + let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("Samsung raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + 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 strip: u64 = chunks.iter().map(|c| c.len).sum(); + let Some(coding) = coding(info.compression, w, h, strip).filter(|_| chunks.len() == 1) else { + return Err(RawError::Unsupported(format!( + "Samsung compressed raw (compression {}, {:.2} stored bits per sample)", + info.compression, + strip as f64 * 8.0 / n as f64 + ))); + }; + let data = if mode == Mode::Full { + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("SRW strip outside file".into()))?; + let mut d = vec![0u16; n]; + match coding { + Coding::Words => read_u16s(src, ByteOrder::Little, &mut d), + Coding::Packed12 => { + let stride = w / 2 * 3; + let unpack: fn(&[u8], u32, &mut [u16]) = if lsb_is_smoother(src, w, h) { unpack_lsb } else { unpack_msb }; + d.par_chunks_mut(w).zip(src.par_chunks(stride)).for_each(|(row, s)| unpack(s, 12, row)); + } + } + RawData::U16(d) + } else { + RawData::U16(Vec::new()) + }; + + let mn = maker_note(bytes, &tiff); + // the camera JPEG's framing (left, top, right, bottom), when it lies inside the stored array + let active = mn + .as_ref() + .and_then(|m| m.u64s(SENSOR_AREAS)) + .filter(|v| v.len() == 8) + .map(|v| (v[4] as usize, v[5] as usize, v[6] as usize, v[7] as usize)) + .filter(|&(l, tp, r, b)| l < r && tp < b && r <= w && b <= h) + .map_or(Rect::new(0, 0, w, h), |(l, tp, r, b)| Rect::new(l, tp, r - l, b - tp)); + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Samsung raw without a 2x2 CFAPattern".into())), + }; + // saturation levels and black levels (R, G1, G2, B), in the file's own words + let levels = mn.as_ref().and_then(|m| keyed(m, RAW_DATA, 1)).filter(|v| v.len() == 12); + let RawData::U16(samples) = &data else { return Err(RawError::Unsupported("float SRW".into())) }; + // saturation: the file's level, or lower where the samples pile up (EX1 clips at 15882 of its stated 16383) + let stated = levels.as_ref().filter(|v| v[4..].iter().all(|&x| x == v[4]) && (255..=65535).contains(&v[4])).map(|v| v[4] as f32); + let bits = stated.map_or((info.bits() as u32).clamp(8, 16), |s| 32 - (s as u32).leading_zeros()); + let plateau = (!samples.is_empty()).then(|| white_from_data(&active_rows(samples, w, active), bits)); + let white = match (stated, plateau) { + (Some(s), Some(p)) => s.min(p), + (Some(s), None) => s, + (None, Some(p)) => p, + (None, None) => 4095.0, + }; + let tagged_black = + mn.as_ref().and_then(|m| keyed(m, WB_BLACK, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| (0..=u16::MAX as i64).contains(&x))); + let black = match tagged_black { + Some(v) if v.iter().any(|&x| x > 0) => { + // RGGB-ordered levels -> per CFA position at the active area origin + let a = cfa.shifted(active.x, active.y); + let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2, v[3]][c as usize] as f32).collect(); + BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() } + } + // the file states no black level: measure the masked band around the active area, when there is one + _ => masked_black(samples, w, h, active, white), + }; + // as-shot white balance: gains with the greens near 4096 (unity) + let wb = mn.as_ref().and_then(|m| keyed(m, WB_UNCORRECTED, -1)).filter(|v| v.len() == 4 && v.iter().all(|&x| x > 0)).map(|v| { + let g = (v[1] + v[2]) as f64 / 2.0; + [(v[0] as f64 / g) as f32, 1.0, (v[3] as f64 / g) as f32] + }); + let mut metadata = lightcraft_meta::from_tiff(&tiff); + let crop = picture_crop(&tiff, active); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let img = RawImage { + format: RawFormat::Srw, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits, + black, + white: vec![white], + active_area: active, + crop, + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + img.validate_for(mode)?; + Ok(img) +} + +/// Which bit order unpacks 12-bit samples of `w` x `h` (in `src`, `w / 2 * 3` bytes per row) into the image: in +/// the right order neighbouring samples of one colour differ by little, in the wrong one by about the whole range. +/// Compares the summed absolute differences of samples two apart over 64 rows from the middle of the frame. +fn lsb_is_smoother(src: &[u8], w: usize, h: usize) -> bool { + let stride = w / 2 * 3; + let (mut msb, mut lsb) = (0u64, 0u64); + let (mut a, mut b) = (vec![0u16; w], vec![0u16; w]); + for y in (h / 5..h * 4 / 5).step_by((h * 3 / 5 / 64).max(1)) { + let Some(row) = y.checked_mul(stride).and_then(|s| src.get(s..s + stride)) else { break }; + unpack_msb(row, 12, &mut a); + unpack_lsb(row, 12, &mut b); + let spread = |v: &[u16]| v.windows(3).map(|p| u64::from(p[0].abs_diff(p[2]))).sum::(); + msb += spread(&a); + lsb += spread(&b); + } + lsb < msb +} + +/// The default crop: the whole framing, or, when the Exif picture size is the framing cut in one direction only +/// (a 16:9 picture from a 4:3 sensor), the centred part of that size. +fn picture_crop(tiff: &Tiff, active: Rect) -> Rect { + let whole = Rect::new(0, 0, active.width, active.height); + let Some(exif) = tiff.exif() else { return whole }; + let (Some(pw), Some(ph)) = (exif.u32(t::PIXEL_X_DIMENSION), exif.u32(t::PIXEL_Y_DIMENSION)) else { return whole }; + let (pw, ph) = (pw as usize, ph as usize); + if pw == 0 || ph == 0 || pw > active.width || ph > active.height || (pw < active.width && ph < active.height) { + return whole; + } + Rect::new((active.width - pw) / 2, (active.height - ph) / 2, pw, ph) +} + +/// Every third row of the active area (the saturation statistics need no more). +fn active_rows(d: &[u16], w: usize, a: Rect) -> Vec { + (a.y..a.y + a.height).step_by(3).filter_map(|y| d.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() +} + +/// Black level from rows or columns outside the active area that are dark (mean below 1.5% of `white`): the +/// longest run of dark lines on the right, else the left, below, above; at least four lines, the two outermost +/// rows and columns of the array left out. Uniform 0 when there is no such run. +fn masked_black(d: &[u16], w: usize, h: usize, a: Rect, white: f32) -> BlackLevel { + if d.is_empty() { + return BlackLevel::uniform(0.0); + } + let limit = f64::from(white) * 0.015; + let col_mean = |x: usize| { + (a.y..a.y + a.height).step_by(7).filter_map(|y| d.get(y * w + x)).map(|&v| f64::from(v)).sum::() / a.height.div_ceil(7) as f64 + }; + let row_mean = + |y: usize| (a.x..a.x + a.width).step_by(7).filter_map(|x| d.get(y * w + x)).map(|&v| f64::from(v)).sum::() / a.width.div_ceil(7) as f64; + // the longest run of consecutive dark lines within `lines` + let longest = |lines: std::ops::Range, mean: &dyn Fn(usize) -> f64| { + let (mut best, mut start) = (0..0, lines.start); + for i in lines.clone() { + if mean(i) >= limit { + start = i + 1; + } else if i + 1 - start > best.len() { + best = start..i + 1; + } + } + best + }; + let cols = a.y..a.y + a.height; + let rows = a.x..a.x + a.width; + let right = longest(a.x + a.width..w.saturating_sub(2), &col_mean); + let left = longest(2..a.x, &col_mean); + let below = longest(a.y + a.height..h.saturating_sub(2), &row_mean); + let above = longest(2..a.y, &row_mean); + if right.len() >= 4 { + black_from_columns(d, w, right, cols, a) + } else if left.len() >= 4 { + black_from_columns(d, w, left, cols, a) + } else if below.len() >= 4 { + black_from_columns(d, w, rows, below, a) + } else if above.len() >= 4 { + black_from_columns(d, w, rows, above, a) + } else { + BlackLevel::uniform(0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{decode, probe, probe_info}; + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + + const KEY: [u32; 11] = [305, 72, 737, 456, 282, 307, 519, 724, 13, 505, 193]; + + /// A maker note in the Samsung layout: a big-endian IFD of LONG vectors, offsets relative to the note's start. + fn note(entries: &[(u16, Vec)]) -> Vec { + let mut head = Vec::new(); + let mut tail = Vec::new(); + head.extend_from_slice(&(entries.len() as u16).to_be_bytes()); + let values_at = 2 + 12 * entries.len() + 4; + for (tag, v) in entries { + head.extend_from_slice(&tag.to_be_bytes()); + head.extend_from_slice(&4u16.to_be_bytes()); + head.extend_from_slice(&(v.len() as u32).to_be_bytes()); + if v.len() == 1 { + head.extend_from_slice(&v[0].to_be_bytes()); + } else { + head.extend_from_slice(&((values_at + tail.len()) as u32).to_be_bytes()); + tail.extend(v.iter().flat_map(|x| x.to_be_bytes())); + } + } + head.extend_from_slice(&[0; 4]); + head.extend(tail); + head + } + + /// Stored form of a vector the way the file keeps it: `plain + key` (`sign` 1) or `plain - key` (`sign` -1). + fn stored(plain: &[i64], sign: i64) -> Vec { + plain.iter().enumerate().map(|(i, &p)| (p + sign * i64::from(KEY[i % 11])) as u32).collect() + } + + fn pack12(v: &[u16]) -> Vec { + v.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4) as u8 | (p[1] >> 8) as u8, p[1] as u8]).collect() + } + + fn le_words(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() + } + + struct Spec { + w: u32, + h: u32, + compression: u16, + strip: Vec, + pattern: Option<[u8; 4]>, + note: Option>, + picture: Option<(u32, u32)>, + } + + fn srw(s: Spec) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![s.w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![s.h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![s.compression])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + if let Some(p) = s.pattern { + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(p.to_vec())); + } + raw.set_image(ImageData::Strips { rows_per_strip: s.h, strips: vec![s.strip] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SAMSUNG".into())); + ifd0.set(t::MODEL, Value::Ascii("NX0".into())); + let mut exif = IfdBuilder::new(); + if let Some(n) = s.note { + exif.set(t::MAKER_NOTE, Value::Undefined(n)); + } + if let Some((pw, ph)) = s.picture { + exif.set(t::PIXEL_X_DIMENSION, Value::Long(vec![pw])); + exif.set(t::PIXEL_Y_DIMENSION, Value::Long(vec![ph])); + } + ifd0.set_child(t::EXIF_IFD, exif); + ifd0.add_sub_ifd(raw); + TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + /// The camera-stated description every test file carries: JPEG framing, white balance, levels. + fn full_note(area: [u32; 4], white: i64, black: i64) -> Vec { + let mut levels = vec![0i64; 4]; + levels.extend([white; 8]); + note(&[ + (SENSOR_AREAS, vec![0, 0, 64, 32, area[0], area[1], area[2], area[3]]), + (ENCRYPTION_KEY, KEY.to_vec()), + (WB_UNCORRECTED, stored(&[8192, 4096, 4096, 6144], 1)), + (WB_BLACK, stored(&[black; 4], 1)), + (RAW_DATA, stored(&levels, -1)), + ]) + } + + /// A smooth gradient with a little texture, `max` the first value that is not reached. + fn ramp(w: u32, h: u32, max: u32) -> Vec { + (0..w * h).map(|i| ((1000 + (i % w) * 7 + (i / w) * 5 + (i * 13) % 9) % max) as u16).collect() + } + + fn pack12_lsb(v: &[u16]) -> Vec { + v.chunks(2).flat_map(|p| [p[0] as u8, ((p[0] >> 8) as u8) | ((p[1] & 15) << 4) as u8, (p[1] >> 4) as u8]).collect() + } + + /// Known answer: the samples, layout, framing, levels and white balance all come from the file's own words. + #[test] + fn packed_12_bit_samples_decode_with_the_maker_note() { + let v = ramp(64, 32, 4096); + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note([6, 4, 58, 30], 4095, 3)), + picture: None, + }); + assert_eq!(probe(&bytes), Some(RawFormat::Srw)); + let r = decode(&bytes).unwrap(); + assert_eq!((r.format, r.width, r.height, r.bits), (RawFormat::Srw, 64, 32, 12)); + assert_eq!(r.data, RawData::U16(v)); + assert_eq!(r.cfa.as_ref().unwrap().name(), "BGGR"); + assert_eq!(r.active_area, Rect::new(6, 4, 52, 26)); + assert_eq!(r.crop, Rect::new(0, 0, 52, 26)); + assert_eq!((r.black.mean(), r.white.clone()), (3.0, vec![4095.0])); + assert_eq!(r.wb_multipliers, Some([2.0, 1.0, 1.5])); + // headers only: everything but the samples + let i = probe_info(&bytes).unwrap(); + assert_eq!((i.active_area, i.wb_multipliers, i.bits), (Rect::new(6, 4, 52, 26), Some([2.0, 1.0, 1.5]), 12)); + } + + /// The later bodies pack the same 12-bit samples LSB-first; which of the two orders a file uses is found from + /// the samples (the wrong order makes a noisy image), not from the model. + #[test] + fn lsb_first_packing_is_found_from_the_samples() { + let v = ramp(64, 32, 4096); + for (strip, name) in [(pack12(&v), "msb"), (pack12_lsb(&v), "lsb")] { + let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip, pattern: Some([1, 0, 2, 1]), note: None, picture: None }); + assert_eq!(decode(&bytes).unwrap().data, RawData::U16(v.clone()), "{name}"); + } + } + + /// A picture size narrower than the framing in one direction only (16:9 from a 4:3 sensor) is the framing's + /// centre; any other size is ignored. + #[test] + fn the_exif_picture_size_centres_a_one_direction_crop() { + let v = ramp(64, 32, 4096); + let file = |picture| { + srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note([6, 4, 58, 30], 4095, 0)), + picture, + }) + }; + // framing 52 x 26 + assert_eq!(decode(&file(Some((52, 18)))).unwrap().crop, Rect::new(0, 4, 52, 18)); + assert_eq!(decode(&file(Some((40, 26)))).unwrap().crop, Rect::new(6, 0, 40, 26)); + let r = decode(&file(Some((52, 18)))).unwrap(); + assert_eq!((r.metadata.width, r.metadata.height), (Some(52), Some(18))); + for other in [None, Some((52, 26)), Some((40, 18)), Some((60, 18)), Some((52, 30)), Some((0, 18))] { + assert_eq!(decode(&file(other)).unwrap().crop, Rect::new(0, 0, 52, 26), "{other:?}"); + } + } + + /// 16-bit words are little-endian although the file is big-endian; a saturation plateau below the stated + /// level wins; dark rows below the framing give the black level when the file states none. + #[test] + fn word_samples_decode_and_measure_what_the_file_does_not_say() { + let (w, h) = (64u32, 40u32); + let mut v: Vec = (0..w * h).map(|i| 3000 + (i * 91 % 5000) as u16).collect(); + // saturation: a plateau at 15882 inside the framing (rows 4..28), two rows of picture beyond it, dark rows 30..40 + for i in 0..150usize { + v[(4 + i % 24) * 64 + 8 + i / 24] = 15882; + } + for x in 0..64 * 10 { + v[30 * 64 + x] = 80; + } + let bytes = srw(Spec { + w, + h, + compression: 32769, + strip: le_words(&v), + pattern: Some([0, 1, 1, 2]), + note: Some(full_note([0, 4, 64, 28], 16383, 0)), + picture: None, + }); + let r = decode(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16(v)); + assert_eq!(r.bits, 14); + assert!((15800.0..=15882.0).contains(&r.white[0]), "{:?}", r.white); + assert_eq!(r.black.mean(), 80.0); + assert_eq!(r.cfa.unwrap().name(), "RGGB"); + } + + /// Files whose strip is not exactly the uncompressed size (or that use another private value) are compressed + /// codings: reported as such, still recognised as SRW. + #[test] + fn compressed_codings_are_reported_not_misread() { + let plain = |compression, strip: Vec| { + srw(Spec { w: 64, h: 32, compression, strip, pattern: Some([2, 1, 1, 0]), note: Some(full_note([0, 0, 64, 32], 4095, 0)), picture: None }) + }; + for bytes in [ + plain(32770, vec![7; 64 * 32 * 12 / 8 - 3]), + plain(32772, vec![7; 4000]), + plain(32773, pack12(&ramp(64, 32, 4096))), + plain(32769, vec![0; 64 * 32]), + ] { + assert_eq!(probe(&bytes), Some(RawFormat::Srw)); + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported") }; + assert!(why.contains("compressed"), "{why}"); + assert!(probe_info(&bytes).is_err()); + } + } + + /// Without a maker note the samples still decode: whole array, no white balance, depth from the TIFF tags. + #[test] + fn a_file_without_a_maker_note_decodes_with_defaults() { + let v = ramp(64, 32, 4096); + let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&v), pattern: Some([1, 0, 2, 1]), note: None, picture: None }); + let r = decode(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16(v)); + assert_eq!((r.active_area, r.wb_multipliers, r.black.mean()), (Rect::new(0, 0, 64, 32), None, 0.0)); + assert_eq!(r.cfa.unwrap().name(), "GRBG"); + assert_eq!(r.bits, 12); + } + + /// A raw IFD that does not say how its mosaic is laid out is not guessed at. + #[test] + fn a_file_without_a_cfa_pattern_is_unsupported() { + let bytes = srw(Spec { w: 64, h: 32, compression: 32770, strip: pack12(&ramp(64, 32, 4096)), pattern: None, note: None, picture: None }); + assert!(matches!(decode(&bytes), Err(RawError::Unsupported(w)) if w.contains("CFAPattern"))); + } + + /// A framing rectangle outside the array, or reversed, is ignored; truncated files fail without panicking. + #[test] + fn hostile_framing_and_truncation_do_not_panic() { + let v = ramp(64, 32, 4096); + for area in [[0, 0, 65, 32], [10, 0, 10, 32], [0, 40, 64, 20]] { + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note(area, 4095, 0)), + picture: None, + }); + assert_eq!(decode(&bytes).unwrap().active_area, Rect::new(0, 0, 64, 32)); + } + let bytes = srw(Spec { + w: 64, + h: 32, + compression: 32770, + strip: pack12(&v), + pattern: Some([2, 1, 1, 0]), + note: Some(full_note([0, 0, 64, 32], 4095, 0)), + picture: None, + }); + for cut in [10, bytes.len() / 2, bytes.len() - 20] { + let _ = decode(&bytes[..cut]); + let _ = probe_info(&bytes[..cut]); + } + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 53193f1f..5ce32565 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -528,6 +528,63 @@ fn corpus_orf_cfa_patterns() { eprintln!("ORF CFA layouts checked on {seen} files"); } +/// Samsung SRW (`crates/raw/src/vendor/srw.rs`): the 12-bit packings of the NX10 and NX20 generations (opposite bit +/// orders, told apart from the samples) and the 16-bit words of the EX1 and WB2000 decode to the image of the camera's own +/// JPEG, with the layout, framing, levels and white balance the file's maker note states. +#[test] +fn corpus_samsung_srw() { + let dir = corpus_root().join("raw"); + // (file, bits, active area width x height, crop width x height, CFA layout, JPEG shows the whole active area) + let cases = [ + ("srw-samsung-nx10.srw", 12, (4592, 3056), (4592, 3056), "BGGR", true), + ("srw-samsung-nx20.srw", 12, (5472, 3648), (5472, 3648), "GRBG", true), + ("srw-samsung-ex1.srw", 14, (3648, 2736), (3648, 2736), "RGGB", true), + ]; + let mut seen = 0; + for (name, bits, active, crop, cfa, whole) 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.format, RawFormat::Srw, "{name}"); + assert_eq!(img.bits, bits, "{name}: bits"); + assert_eq!((img.active_area.width, img.active_area.height), active, "{name}: active area"); + assert_eq!((img.crop.width, img.crop.height), crop, "{name}: crop"); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cfa), "{name}: CFA layout"); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance")); + assert!(wb[0] > 1.0 && wb[1] == 1.0 && wb[2] > 0.5, "{name}: white balance {wb:?}"); + assert!(img.white_at(0) > (1u32 << bits) as f32 * 0.9 && img.white_at(0) <= ((1u32 << bits) - 1) as f32, "{name}: white {}", img.white_at(0)); + assert!(img.black.mean() < 8.0, "{name}: black {}", img.black.mean()); + // the mosaic inside the framing holds no marker or padding values above the saturation level + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let a = img.active_area; + let above = (a.y..a.y + a.height) + .map(|y| d[y * img.width + a.x..y * img.width + a.x + a.width].iter().filter(|&&v| f32::from(v) > ((1u32 << bits) - 1) as f32).count()) + .sum::(); + assert_eq!(above, 0, "{name}: samples above the saturation level in the active area"); + if whole { + 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 layout = img.cfa.clone().unwrap_or_else(|| panic!("{name}: no CFA")); + let green: Vec = sites.iter().map(|s| (0..4).filter(|&i| layout.pattern[i] == 1).map(|i| s[i]).sum::()).collect(); + let rho = correlation(&ranks(&green), &ranks(&jpeg.iter().map(|j| j[1]).collect::>())); + 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); + eprintln!( + "{name:26} {}x{} {bits}-bit: green rank correlation {rho:.3}, colour agreement {own:.3} (others <= {best_other:.3})", + img.width, img.height + ); + assert!(rho > 0.9, "{name}: rank correlation with the camera JPEG {rho}"); + assert!(own > 0.5 && own > best_other, "{name}: colours follow the JPEG better with another layout ({own} vs {best_other})"); + } + seen += 1; + } + eprintln!("Samsung SRW checked on {seen} files"); +} + /// Reference sensor sums and position-weighted sums from black-box decoding, independent /// of this implementation. These cover complete images, padding, stripe boundaries and /// lossy block quantizer changes; the source files are CC0, fetched by `xtask corpus`. @@ -598,6 +655,7 @@ fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { "orf" => RawFormat::Orf, "pef" => RawFormat::Pef, "raf" => RawFormat::Raf, + "srw" => RawFormat::Srw, "rw2" | "rwl" | "raw" => RawFormat::Rw2, _ => continue, }; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 31be10f5..def83dbb 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,6 +1,6 @@ # Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look -ARW, NEF, RW2, RAF, CR3, CR2 and PEF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. +ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate @@ -85,6 +85,11 @@ An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `Color Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included. +### Samsung SRW + +Samsung SRW decoding (uncompressed files) supplies the as-shot white-balance multipliers from the maker note and no colour +matrix, so SRW takes the same fit, gates and relative white balance. + ### Canon CR2 and Pentax PEF CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening diff --git a/docs/parity.md b/docs/parity.md index c06dad1b..e44cc9fe 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -142,7 +142,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`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 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", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature: `crates/heif`, heic-rs, same policy as PhotoCraft; 8/10-bit, alpha, irot/imir/clap, ICC/EXIF/XMP), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | | LR-IMP-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 | @@ -295,7 +295,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | diff --git a/docs/whats-new.md b/docs/whats-new.md index ccb0be80..29a82b54 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -32,6 +32,9 @@ belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`. ### RAW decoding +- Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1 + and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their + camera JPEG. Photos already imported pick the change up on Reload. - Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered. - JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files); diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 1680e08c..777dd652 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -9,8 +9,8 @@ use std::process::Command; /// Extensions treated as assets (lower-case). const ASSET_EXT: &[&str] = &[ "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "icns", "bmp", "ttf", "otf", "woff", "woff2", "wav", "mp3", "ogg", "flac", "mp4", - "mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "heic", "jxl", "psd", "cube", "icc", "icm", - "xmp", + "mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "heic", "jxl", "psd", "cube", "icc", + "icm", "xmp", ]; /// Adobe-specific formats we never ship (camera/lens profiles, Lightroom templates). const FORBIDDEN_EXT: &[&str] = &["dcp", "lcp", "lrtemplate", "lrcat", "lrsmcol", "aco", "ase", "abr"]; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0db8f6b2..85b20884 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -396,6 +396,10 @@ const RAW_SAMPLES: &[(&str, &str)] = &[ ("pef-pentax-k10d.pef", "https://raw.pixls.us/getfile.php/2239/nice/Pentax%20-%20K10D%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF"), ("pef-pentax-k3.pef", "https://raw.pixls.us/getfile.php/1075/nice/Pentax%20-%20K-3%20-%2014bit%20%283:2%29.PEF"), ("pef-pentax-k5iis.pef", "https://raw.pixls.us/getfile.php/1198/nice/Pentax%20-%20K-5%20II%20s%20-%2014bit%20%283:2%29.PEF"), + // Samsung SRW, one file per packing: 12-bit MSB-first (NX10), 12-bit LSB-first (NX20), 16-bit words (EX1). + ("srw-samsung-nx10.srw", "https://raw.pixls.us/getfile.php/5913/nice/Samsung%20-%20NX10%20-%2012bit%20%283:2%29.SRW"), + ("srw-samsung-nx20.srw", "https://raw.pixls.us/getfile.php/5370/nice/Samsung%20-%20NX20%20-%2012bit%20%283:2%29.SRW"), + ("srw-samsung-ex1.srw", "https://raw.pixls.us/getfile.php/1204/nice/Samsung%20-%20EX1%20-%2014bit%20%284:3%29.SRW"), ( "raf-fuji-xa2-12bit-bayer.raf", "https://raw.pixls.us/getfile.php/2883/nice/Fujifilm%20-%20X-A2%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", From bf35a3c6c5b18fcd263961c2a72538064d9c5314 Mon Sep 17 00:00:00 2001 From: supper_yijie Date: Fri, 9 Oct 2026 14:48:00 +0800 Subject: [PATCH 012/344] fix #425: Restore Simplified Chinese font rendering 1.Added Simplified Chinese font support for web and desktop builds. 2.Kept Inter as the default font, with Chinese used only as a fallback. 3.Verified Chinese glyph tests and a macOS universal build. Signed-off-by: supper_yijie --- AGENTS.md | 7 +- CLAUDE.md | 7 +- apps/lightcraft-web/index.html | 12 ++- apps/lightcraft-web/src/web.rs | 14 ++- assets/ATTRIBUTION.md | 4 +- crates/engine/build.rs | 19 +++- crates/ui-egui/src/lib.rs | 15 ++- crates/ui-egui/src/panels/dialogs.rs | 5 +- crates/ui-egui/src/theme.rs | 138 +++++++++++++++++++++++++-- docs/localization-zh-hans.md | 6 +- docs/localization-zh-hant.md | 2 +- docs/parity.md | 2 +- docs/releasing.md | 4 +- docs/web.md | 11 ++- packaging/web/README.md | 2 + xtask/src/web.rs | 62 +++++++++++- 16 files changed, 267 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ab653c7..b199c7f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` - embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI - (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset, - `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build + embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web` + ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI + (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset, + `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). diff --git a/CLAUDE.md b/CLAUDE.md index bdf7eceb..994920d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,9 +74,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` - embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); the UI - (`theme::font_definitions`) and the export watermark renderer use its CJK faces (picked by script) as fallbacks after Inter. Unset, - `CRAFT_FONTS` is empty: everything builds, tests and runs, but Japanese text has no glyphs. Releases always build + embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web` + ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI + (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset, + `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). diff --git a/apps/lightcraft-web/index.html b/apps/lightcraft-web/index.html index bc0ad203..b088ad18 100644 --- a/apps/lightcraft-web/index.html +++ b/apps/lightcraft-web/index.html @@ -24,13 +24,21 @@