From 82f904495f6608e8ff16e7cea3ed30486f8beab1 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Thu, 8 Oct 2026 23:16:10 -0400 Subject: [PATCH 01/20] 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 02/20] 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 40652fc5f91ffa4ba7d8b099286762c9f6741e05 Mon Sep 17 00:00:00 2001 From: Hanmo Date: Fri, 9 Oct 2026 13:35:02 +0800 Subject: [PATCH 03/20] 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 04/20] 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 05/20] 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 06/20] 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 d9fd2f8ae0ad375aa4223a5cb79e997202fede22 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 02:02:08 -0400 Subject: [PATCH 07/20] 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 08/20] 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 @@