From 5bcedbe6fc11ed9e23f47b18328a602b19dd5525 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:25:59 +0200 Subject: [PATCH 01/42] NEF: measured image area and white level for the Nikon D4 The D4 records no CropArea. Its 4992 x 3292 data keeps 2 columns of optical black left of the image and 42 right of it (then 8 empty ones), which showed as purple stripes on both sides; the camera's own 4928 x 3280 JPEG sits at (8, 6). Green saturates at 15546-15672 while red and blue run on to 16383, so the data's own top left clipped highlights magenta; one level of 15520 keeps them white without moving the white balance. Measured on 148 uncompressed 14-bit NEFs from one body by aligning the sensor data with the embedded camera JPEG. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/nef.rs | 56 ++++++++++++++++++++++++++++++++++-- docs/parity.md | 2 +- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..d863060ec 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 = 42; +pub const RENDER_CACHE_VERSION: u64 = 43; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index c7b99e937..f97a48f3b 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -57,6 +57,30 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { Rect::new(*x as usize, *y as usize, *width as usize, *height as usize) } +/// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the +/// camera's full-size JPEG sits when the two are aligned) and the level where its channels clip. +struct MeasuredBody { + model: &'static str, + /// Sensor data size and sample depth of the measured files. + size: (usize, usize), + bits: u32, + /// Image area in sensor pixels: x, y, width, height. + crop: (usize, usize, usize, usize), + white: f32, +} + +/// * **NIKON D4** (148 uncompressed 14-bit NEFs from one body, 2023–2026, ISO 100–10000): the data is 4992 × 3292, +/// with 2 columns of optical black left of the image and 42 right of it, then 8 empty ones; the camera's +/// 4928 × 3280 JPEG sits at (8, 6). Without this area the optical black shows as purple stripes on both sides. +/// Green saturates at 15546–15672 while red and blue run on to 15687–16383, so the data's own top (~16300) leaves +/// clipped highlights magenta; one level of 15520 for every channel turns them white and keeps the white balance. +const MEASURED_BODIES: &[MeasuredBody] = + &[MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }]; + +fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { + MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -120,9 +144,13 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, _ => Cfa::bayer_static("RGGB"), }; - let white = white_from_data(samples, bits); + let body = measured_body(&ifd0.string(t::MODEL).unwrap_or_default(), w, h, bits); + let white = body.map_or_else(|| white_from_data(samples, bits), |b| b.white); let active_w = trailing_masked_columns(samples, w, h, white); - let crop = default_crop(mn.as_ref(), active_w, h); + let crop = match body { + Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), + _ => default_crop(mn.as_ref(), active_w, h), + }; let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); metadata.height = Some(crop.height as u32); @@ -275,6 +303,30 @@ mod tests { } } + /// The D4 records no CropArea: its measured image area and saturation level apply to files of its data size and + /// depth only. + #[test] + fn measured_d4_image_area_and_white_level() { + let (w, h) = (4992usize, 3292usize); + let decode_as = |model: &str, bits: u16| { + let mut nef = nef(1, bits, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32); + let at = nef.windows(10).position(|b| b == b"NIKON TEST").unwrap(); + let name = format!("{model:<10}"); + nef[at..at + 10].copy_from_slice(name.as_bytes()); + crate::decode(&nef).unwrap() + }; + let d4 = decode_as("NIKON D4", 14); + assert_eq!(d4.crop, Rect::new(8, 6, 4928, 3280)); + assert_eq!(d4.white, vec![15520.0]); + assert_eq!((d4.metadata.width, d4.metadata.height), (Some(4928), Some(3280))); + let developed = d4.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (4928, 3280)); + for other in [decode_as("NIKON D5", 14), decode_as("NIKON D4", 12)] { + assert_eq!(other.crop, Rect::new(0, 0, w, h)); + assert_ne!(other.white, vec![15520.0]); + } + } + #[test] fn black_level_tag_is_in_14_bit_units() { let (w, h) = (16usize, 4usize); diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..e020a3ace 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -147,7 +147,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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 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; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); 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; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet), 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.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Hasselblad 3FR and FFF (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`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, 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 (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, 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), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), 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, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), 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; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus 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; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; 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`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. 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-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; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus 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; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; 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`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. 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; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | 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 | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | From b59ffcc4c9d920162720fded7cbb9e4bfc507920 Mon Sep 17 00:00:00 2001 From: TicT4x <189464378+TicT4x@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:07:47 +0200 Subject: [PATCH 02/42] raw: find the saturation plateau despite stray samples above it white_from_data anchored the plateau search on the absolute maximum, so a handful of stray samples above saturation (Canon EOS 70D CR2: 83 of 20.6M, up to 14426 vs. a plateau at 13583) made it fall back to 2^bits-1. The image rendered ~0.3 EV dark and clipped highlights were never seen as clipped. Skip up to half a plateau's worth of strays when locating the top. Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/vendor/mod.rs | 53 +++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index fcab4eabf..b807c6c1e 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -58,28 +58,38 @@ pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range, } /// White level estimate: the saturation plateau if a noticeable number of samples sit at the maximum value, -/// otherwise the full `bits` range. +/// otherwise the full `bits` range. A few stray samples above the plateau (hot pixels, e.g. on a Canon EOS 70D +/// CR2) don't hide it. pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 { - let full = ((1u32 << bits.clamp(1, 16)) - 1) as f32; + let full_level = (1u32 << bits.clamp(1, 16)) - 1; + let full = full_level as f32; + let step = (data.len() / 2_000_000).max(1); // samples above the nominal range are padding / invalid (e.g. 0xFFFF fill), not saturation - let Some(&mx) = data.iter().filter(|&&v| v as f32 <= full).max() else { return full }; + let mut hist = vec![0usize; full_level as usize + 1]; + for &v in data.iter().step_by(step) { + if let Some(h) = hist.get_mut(v as usize) { + *h += 1; + } + } + let min_plateau = (data.len() / 20_000).max(16); + // the top of the data, skipping up to half a plateau's worth of strays so a real plateau can't be skipped + let strays = min_plateau / 2; + let mut above = 0usize; + let Some(mx) = hist.iter().enumerate().rev().find_map(|(v, &n)| { + above += n * step; + (above > strays).then_some(v as u32) + }) else { + return full; + }; let band = (mx / 256).max(1); let near = mx.saturating_sub(band); let below = near.saturating_sub(band); - let step = (data.len() / 2_000_000).max(1); - let (mut top, mut under) = (0usize, 0usize); - for &v in data.iter().step_by(step) { - if v as f32 > full { - continue; - } - if v >= near { - top += 1; - } else if v >= below { - under += 1; - } - } + let count = |from: u32, to: u32| hist.get(from as usize..to as usize).map_or(0, |h| h.iter().sum::()); + // the strays count as saturated too + let top = count(near, full_level + 1); + let under = count(below, near); // a saturation plateau is a spike: many more samples in the top band than in the band just below it - if mx as f32 > full * 0.5 && top * step >= (data.len() / 20_000).max(16) && top >= 4 * under + 4 { + if mx as f32 > full * 0.5 && top * step >= min_plateau && top >= 4 * under + 4 { // plateau: use its lower edge so everything at saturation maps to ≥ 1.0 near as f32 } else { @@ -116,4 +126,15 @@ mod tests { assert!((14900.0..=15000.0).contains(&w), "{w}"); assert_eq!(white_from_data(&[], 12), 4095.0); } + + /// A Canon EOS 70D CR2 saturates at 13583 but has a handful of samples up to 14426 (out of ~20M): the plateau + /// is still found instead of falling back to 16383 (≈0.3 EV too dark, highlights never seen as clipped). + #[test] + fn white_plateau_with_strays_above() { + let mut d: Vec = (0..100_000).map(|i| (2048 + i % 10000) as u16).collect(); + d.extend(std::iter::repeat_n(13583u16, 5000)); + d.extend([13700, 13900, 14100, 14426]); + let w = white_from_data(&d, 14); + assert!((13500.0..=13583.0).contains(&w), "{w}"); + } } From e7f8faf77dce9938cc226a03fc93abc2c4d9fdcd Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 10:14:32 -0400 Subject: [PATCH 03/42] LR-IMP-FORMATS: EOS R5 Mark II crop-mode CR3s develop without a black band The R5 Mark II's APS-C crop-mode files (5376 x 3574) state an IAD1 active area of x 132..5243, but columns 132..~256 sit at the black level and the image runs to the right edge (measured on raw.pixls.us 7883/7884), while their recommended crop, x 272..5359 (5088 x 3392, the camera JPEG's size), lies on the image. The recommended crop didn't fit inside the stated active area, so the whole active area was kept: a black band down the left and ~130 image columns lost. When the recommended crop doesn't fit inside the stated active area, it is now the valid area. On 7883 the render's high-pass correlation with the camera JPEG goes from -0.13 to 0.99. The active area and crop of the other 108 decodable CC0 CR3s on raw.pixls.us are unchanged. RENDER_CACHE_VERSION 49. --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/cr3.rs | 30 ++++++++++++++++++++++-------- docs/cr3.md | 6 ++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..cb76b1251 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 = 42; +pub const RENDER_CACHE_VERSION: u64 = 49; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index b5442790c..1d23d7272 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -108,10 +108,20 @@ fn aspect_crop(maker: Option<&Ifd>, image: Rect) -> Option { fn geometry(area: Option<&Cr3ImageArea>, maker: Option<&Ifd>, width: usize, height: usize) -> (Rect, Rect) { let area = area.filter(|a| usize::from(a.width) == width && usize::from(a.height) == height); let sensor = sensor_crop(maker, width, height); - let active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height)); - // The recommended crop, when the file gives a consistent one. `AspectInfo` is measured from its origin, so it - // is only applied on top of that; without it (the crop doesn't fit the valid area) the whole active area stays. - let recommended = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor).and_then(|c| relative_crop(c, active)); + let mut active = area.and_then(|a| a.active).and_then(|b| inclusive_rect(b, width, height)).or(sensor).unwrap_or(Rect::new(0, 0, width, height)); + // The recommended crop. `AspectInfo` is measured from its origin, so it is applied on top of it. + let stated = area.and_then(|a| inclusive_rect(a.crop, width, height)).or(sensor); + let mut recommended = stated.and_then(|c| relative_crop(c, active)); + // Some crop-mode files (EOS R5 Mark II APS-C, 5376 x 3574) state an active area that starts in the dark + // columns and stops short of the image, while their recommended crop lies on the image (measured: columns + // 132..~256 sit at the black level, the image runs to the right edge). The recommended crop is then the + // valid area. + if recommended.is_none() + && let Some(c) = stated.filter(|c| c.width >= 2 && c.height >= 2) + { + active = c; + recommended = Some(Rect::new(0, 0, c.width, c.height)); + } let crop = recommended.map(|c| aspect_crop(maker, c).unwrap_or(c)).unwrap_or(Rect::new(0, 0, active.width, active.height)); (active, crop) } @@ -424,8 +434,9 @@ mod tests { } } - /// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their valid area. The - /// whole active area stays then, and `AspectInfo`, which is measured from the recommended crop, is not applied. + /// Some crop-mode files (EOS R5 Mark II 7883) record a recommended crop that reaches past their stated active + /// area, which starts in dark columns. The recommended crop is then the valid area (no black band on the left), + /// and `AspectInfo` applies from its origin. #[test] fn aspect_info_needs_a_consistent_recommended_crop() { let aspect = lightcraft_tiff::IfdBuilder::new().with(ASPECT_INFO, Value::Long(vec![13, 5088, 3392, 0, 0])); @@ -433,8 +444,11 @@ mod tests { let maker = Tiff::parse(&bytes).unwrap().ifds.remove(0); let area = |crop| Cr3ImageArea { width: 5376, height: 3574, crop, active: Some([132, 160, 5243, 3567]), masked_left: [0; 4], masked_top: None }; - // crop right edge 5359 > active right edge 5243: not a crop of the active area - assert_eq!(geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574).1, Rect::new(0, 0, 5112, 3408)); + // crop right edge 5359 > active right edge 5243: the recommended crop becomes the valid area + assert_eq!( + geometry(Some(&area([272, 172, 5359, 3563])), Some(&maker), 5376, 3574), + (Rect::new(272, 172, 5088, 3392), Rect::new(0, 0, 5088, 3392)) + ); // consistent crop: AspectInfo applies from its origin assert_eq!(geometry(Some(&area([140, 172, 5227, 3563])), Some(&maker), 5376, 3574).1, Rect::new(8, 12, 5088, 3392)); } diff --git a/docs/cr3.md b/docs/cr3.md index ac13d3fda..82f7e6368 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -11,6 +11,12 @@ recommended crop, and the decoder uses it as the default crop, so the developed framed (PowerShot SX70 HS, EOS 250D, PowerShot G5 X Mark II, EOS M6 Mark II and EOS M50 samples). A rectangle that is missing or does not fit inside the crop is ignored. The embedded JPEG still shows the whole frame. +The EOS R5 Mark II's APS-C crop-mode files (5376 x 3574) state an active area that starts in dark columns +(132..~256 sit at the black level) and stops ~130 columns short of the image, while their recommended crop +(5088 x 3392, the camera JPEG's size) lies on the image. When the recommended crop doesn't fit inside the stated +active area, it is used as the valid area: on raw.pixls.us 7883/7884 the render matches the camera JPEG +(high-pass correlation 0.99, was -0.13 with a black band on the left). No other of the 110 CC0 CR3s changes. + The decoder is independent Rust code shared by desktop, CLI and WebAssembly; it has no operating-system imaging dependency or external decoder. Its sources are the public prose descriptions and patents cited in From bbcbbebf8ea0c07c23270279deed06b90c85bc1a Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:49:39 +0200 Subject: [PATCH 04/42] LR-EDIT-COPY-PASTE: Enter applies the Copy and Paste Settings dialogs (#735) Return presses OK in Choose Edit Settings to Copy and Paste Selected Settings when no widget has the keyboard focus (a Tab-focused button or checkbox takes Return itself, as egui does). Esc already cancelled them; tests now cover both keys. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 16 ++++ .../ui-egui/src/tests_copy_paste_dialogs.rs | 91 +++++++++++++++++++ docs/parity.md | 4 +- docs/whats-new.md | 2 + 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/tests_copy_paste_dialogs.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..34a38a2d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,8 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_copy_paste_dialogs; +#[cfg(test)] mod tests_crop_rotate; #[cfg(test)] mod tests_curve; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..99275e4b8 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -77,6 +77,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); let mut close = false; let mut confirm = false; + // Return presses OK in a dialog without a text field of its own (Lightroom's default button); + // a widget that has the keyboard focus (a text field, a button or checkbox reached with Tab) + // takes Return itself. Consumed before the dialog is drawn, so nothing else sees it too. + if return_confirms(&dlg) + && ctx.memory(|m| m.focused().is_none()) + && !egui::Popup::is_any_open(ctx) + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) + { + confirm = true; + } let title: String = match &dlg { Dialog::TextPrompt { title, .. } => title.as_str(), Dialog::NewAlbum { folder: true, .. } => "Create Folder", @@ -1556,6 +1566,12 @@ fn choices(ui: &mut egui::Ui, label: &str, id: &str, option }); } +/// Dialogs that Return confirms with nothing focused (those with text fields confirm from the +/// field: `dialog_field`). +fn return_confirms(dlg: &Dialog) -> bool { + matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) +} + /// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens /// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc /// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the diff --git a/crates/ui-egui/src/tests_copy_paste_dialogs.rs b/crates/ui-egui/src/tests_copy_paste_dialogs.rs new file mode 100644 index 000000000..5a8bf4ad6 --- /dev/null +++ b/crates/ui-egui/src/tests_copy_paste_dialogs.rs @@ -0,0 +1,91 @@ +//! Return presses OK in Copy Settings (⇧⌘C) and Paste Selected Settings (⇧⌘V); Esc cancels (#735). + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::headless::Headless; +use crate::state::Dialog; + +const SETTLE: Duration = Duration::from_secs(120); +const T: Duration = Duration::from_secs(10); + +/// The demo library with an edited first photo selected; returns the first two photos' ids. +fn edited() -> (Headless, [u64; 2]) { + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + let ids = [ids[0], ids[1]]; + exec(&mut h, "library.select", json!({"ids": [ids[0]]})); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0, "color.vibrance": 30}})); + (h, ids) +} + +fn exec(h: &mut Headless, command: &str, params: Value) { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); +} + +fn key(h: &mut Headless, k: Value) { + let r = h.request("ui.key", k.clone(), T); + assert_eq!(r["ok"], true, "{k}: {r}"); + h.settle(SETTLE); +} + +fn copy_dialog(h: &mut Headless) { + key(h, json!({"key": "C", "cmd": true, "shift": true})); + assert!(matches!(h.app.ui.dialog, Some(Dialog::CopySettings { .. })), "dialog not open: {:?}", h.app.ui.dialog); +} + +/// Copies light and colour from the first photo, selects the second and opens ⇧⌘V. +fn paste_dialog(h: &mut Headless, ids: [u64; 2]) { + exec(h, "develop.copy", json!({"groups": ["light", "color"]})); + exec(h, "library.select", json!({"ids": [ids[1]]})); + key(h, json!({"key": "V", "cmd": true, "shift": true})); + assert!(matches!(h.app.ui.dialog, Some(Dialog::PasteSettings { .. })), "dialog not open: {:?}", h.app.ui.dialog); +} + +fn exposure(h: &Headless, id: u64) -> f64 { + h.app.session.develop_of(PhotoId(id)).map(|d| d.light.exposure).unwrap_or_default() +} + +#[test] +fn return_copies_the_chosen_settings() { + let (mut h, _) = edited(); + copy_dialog(&mut h); + assert!(h.app.session.clipboard.is_none()); + key(&mut h, json!({"key": "Enter"})); + assert!(h.app.ui.dialog.is_none(), "Return closes the dialog"); + assert!(h.app.session.clipboard.is_some(), "Return copies the settings"); +} + +#[test] +fn escape_closes_copy_settings_without_copying() { + let (mut h, _) = edited(); + copy_dialog(&mut h); + key(&mut h, json!({"key": "Escape"})); + assert!(h.app.ui.dialog.is_none(), "Esc closes the dialog"); + assert!(h.app.session.clipboard.is_none(), "Esc copies nothing"); +} + +#[test] +fn return_pastes_into_the_selected_photos() { + let (mut h, ids) = edited(); + paste_dialog(&mut h, ids); + key(&mut h, json!({"key": "Enter"})); + assert!(h.app.ui.dialog.is_none(), "Return closes the dialog"); + assert_eq!(exposure(&h, ids[1]), 1.0, "Return pastes the settings"); +} + +#[test] +fn escape_closes_paste_settings_without_pasting() { + let (mut h, ids) = edited(); + paste_dialog(&mut h, ids); + key(&mut h, json!({"key": "Escape"})); + assert!(h.app.ui.dialog.is_none(), "Esc closes the dialog"); + assert_eq!(exposure(&h, ids[1]), 0.0, "Esc pastes nothing"); +} diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..f34ca3cd8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -572,8 +572,8 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step | | MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | | | MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | -| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | | -| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted | +| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | Return copies, Esc cancels | +| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels | | MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | | | MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | | MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..c9c34b8a1 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -157,6 +157,8 @@ - Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`. +- Return presses OK in Choose Edit Settings to Copy (⇧⌘C) and Paste Selected Settings (⇧⌘V), and Esc cancels them + (issue #735). ### RAW decoding - Olympus ORF raws get the starting look fitted to the camera's own JPEG too, instead of opening flat and grey (the From 61df5d304f1541ae38ac8ecf4a7f6b12402b70d5 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:21:23 +0200 Subject: [PATCH 05/42] LR-MASK-BRUSH: holding the brush still adds no coverage; flow builds up by passes (#518) A stationary hold recorded one pointer sample per frame and each sample laid another dab at the same spot, so flow accumulated with hold time and the feathered edge hardened. brush_dabs now lays no dab for a sample that hasn't moved from the last one, and the canvas no longer records repeated samples at the same position. Moving strokes and passes back over an area are unchanged. --- crates/pipeline/src/masks.rs | 70 +++++++++++++++++++++++++++-- crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/tests_masking.rs | 26 +++++++++++ docs/parity.md | 2 +- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 4777c8782..e6508c084 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -440,25 +440,35 @@ pub struct BrushDabs { pub auto: bool, } +/// Output pixels a brush sample must move from the last dab to count as movement: below this the +/// pointer is held still (the same screen position mapped again, up to float rounding). +const STATIONARY_PX: f64 = 1e-6; + /// The dabs of stroke `s` in a `w × h` output. pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs { let to_out = frame.norm_to_out(w, h); let ppl = frame.px_per_long(w); let r = (s.size * ppl).max(0.5); let hard = r * (1.0 - (s.feather / 100.0).clamp(0.0, 1.0)); - // Densify the path so dabs overlap (spacing r/4). + // Densify the path so dabs overlap (spacing r/4). Coverage builds up by passes, not by how + // long the button is held: a sample where the pointer hasn't moved (a stationary hold records + // one per frame) lays no dab, so waiting in place adds nothing (#518). let mut dabs: Vec = Vec::new(); - for (i, p) in s.points.iter().enumerate() { + let mut prev: Option = None; + for p in &s.points { let q = to_out.apply(*p); - if i > 0 { - let prev = to_out.apply(s.points[i - 1]); + if let Some(prev) = prev { let dist = prev.dist(q); + if dist.is_nan() || dist <= STATIONARY_PX { + continue; + } let n = (dist / (r / 4.0).max(0.5)).ceil() as usize; for k in 1..n { dabs.push(prev.lerp(q, k as f64 / n as f64)); } } dabs.push(q); + prev = Some(q); } BrushDabs { dabs, @@ -714,6 +724,58 @@ mod tests { assert!(a.get(40, 5) < 0.01); } + /// #518: holding the brush still adds no coverage. A press plus any number of stationary + /// pointer samples paints exactly the single dab of the press, whatever the frame rate. + #[test] + fn holding_the_brush_still_adds_no_coverage() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let p = Point::new(0.5, 0.5); + let spot = |samples: usize, flow: f64| { + let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + for flow in [100.0, 40.0] { + let press = spot(1, flow); + // the feathered edge is soft to begin with + let edge = press.get(100 + 14, 50); + assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}"); + for n in [2, 5, 60, 600] { + let held = spot(n, flow); + assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50)); + } + } + let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() }; + assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press"); + // a stroke that pauses partway lays the same dabs as one that doesn't + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() }; + let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() }; + assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs); + } + + /// Flow builds up by passes: going back over an area in the same stroke adds coverage. + #[test] + fn a_second_pass_builds_up_with_low_flow() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paint = |points: Vec| { + let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let once = paint(vec![a, b]); + let twice = paint(vec![a, b, a]); + let thrice = paint(vec![a, b, a, b]); + let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50)); + assert!(o > 0.05 && o < 0.99, "one pass {o}"); + assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}"); + } + #[test] fn auto_mask_stops_at_edges() { // dark left half, bright right half; a stroke along the boundary, centred on the dark side diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..7a6a0379a 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1672,6 +1672,8 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons { let n = map.norm(q); match &mut app.gesture { + // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) + Some(Gesture::Brush { points }) if points.last() == Some(&n) => {} Some(Gesture::Brush { points }) => points.push(n), _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), } diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index c748ecf14..bb03aa564 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -298,6 +298,32 @@ fn mask_overlay_keys_and_pins() { h.settle(SETTLE); } +/// #518: holding the brush still, however long, paints the same feathered spot as a quick press. +#[test] +fn holding_the_brush_still_does_not_harden_the_spot() { + let mut h = detail("panel.masking"); + let r = h.request("ui.set", json!({"brushSize": 0.1, "brushFeather": 80.0, "brushFlow": 100.0}), T); + assert_eq!(r["ok"], true, "{r}"); + exec(&mut h, "tool.brush", json!({"new": true})); + // a quick press at the left, a long stationary hold (120 frames, ~2 s) at the right + brush_at(&mut h, 0.3, 0.5); + let mut hold = vec![json!({"kind": "down", "x": 0.7, "y": 0.5})]; + hold.extend((0..120).map(|_| json!({"kind": "move", "x": 0.7, "y": 0.5}))); + hold.push(json!({"kind": "up", "x": 0.7, "y": 0.5})); + pointer(&mut h, json!(hold)); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert_eq!(strokes.len(), 2, "{strokes:?}"); + assert_eq!(strokes[1].points.len(), 1, "a stationary hold records one sample: {:?}", strokes[1].points); + // the same edge profile around both spots + for dx in [0usize, 4, 6, 8] { + let (quick, held) = (mask_coverage(&h, 0, 30 + dx, 50), mask_coverage(&h, 0, 70 + dx, 50)); + assert!((quick - held).abs() < 0.01, "{dx} px from the centre: quick {quick}, held {held}"); + } + assert!(mask_coverage(&h, 0, 70 + 6, 50) < 0.9, "the edge stays feathered"); + h.settle(SETTLE); +} + #[test] fn brush_strokes_carry_auto_mask() { let mut h = detail("panel.masking"); diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..4250b3940 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -356,7 +356,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | 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; Create New Mask starts a separate brush in paint mode; live-preview gaps (#517) and stationary accumulation (#518) remain; 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-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; coverage builds up by passes, not hold time (holding the brush still adds nothing, #518); live-preview gaps (#517) remain; 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`, `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 | From 6dca6314825eac18e494edc931f36b2b53cc88fd Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Sat, 10 Oct 2026 17:41:11 +0200 Subject: [PATCH 06/42] LR-IMP-CAMERA-COVERAGE: SRF and DSLR-A100 raws open again (#702) Since #281, probe takes a Sony file as a raw only with raw evidence, and two layouts have none of the kinds has_raw_ifd looks for: SRF (DSC-F828, an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed by SubIFDs). Both were "not a readable photo". For Sony, IFD0 samples of 12 or 14 bits, or model DSLR-A100 with an old-style JPEG IFD0 (compression 6) and SubIFDs, now count too; an 8- or 16-bit camera TIFF, or an exported A100 TIFF with a SubIFDs thumbnail, stays an image. The public F828 and A100 samples open through their previews as on main 5436951; 133 other Sony files unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/lib.rs | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index f0331d610..2d7171fe2 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -167,13 +167,21 @@ pub fn probe(bytes: &[u8]) -> Option { let pentax_raw = has_cfa || t.exif().is_some_and(|e| e.contains(vendor::EXIF_CFA_PATTERN)); // Samsung's raw IFD uses private compressions 32769..=32773 (`vendor::srw`), 32773 being PackBits elsewhere let samsung_raw = has_cfa || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION).is_some_and(|c| (32769..=32773).contains(&c))); + // Sony raws without a CFA IFD (#702): SRF (DSC-F828), whose IFD0 looks like RGB but holds 14-bit samples (12 or + // 14 count: raw sensor depths; the raw is in Sony's SRF blocks), and the DSLR-A100, whose IFD0 is an old-style + // JPEG thumbnail (compression 6, which today's TIFF writers don't produce) and whose `SubIFDs` tag addresses its + // raw data. A camera-authored or exported TIFF has 8- or 16-bit samples, and its SubIFDs hold ordinary images + let model = t.find(lightcraft_tiff::tags::MODEL).and_then(|e| e.value.as_str()).unwrap_or_default().trim().to_ascii_uppercase(); + let sony_raw = has_cfa + || ifd0.u64s(lightcraft_tiff::tags::BITS_PER_SAMPLE).is_some_and(|b| b.iter().any(|v| matches!(v, 12 | 14))) + || (model == "DSLR-A100" && ifd0.u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) && ifd0.contains(lightcraft_tiff::tags::SUB_IFDS)); if make.starts_with("CANON") && t.ifds.len() >= 4 && t.ifds[3].u16(lightcraft_tiff::tags::COMPRESSION) == Some(6) { return Some(RawFormat::Cr2); } if make.starts_with("NIKON") && has_cfa { return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw }); } - if make.starts_with("SONY") && has_cfa { + if make.starts_with("SONY") && sony_raw { return Some(RawFormat::Arw); } if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && pentax_raw { @@ -943,6 +951,35 @@ mod tests { } } + /// #702: two Sony layouts carry no CFA IFD, so the raw evidence #281 asks of Sony files has to + /// include them: SRF (an RGB-looking IFD0 of 14-bit samples) and the DSLR-A100 (raw data addressed + /// by `SubIFDs`). An 8-bit TIFF from the same bodies, or a SubIFDs TIFF from another Sony model, + /// stays an ordinary image. + #[test] + fn sony_raws_without_a_cfa_ifd_are_still_raws() { + let sony = |model: &str, bits: u16, compression: u16, sub_ifd: Option| { + let mut ifd = rgb_ifd(400, 300); + ifd.set(t::MAKE, Value::Ascii("SONY".into())); + ifd.set(t::MODEL, Value::Ascii(model.into())); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + if let Some(child) = sub_ifd { + ifd.set_child(t::SUB_IFDS, child); + } + probe(&write(&[ifd])) + }; + let raw_block = || Some(rgb_ifd(64, 48)); // stands in for the A100's raw-data pointer + assert_eq!(sony("DSC-F828", 14, 1, None), Some(RawFormat::Arw), "SRF"); + assert_eq!(sony("DSC-F828", 12, 1, None), Some(RawFormat::Arw), "12-bit samples count too"); + assert_eq!(sony("DSLR-A100", 8, 6, raw_block()), Some(RawFormat::Arw), "A100: old-JPEG IFD0 and SubIFDs"); + assert_eq!(sony("DSC-F828", 8, 1, None), None, "an 8-bit TIFF"); + assert_eq!(sony("DSC-F828", 16, 1, None), None, "a 16-bit TIFF"); + // TIFFs exported from A100 photos, with a thumbnail in SubIFDs (Codex review of #702) + assert_eq!(sony("DSLR-A100", 8, 1, Some(rgb_ifd(64, 48))), None, "an 8-bit A100 TIFF with a SubIFDs thumbnail"); + assert_eq!(sony("DSLR-A100", 16, 8, Some(rgb_ifd(64, 48))), None, "a 16-bit A100 TIFF with a SubIFDs thumbnail"); + assert_eq!(sony("ILCE-7M4", 8, 6, raw_block()), None, "the A100's layout under another model"); + } + // --- containers that are recognised but not decoded, with a preview --- #[test] From 0bd3f3715df2433786c5b9d8c350ea21be50d916 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 12:11:41 -0400 Subject: [PATCH 07/42] LR-BEHAV-GPU: no indirect-call validation pipeline, which Intel Metal fails to compile (issue #250) On a 2015 MacBook Pro (Intel Iris/Metal), 0.5.0 never opened its window, even with LIGHTCRAFT_GPU=0: wgpu's indirect-call validation builds an internal compute pipeline when the window's device is created, Metal's compiler fails on it ("Compiler encountered an internal error"), the device is lost and eframe exits. That validation only guards indirect draws and dispatches, which neither LightCraft nor egui issue. backend::instance_flags clears InstanceFlags::VALIDATION_INDIRECT_CALL for every instance LightCraft creates (the window's, Gpu::new and the denoise device), unless WGPU_VALIDATION_INDIRECT_CALL asks for it. Tests: the flag rule, and that the window's instance descriptor goes without it. --- apps/lightcraft/src/main.rs | 12 ++++++++++++ crates/gpu/src/backend.rs | 30 ++++++++++++++++++++++++++++++ crates/gpu/src/ctx.rs | 2 ++ crates/gpu/src/nn.rs | 2 ++ 4 files changed, 46 insertions(+) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 00665e87e..94aeefa12 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -98,6 +98,8 @@ fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { if let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = &mut c.wgpu_setup { n.instance_descriptor.backends = lightcraft_engine::gpu::backend::window_backends(); n.instance_descriptor.backend_options = lightcraft_engine::gpu::backend::backend_options(); + // no indirect-call validation pipeline: Intel Metal fails to compile it and the window never opens (#250) + n.instance_descriptor.flags = lightcraft_engine::gpu::backend::instance_flags_from_env(n.instance_descriptor.flags); } c } @@ -999,6 +1001,16 @@ mod tests { } } + /// Issue #250: the window's device is created without wgpu's indirect-call validation pipeline, + /// which Intel Macs' Metal compiler fails to build (the device was lost, the window never opened). + #[test] + fn window_has_no_indirect_call_validation_pipeline() { + let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") }; + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + assert!(!n.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + } + /// Issue #234: Windows opens links with the URL protocol handler (the default browser), not /// File Explorer, and passes the whole map link, `?`, `&` and `#` included, as one argument. #[test] diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 041f57737..81bfe719e 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -145,6 +145,25 @@ pub fn backend_options() -> wgpu::BackendOptions { o } +/// wgpu's instance flags for every instance LightCraft creates, from `flags` (wgpu's defaults with its +/// environment variables applied) without `VALIDATION_INDIRECT_CALL`, unless +/// `WGPU_VALIDATION_INDIRECT_CALL` asks for it (any value but `0`). That validation builds an internal +/// compute pipeline when each device is created; Intel Macs' Metal compiler fails on it ("Compiler +/// encountered an internal error"), the device is lost and the window never opens (#250). It only +/// guards indirect draws and dispatches, which neither LightCraft nor egui issue. +pub fn instance_flags(flags: wgpu::InstanceFlags, env: Option<&str>) -> wgpu::InstanceFlags { + if env.is_some_and(|v| v != "0") { + flags | wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL + } else { + flags - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL + } +} + +/// [`instance_flags`] with `WGPU_VALIDATION_INDIRECT_CALL` read from the environment. +pub fn instance_flags_from_env(flags: wgpu::InstanceFlags) -> wgpu::InstanceFlags { + instance_flags(flags, std::env::var("WGPU_VALIDATION_INDIRECT_CALL").ok().as_deref()) +} + /// `LIGHTCRAFT_GPU_BACKEND=off`. pub(crate) fn env_off() -> bool { env_choice() == BackendChoice::Off @@ -195,6 +214,17 @@ pub fn read_init_marker(path: &std::path::Path) -> Option { #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { + + /// #250: the indirect-call validation pipeline is off unless asked for; other flags are kept. + #[test] + fn indirect_call_validation_is_off_unless_asked_for() { + use wgpu::InstanceFlags as F; + let base = F::VALIDATION_INDIRECT_CALL | F::DEBUG; + assert_eq!(instance_flags(base, None), F::DEBUG); + assert_eq!(instance_flags(base, Some("0")), F::DEBUG); + assert_eq!(instance_flags(F::empty(), Some("1")), F::VALIDATION_INDIRECT_CALL); + assert_eq!(instance_flags(F::empty(), None), F::empty()); + } use super::*; #[test] diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 2f4abd4d2..8d73703c3 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -284,6 +284,8 @@ impl Gpu { desc.backends = backends; // DX12 shaders compile with FXC (issue #471) desc.backend_options = crate::backend::backend_options(); + // no indirect-call validation pipeline: Intel Metal fails to compile it (#250) + desc.flags = crate::backend::instance_flags_from_env(desc.flags); let instance = wgpu::Instance::new(desc); let adapter = pollster::block_on( instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 689aecbec..ca0975e9f 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -86,6 +86,8 @@ fn make_device(backends: wgpu::Backends) -> Result { desc.backends = backends; // DX12 shaders compile with FXC (issue #471) desc.backend_options = crate::backend::backend_options(); + // no indirect-call validation pipeline: Intel Metal fails to compile it (#250) + desc.flags = crate::backend::instance_flags_from_env(desc.flags); let instance = wgpu::Instance::new(desc); let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()); let adapter = match &wanted { From 19833e56e36f8fac33d42e613a31d014f5b71783 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:50:12 +0200 Subject: [PATCH 08/42] MENU-HELP-SYSINFO: System Info, All Metadata and What's New end with one Close button (#782) Their confirm does nothing, so they join About, Shortcuts and Settings in the footer's informational list: one Close, no Cancel / OK. System Info keeps Copy to Clipboard; Esc still closes them. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 9 ++++- crates/ui-egui/src/tests_info_dialogs.rs | 50 ++++++++++++++++++++++++ docs/parity.md | 2 +- docs/whats-new.md | 4 ++ 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/tests_info_dialogs.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..f45ce7a93 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,8 @@ mod tests_filmstrip; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_info_dialogs; +#[cfg(test)] mod tests_keymap; #[cfg(test)] mod tests_keyword_files; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..11593f274 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -948,9 +948,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } ui.add_space(4.0); ui.horizontal(|ui| { - // a model file LightCraft cannot use has nothing to confirm + // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that + // only shows something (one Close, no Cancel / OK) let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model || matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. }); + let informational = unusable_model + || matches!( + dlg, + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + ); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); // no download location in this build: nothing to offer but the manual install diff --git a/crates/ui-egui/src/tests_info_dialogs.rs b/crates/ui-egui/src/tests_info_dialogs.rs new file mode 100644 index 000000000..7fd07384c --- /dev/null +++ b/crates/ui-egui/src/tests_info_dialogs.rs @@ -0,0 +1,50 @@ +//! #782: dialogs that only show something (System Info, All Metadata, What's New) end with one +//! Close, like About, instead of a Cancel / OK pair that both just close them. + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; +use serde_json::json; +use std::time::Duration; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(60); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +/// Open the dialog through its menu item; let the new window size itself before reading it. +fn open(h: &mut Headless, id: &str) { + let r = h.request("ui.menu.invoke", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + assert!(h.app.ui.dialog.is_some(), "{id}: no dialog"); + h.step(); + h.step(); +} + +#[test] +fn information_dialogs_end_with_one_close() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + h.settle(SETTLE); + for id in ["app.systemInfo", "dialog.allMetadata", "app.whatsNew"] { + open(&mut h, id); + assert!(!has(&h, "button:dialogCancel"), "{id}: a Cancel button, though there is nothing to confirm"); + assert!(has(&h, "button:dialogOk"), "{id}: no Close button"); + let close = h.app.widgets.iter().filter(|(w, _)| w == "button:dialogOk").count(); + assert_eq!(close, 1, "{id}: one footer button"); + let text = h.painted_text(); + assert!(text.iter().any(|t| t == "Close"), "{id}: the button reads Close: {text:?}"); + assert!(!text.iter().any(|t| t == "Cancel" || t == "OK"), "{id}: no Cancel / OK: {text:?}"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "{id}: Close closes the dialog"); + // Esc still closes it too + open(&mut h, id); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + assert!(h.app.ui.dialog.is_none(), "{id}: Esc closes the dialog"); + } +} diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..7a407fc14 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -631,7 +631,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | | MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub | | MENU-HELP-LOGFOLDER | Open log folder | P2 | ✅ | `cmd:app.openLogFolder` | an extra (the reference app has no such item): Help ▸ Open Log Folder reveals `logs/lightcraft.log` in the system file manager (Finder, Explorer, or the folder on Linux); off on the web and in `--memory` sessions, which keep no log (issue #260) | -| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) | +| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..812a06dfb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -95,6 +95,10 @@ Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit Settings. +### Dialogs +- System Info, All Metadata and What's New end with one Close button, like About, instead of Cancel and OK + (issue #782). + ### Synchronize Folder - Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose From 9da719c6ed5e8b978abee45434332c9ed728a8f9 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 14:01:26 -0400 Subject: [PATCH 09/42] LR-EDIT-RESET: Reset Edits returns a raw to its import defaults, sharpening and colour NR included Reset Edits rebuilt a photo without a default-preset look from DevelopSettings::default(), so a raw imported with capture sharpening 40 and colour noise reduction 25 (DevelopSettings::for_raw) came back with both at 0, and with its embedded lens corrections off. It now starts from the photo's own built-in defaults (Photo::camera_defaults, the same as import) and keeps the white balance of its current as-shot reference, as before. Rendered files are unchanged (their defaults are DevelopSettings::default()). Reported in #763's notes. --- crates/engine/src/cmd/develop.rs | 6 +++++- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 33570f47b..3a84b1dcc 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -239,10 +239,14 @@ pub fn specs() -> Vec { let fresh = match look { Some(l) => (*l).clone(), None => { + // the photo's built-in defaults (raws: capture sharpening and colour noise + // reduction, embedded lens corrections), as at import, with the white balance + // of its current as-shot reference let info = s.source_info(id); + let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default(); DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint }, - ..Default::default() + ..defaults } } }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cea8475e6..489b92cb3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -37,6 +37,33 @@ fn changing_one_wb_control_resolves_as_shot_without_stale_tint() { } } +/// Reset Edits on a raw goes back to its import defaults, capture sharpening and colour noise +/// reduction included (#763: reset left both at 0 while import sets 40 / 25); a rendered file +/// keeps none. +#[test] +fn reset_edits_restores_a_raws_import_defaults() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + use lightcraft_develop::DevelopSettings; + let mut s = demo(); + for (id, format, raw) in [(200u64, "NEF", true), (201, "JPG", false)] { + let name = format!("p{id}.{format}"); + let mut p = Photo::new(PhotoId(id), Source::File { path: name.clone() }, &name, format, 16, 16, ""); + if raw { + p.as_shot_wb = Some((5200.0, 4.0)); + p.develop = std::sync::Arc::new(DevelopSettings::for_raw(5200.0, 4.0)); + } + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + for (id, want) in [(200, (40.0, 25.0)), (201, (0.0, 0.0))] { + s.execute("library.select", &json!({"ids": [id], "active": id})).unwrap(); + s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 90})).unwrap(); + s.execute("develop.set", &json!({"control": "detail.nrColor", "value": 70})).unwrap(); + s.execute("develop.reset", &json!({})).unwrap(); + let d = active_dev(&s); + assert_eq!((d.detail.sharpen_amount, d.detail.nr_color), want, "photo {id}"); + } +} + #[test] fn demo_library_loads() { let mut s = demo(); From 7f966965fc1497f2d7a2d3d0792e1e69b106b8c2 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 10 Oct 2026 20:56:23 +0200 Subject: [PATCH 10/42] Fix #714: mouse wheel zooms the loupe around the pointer A notched wheel (line/page units, no modifiers) zoomed nothing; only double-click and pinch/Ctrl+wheel did. Trackpad scrolls still pan. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 44 ++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..f76739151 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -153,13 +153,37 @@ pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: e let _ = app.run("view.navigate", json!({"pan": pan})); } +/// Zoom factor of one mouse-wheel notch. +const WHEEL_ZOOM_STEP: f32 = 1.15; + +/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. +fn wheel_notches(events: &[egui::Event]) -> f32 { + events + .iter() + .filter_map(|e| match e { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. } + if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() => + { + Some(delta.y) + } + _ => None, + }) + .sum() +} + /// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view. pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool { if !resp.hovered() { return false; } - let (factor, delta, anchor) = + let (mut factor, mut delta, anchor) = ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos()))); + // A notched mouse wheel (line/page units, no modifier) zooms around the pointer; trackpads report points and still pan. + let notches = ui.input(|i| wheel_notches(&i.events)); + if notches != 0.0 { + factor *= WHEEL_ZOOM_STEP.powf(notches); + delta = egui::Vec2::ZERO; + } if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) { return false; } @@ -2173,3 +2197,21 @@ mod tests { assert_eq!(film_label(""), ""); } } + +#[cfg(test)] +mod wheel_zoom_tests { + use super::*; + + fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers } + } + + #[test] + fn only_notched_unmodified_wheels_count() { + let m = egui::Modifiers::NONE; + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0); + assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0); + } +} From 732a2f04fddd78742b73fe38bc577b10df6b792d Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 10 Oct 2026 20:59:29 +0200 Subject: [PATCH 11/42] Test: wheel zoom in the loupe with and without Ctrl Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/headless.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d0104f7de..1e4f3e121 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3347,6 +3347,28 @@ mod tests { assert_eq!(h.app.ui.pan, (0.5, 0.5)); } + #[test] + fn mouse_wheel_zooms_the_loupe_with_and_without_ctrl() { + use crate::state::Zoom; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none", "leftPanel": false, "filmstrip": false}), t); + h.settle(SETTLE); + for (unit, y, modifiers) in [ + (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::NONE), + (egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL), + (egui::MouseWheelUnit::Point, 50.0, egui::Modifiers::CTRL), + ] { + h.request("ui.set", json!({"zoom": "fit", "pan": [0.5, 0.5]}), t); + h.request("ui.hoverWidget", json!({"id": "canvas:image"}), t); + h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers }); + for _ in 0..10 { + h.step(); + } + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "wheel {unit:?} {modifiers:?} must zoom in, got {:?}", h.app.ui.zoom); + } + } + #[test] fn trackpad_navigation_works_in_tools_compare_reference_and_before_after() { use crate::state::Zoom; From 3cd471b32dd94ecdfa793a01b961860452ae7f0e Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 10 Oct 2026 21:08:25 +0200 Subject: [PATCH 12/42] Smooth the plain mouse-wheel zoom like Ctrl+wheel Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index f76739151..1610d3668 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -153,9 +153,6 @@ pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: e let _ = app.run("view.navigate", json!({"pan": pan})); } -/// Zoom factor of one mouse-wheel notch. -const WHEEL_ZOOM_STEP: f32 = 1.15; - /// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad. fn wheel_notches(events: &[egui::Event]) -> f32 { events @@ -178,11 +175,21 @@ pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: } let (mut factor, mut delta, anchor) = ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos()))); - // A notched mouse wheel (line/page units, no modifier) zooms around the pointer; trackpads report points and still pan. - let notches = ui.input(|i| wheel_notches(&i.events)); - if notches != 0.0 { - factor *= WHEEL_ZOOM_STEP.powf(notches); - delta = egui::Vec2::ZERO; + // A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel + // delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan. + let wheel_id = egui::Id::new("loupe-wheel-zoom"); + if ui.input(|i| wheel_notches(&i.events)) != 0.0 { + ui.data_mut(|d| d.insert_temp(wheel_id, true)); + } + if ui.data(|d| d.get_temp::(wheel_id)).unwrap_or(false) { + let smooth = ui.input(|i| i.smooth_scroll_delta.y); + let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); + if smooth == 0.0 { + ui.data_mut(|d| d.remove_temp::(wheel_id)); + } else { + factor *= (speed * smooth).exp(); + delta = egui::Vec2::ZERO; + } } if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) { return false; From e1267a713949277e9ceac712cfc8ae18b26a578e Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 10 Oct 2026 21:55:57 +0200 Subject: [PATCH 13/42] UI-MODAL: block background input while application dialogs are open Co-Authored-By: GPT-6.1 Sol --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 18 ++++- crates/ui-egui/src/shortcuts.rs | 4 ++ crates/ui-egui/src/tests_dialogs.rs | 98 ++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tests_dialogs.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..578b09b94 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,8 @@ mod tests_curve; #[cfg(test)] mod tests_date_picker; #[cfg(test)] +mod tests_dialogs; +#[cfg(test)] mod tests_filmstrip; #[cfg(test)] mod tests_grid; @@ -1013,6 +1015,7 @@ impl LightcraftApp { ctx.request_repaint(); return; } + panels::dialogs::prepare(self, ui); let t0 = now_ms(); if std::mem::take(&mut self.ui.quit) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..95e3ca7bb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -60,6 +60,21 @@ pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", " /// Help ▸ What's New (docs/whats-new.md). pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); +fn window_id(dialog: &Dialog) -> egui::Id { + egui::Id::new(if matches!(dialog, Dialog::Import { .. }) { "lightcraft-import-dialog" } else { "lightcraft-dialog" }) +} + +/// Block the underlying panels from the first frame, before the window establishes its modal +/// layer. Close background menus on entry; popups subsequently opened inside the dialog stay usable. +pub(crate) fn prepare(app: &LightcraftApp, ui: &mut egui::Ui) { + let Some(dialog) = &app.ui.dialog else { return }; + ui.disable(); + let layer = egui::LayerId::new(egui::Order::Middle, window_id(dialog)); + if ui.ctx().memory(|m| m.top_modal_layer()) != Some(layer) { + egui::Popup::close_all(ui.ctx()); + } +} + pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(mut dlg) = app.ui.dialog.clone() else { return }; let at_start = dlg.clone(); @@ -127,7 +142,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); - let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" }); + let window_id = window_id(&dlg); let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) .resizable(import) @@ -1022,6 +1037,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); if let Some(w) = shown { + ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); ctx.move_to_top(w.response.layer_id); crate::widgets::register(ctx, "dialog:window", w.response.rect); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2abea5fa4..877ad6b83 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -304,6 +304,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) { app.recording_shortcut = None; } + // Dialogs own their keys (including Esc and text editing); background photo actions wait. + if app.ui.dialog.is_some() { + return; + } // don't steal keys from text fields or from the keymap editor recording a shortcut if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { return; diff --git a/crates/ui-egui/src/tests_dialogs.rs b/crates/ui-egui/src/tests_dialogs.rs new file mode 100644 index 000000000..6581640ec --- /dev/null +++ b/crates/ui-egui/src/tests_dialogs.rs @@ -0,0 +1,98 @@ +//! Modal application dialogs block background input while retaining their own controls. + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; +use serde_json::json; +use std::time::Duration; + +const T: Duration = Duration::from_secs(20); + +fn demo(size: [f32; 2], ui: serde_json::Value) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, size, 1.0); + let r = h.request("ui.set", ui, T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(Duration::from_secs(120)); + h +} + +/// Application dialogs block the toolbar, including the gear that would reopen Copy Settings +/// and discard its choices. Their own controls work, and closing restores background input. +#[test] +fn modal_dialogs_block_background_clicks_and_preserve_choices() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + let active = h.app.session.active().unwrap(); + let rating = h.app.session.catalog.photo(active).unwrap().rating; + h.app.run("dialog.copySettings", json!({})).unwrap(); + // Also exercise the first frame of a newly opened dialog. + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating, "the dialog blocks background ratings"); + let r = h.request("ui.clickWidget", json!({"id": "copyGroup:none"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let choices = h.app.ui.dialog.clone(); + assert!(matches!(&choices, Some(crate::state::Dialog::CopySettings { groups }) if groups.is_empty())); + let r = h.request("ui.clickWidget", json!({"id": "icon:copyGear"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.dialog, choices, "a background click cannot reopen the dialog and reset its choices"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none()); + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores toolbar input"); + // A different dialog uses the same barrier. + h.app.run("app.settings", json!({})).unwrap(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "star:bottom:1"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn modal_dialogs_block_background_shortcuts_and_keep_escape() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + let active = h.app.session.active().unwrap(); + let rating = h.app.session.catalog.photo(active).unwrap().rating; + h.app.run("dialog.copySettings", json!({})).unwrap(); + h.step(); + for key in ["5", "ArrowRight", "J"] { + let r = h.request("ui.key", json!({"key": key}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, rating); + assert_eq!(h.app.session.active(), Some(active)); + assert!(!h.app.ui.show_clipping); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.dialog.is_none()); + let r = h.request("ui.key", json!({"key": "5"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 5, "closing restores shortcuts"); +} + +#[test] +fn modal_dialogs_close_background_menus() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + let r = h.request("ui.clickWidget", json!({"id": "menu:Photo"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(egui::Popup::is_any_open(&h.view.ctx)); + h.app.run("dialog.copySettings", json!({})).unwrap(); + h.step(); + assert!(!egui::Popup::is_any_open(&h.view.ctx), "background menus cannot remain above the modal window"); +} From fe92e844d02dfd0b6373abea8a2d91159b387d12 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Sat, 10 Oct 2026 22:06:52 +0200 Subject: [PATCH 14/42] UI-PILL: keep rating and flag buttons inside their pill Co-Authored-By: GPT-6.1 Sol --- crates/ui-egui/src/panels/bottombar.rs | 49 ++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index de0409ec1..4361cb0df 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -47,8 +47,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// Width of the centre group: the rating/flag pill, then Copy/Paste Edit Settings and its gear. -const PILL_W: f32 = 196.0; +/// Five 19-point stars, three 22-point flags, seven 3-point gaps, a 6-point group gap +/// and 10-point padding at both ends. +const PILL_W: f32 = 208.0; /// The rating/flag pill and copy/paste settings, between `from` and `to` (the side groups): the /// copy buttons go first when there is no room (they are in the Edit menu too), then the pill. @@ -270,3 +271,47 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use crate::headless::HeadlessView; + + #[test] + fn rating_and_flag_buttons_keep_padding_inside_the_pill() { + for scale in [1.0, 2.0] { + for room in [240.0, 500.0] { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut view = HeadlessView::new(); + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(700.0, 40.0), scale, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| { + let full = ui.max_rect(); + centre(&mut app, ui, full, 20.0, 20.0 + room); + }); + let widgets = crate::widgets::take_registry(&view.ctx); + let star = widgets.iter().find(|(id, _)| id == "star:bottom:1").unwrap().1; + let pill = view + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) if rect.rect.contains(star.center()) && rect.rect.height() == 30.0 => Some(rect.rect), + _ => None, + }) + .unwrap(); + let inner = pill.shrink2(vec2(10.0, 4.0)); + for (id, rect) in &widgets { + if id.starts_with("star:bottom:") || id.starts_with("flag:") { + assert!(inner.contains_rect(*rect), "{id} {rect:?} exceeds the pill's padded bounds {inner:?} at scale {scale}"); + } + } + assert!(pill.left() >= 20.0 && pill.right() <= 20.0 + room); + if let Some((_, copy)) = widgets.iter().find(|(id, _)| id == "button:copySettings") { + assert!(copy.left() >= pill.right() + 10.0); + } + } + } + } + } +} From bc5c561dd8976aa39958ee2f4e12e95141a0f7e9 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:34:46 +0200 Subject: [PATCH 15/42] LR-VIEW-BEFOREAFTER: zoomed side by side / stacked views stay in their panes Zoomed in, each view's photo reaches past its pane. The whole-frame renders were drawn unclipped (only the window renders were clipped to their pane), so the After's magnified whole-frame render covered the Before pane's margins and the gap between the panes with a blurred picture, and the Before side looked like it didn't fill its pane. Both whole-frame renders are now clipped to their pane, like the windows over them; the Before / After labels stay under the pane instead of moving off screen with the photo's bottom edge. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 20 +++++++++------ crates/ui-egui/src/tests_zoom.rs | 39 +++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..142ac1a43 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -531,7 +531,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let p = ui.painter_at(canvas); // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame // between photos, and the full render replaces them in place) - let draw = |slot: Slot, r: Rect| -> &'static str { + let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str { let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); let (tex, what) = if let Some(t) = mine(slot) { (t, "render") @@ -543,7 +543,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return "none"; }; let image_rect = fit_texture_rect(r, tex.size); - p.image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it @@ -554,13 +554,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let shown; if split { + // each view stays in its pane: zoomed in, a photo reaches past it (and would cover the + // other view's margins with its whole-frame render, magnified and blurred) let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); - draw(Slot::Before, br); - shown = draw(Slot::Main, img_rect); - p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); - p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); + draw(Slot::Before, br, areas[0]); + shown = draw(Slot::Main, img_rect, main_area); + // the labels under each photo, or under its pane when the photo reaches past it + let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); + p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); + p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); } else if show_before { - shown = draw(Slot::Before, img_rect); + shown = draw(Slot::Before, img_rect, canvas); p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text); } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) @@ -573,7 +577,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); shown = "hover"; } else { - shown = draw(Slot::Main, img_rect); + shown = draw(Slot::Main, img_rect, canvas); if shown == "none" { match app.renderer.failure(Slot::Main) { Some(e) => { diff --git a/crates/ui-egui/src/tests_zoom.rs b/crates/ui-egui/src/tests_zoom.rs index b90506073..ddea400d0 100644 --- a/crates/ui-egui/src/tests_zoom.rs +++ b/crates/ui-egui/src/tests_zoom.rs @@ -69,3 +69,42 @@ fn a_rotated_photo_at_100_is_its_pixel_size_swapped() { let (w, hh) = at_100(&mut h); assert!((w - nh).abs() < 1.5 && (hh - nw).abs() < 1.5, "{w}×{hh} for {nh}×{nw}"); } + +/// Zoomed in, side by side and stacked Before / After views each stay in their pane: the margins +/// around the panes and the gap between them show the background, as at Fit. The whole-frame +/// renders used to be drawn unclipped, so the After's (magnified, blurred) covered the Before +/// pane's margins. +#[test] +fn zoomed_before_after_views_stay_in_their_panes() { + use crate::state::{BeforeAfter, Zoom}; + let mut h = detail(); + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] { + let mut shots = Vec::new(); + for zoom in [Zoom::Fit, Zoom::Percent(100.0)] { + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + h.app.ui.pan = (0.3, 0.4); + h.settle(SETTLE); + let img = h.app.image_rect.expect("After view on screen"); + let canvas = h.app.canvas_rect.expect("canvas"); + shots.push((h.paint(), img, canvas)); + } + let (zoomed, img, canvas) = &shots[1]; + assert!(!canvas.contains_rect(*img), "{mode:?}: 100 % reaches past the pane ({img:?})"); + // outside both panes: the canvas' edges and the gap between the panes + let c = canvas.center(); + let points = [ + egui::pos2(canvas.left() + 4.0, c.y), + egui::pos2(c.x, canvas.top() + 4.0), + egui::pos2(c.x, canvas.bottom() - 4.0), + match mode { + BeforeAfter::SideBySide => c, + _ => egui::pos2(canvas.left() + 200.0, c.y), + }, + ]; + for q in points { + let px = |shot: &egui::ColorImage| shot[(q.x as usize, q.y as usize)]; + assert_eq!(px(zoomed), px(&shots[0].0), "{mode:?}: background at {q:?}, zoomed in as at Fit"); + } + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..aeb6370d1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -130,7 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session. Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | From 0adcb211662b1a16e764465de4fabdfe56b04c26 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:12:19 +0200 Subject: [PATCH 16/42] LR-CROP-FLIP: Flip H / Flip V show in the crop tool The frame dropped the flips together with the crop (flip_h: apply_crop && ...), so the crop tool's uncropped view (render and screen <-> image mapping) ignored Flip H / Flip V until the tool was closed. A flip isn't part of the crop: the frame now keeps it without the crop. Cropped renders and exports are unchanged. The crop overlay's dimming paired the photo's corners with the crop box's corners in data order; flipped, the box reaches the screen mirrored and the photo was dimmed inside the box. It now takes the box's corners in screen order (flipped_corners). Box, handles, drags and guides already go through the frame's mapping. Tests: geometry uncropped_frame_keeps_the_flips (fails without the fix) and detail flipped_crop_box_corners_come_in_screen_order; headless screenshots for H, V and H+V checked. Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/geometry.rs | 23 +++++++++++++++-- crates/ui-egui/src/panels/detail.rs | 38 ++++++++++++++++++++++++++--- docs/parity-checklist.md | 2 +- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index c99bde452..a02de1235 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -71,8 +71,9 @@ impl Frame { ow, oh, crop, - flip_h: apply_crop && s.crop.flip_h, - flip_v: apply_crop && s.crop.flip_v, + // a flip is not part of the crop: the crop tool's uncropped view shows it too + flip_h: s.crop.flip_h, + flip_v: s.crop.flip_v, warp: (!warp.is_identity()).then_some(warp), view: None, }; @@ -387,4 +388,22 @@ mod tests { assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5); assert!((out.get(3, 0)[0] - 0.0).abs() < 1e-5); } + + /// The crop tool's uncropped frame keeps the flips: Flip H / Flip V show while cropping, and + /// screen points map back through the mirror (the crop box is drawn and dragged right). + #[test] + fn uncropped_frame_keeps_the_flips() { + let mut s = DevelopSettings::default(); + s.crop.flip_h = true; + s.crop.flip_v = true; + s.crop.geometry.rect = Rect::new(0.1, 0.2, 0.6, 0.9); + let src = Rgb32f::from_fn(4, 2, |x, y| [x as f32, y as f32, 0.0]); + let f = Frame::new(4, 2, &s, false); + let out = f.sample(&src, 4, 2); + assert!((out.get(0, 0)[0] - 3.0).abs() < 1e-5 && (out.get(0, 0)[1] - 1.0).abs() < 1e-5, "{:?}", out.get(0, 0)); + assert!((out.get(3, 1)[0] - 0.0).abs() < 1e-5 && (out.get(3, 1)[1] - 0.0).abs() < 1e-5, "{:?}", out.get(3, 1)); + // the whole image, mirrored: output top-left is the image's bottom-right + let p = f.out_to_norm(400, 200).apply(Point::new(0.0, 0.0)); + assert!((p.x - 1.0).abs() < 1e-9 && (p.y - 1.0).abs() < 1e-9, "{p:?}"); + } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..89ed1eadd 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1220,11 +1220,13 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let shade = Color32::from_black_alpha(150); let mut mesh = egui::epaint::Mesh::default(); let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()]; + // the box's corners in screen order (a flipped photo shows its top-left corner elsewhere) + let on_screen = flipped_corners(&pts, frame.flip_h, frame.flip_v); for i in 0..4 { let a = outer[i]; let b = outer[(i + 1) % 4]; - let c = pts[(i + 1) % 4]; - let dd = pts[i]; + let c = on_screen[(i + 1) % 4]; + let dd = on_screen[i]; let base = mesh.vertices.len() as u32; for v in [a, b, c, dd] { mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade }); @@ -1451,6 +1453,19 @@ fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point { Point::new(r.x / w, r.y / h) } +/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo) +/// in the order they appear on screen when the photo is shown flipped. +fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] { + let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO); + let order = match (flip_h, flip_v) { + (false, false) => [0, 1, 2, 3], + (true, false) => [1, 0, 3, 2], + (false, true) => [3, 2, 1, 0], + (true, true) => [2, 3, 0, 1], + }; + order.map(at) +} + fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { let f = Frame { crop: d.crop.geometry, ..frame.clone() }; f.crop_quad_norm() @@ -2143,7 +2158,8 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R #[cfg(test)] mod tests { - use super::{film_label, texture_side}; + use super::{film_label, flipped_corners, texture_side}; + use egui::pos2; /// Issue #652: a native host reports the GPU's texture limit in the first frame's input only. /// The loupe must still know it in every later frame (it read the raw input, found nothing and @@ -2172,4 +2188,20 @@ mod tests { assert_eq!(film_label("DSC_0001"), "DSC_0001"); assert_eq!(film_label(""), ""); } + + /// With the photo flipped, the crop box's corners reach the screen mirrored; the dimming + /// outside the box pairs each photo corner with the box corner on the same side of the screen + /// (it used to pair them in data order, so a flipped photo was dimmed inside the box). + #[test] + fn flipped_crop_box_corners_come_in_screen_order() { + let screen = [pos2(10.0, 10.0), pos2(90.0, 10.0), pos2(90.0, 50.0), pos2(10.0, 50.0)]; + // data order (top-left, top-right, bottom-right, bottom-left) as each flip shows it + let h = [screen[1], screen[0], screen[3], screen[2]]; + let v = [screen[3], screen[2], screen[1], screen[0]]; + let hv = [screen[2], screen[3], screen[0], screen[1]]; + assert_eq!(flipped_corners(&screen, false, false), screen); + assert_eq!(flipped_corners(&h, true, false), screen); + assert_eq!(flipped_corners(&v, false, true), screen); + assert_eq!(flipped_corners(&hv, true, true), screen); + } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..05743e732 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | -| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | +| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical`, `crates/pipeline/src/geometry.rs` | the crop tool's uncropped view shows the flips too, with the crop box and its dimming mirrored to match | | LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays | | LR-CROP-ZOOM | Zoom while cropping | P1 | ✅ | `cmd:view.zoom100`, `cmd:view.zoomIn`, `crates/ui-egui/src/panels/detail.rs` | zoom levels apply with the crop tool open (verified: 100% shows the photo at native size with the crop frame) | | LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | | From 0067879a584f8e3c17c4473124038a6c7ef31d33 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:14:09 +0200 Subject: [PATCH 17/42] KEY-ESC: Esc leaves the crop tool before leaving Detail, and a text field's Esc stays its own view.back (Esc) closed dialogs, the keyword painter, full screen and an armed on-image tool, but not the crop tool (a right panel, not app.ui.tool), so Esc with the crop tool open went straight to the grid. It now closes the crop tool like C does (the crop stays); the next Esc goes back to the grid. Esc that ends an edit in a text field (the crop tool's Angle field) also ran Back: egui drops the field's focus as Esc arrives, before shortcuts::handle checks egui_wants_keyboard_input(). The app now records whether a text field had the keyboard at the end of the last frame (LightcraftApp::text_focus); that Esc is then left to the field, like one that closes a popup. New headless tests in tests_crop_tool.rs; both fail without the fix (the existing escape_in_the_angle_field_keeps_the_angle fails with only the crop step). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 7 +++ crates/ui-egui/src/menus.rs | 3 ++ crates/ui-egui/src/shortcuts.rs | 2 + crates/ui-egui/src/tests_crop_tool.rs | 64 +++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tests_crop_tool.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..c3e2a4ca4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,8 @@ mod tests_album_picker; #[cfg(test)] mod tests_crop_rotate; #[cfg(test)] +mod tests_crop_tool; +#[cfg(test)] mod tests_curve; #[cfg(test)] mod tests_date_picker; @@ -229,6 +231,9 @@ pub struct LightcraftApp { /// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut /// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled. pub recording_shortcut: Option, + /// A text field had the keyboard at the end of the last frame. egui drops the focus as Esc + /// arrives, before the shortcuts are read, so this is how Esc there stays the field's own. + pub text_focus: bool, /// The host is [`headless::Headless`] (it answers viewport screenshot commands itself). pub headless_host: bool, /// Warnings to show one at a time (damaged settings files…, issue #103). @@ -341,6 +346,7 @@ impl LightcraftApp { native_menu: false, native_shortcuts: Default::default(), recording_shortcut: None, + text_focus: false, headless_host: false, notices: vec![], quit_prompt: None, @@ -1074,6 +1080,7 @@ impl LightcraftApp { panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); + self.text_focus = ctx.egui_wants_keyboard_input(); self.end_frame(&ctx, t0); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aff0326a4..9ed3197e6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -401,6 +401,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Date: Sat, 10 Oct 2026 17:20:31 -0400 Subject: [PATCH 18/42] LR-IMP-FORMATS: CR3 CRX quantization 44 (QP 168) decodes, continuing the step sequence Canon EOS R50 C-RAW files reach QP 168 (quant 44, step 102, measured about 100). The quantization step and the adaptive QP map range accept it; anything above stays rejected. Bumps RENDER_CACHE_VERSION since such files change from the embedded preview to a raw decode. --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/crx.rs | 18 +++++++++++++----- docs/cr3.md | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..d863060ec 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 = 42; +pub const RENDER_CACHE_VERSION: u64 = 43; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 2d15627fa..64ca37071 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -330,8 +330,10 @@ fn tile_qp(sample: &[u8], tile: &Tile) -> Result> { fn quant_step(value: u8) -> Result { // Measured independently with an external decoder after modifying only the - // quantValue field: all forty values 4..43 follow this integer sequence. - if !(4..=43).contains(&value) { + // quantValue field: all forty values 4..43 follow this integer sequence. Value 44 (QP 168) + // continues it (step 102); it was checked against a measured step of about 100 on an EOS R50 + // C-RAW file. Nothing above 44 has been seen, so it stays rejected. + if !(4..=44).contains(&value) { return Err(RawError::Unsupported(format!("Canon CRX quantization value {value}"))); } let scale = [40i32, 45, 51, 57, 64, 72].get(usize::from(value % 6)).copied().ok_or_else(|| corrupt("quantization table index"))?; @@ -754,7 +756,7 @@ fn qp_map(src: &[u8], width: usize, height: usize) -> Result { let right = previous.get(x + 1).copied().unwrap_or(above); let symbol = bits.qp_rice(k)?; let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?; - if !(131..=167).contains(&value) { + if !(131..=168).contains(&value) { return Err(RawError::Unsupported(format!("Canon CRX adaptive QP value {value}"))); } *current.get_mut(x).ok_or_else(|| corrupt("QP outside row"))? = value; @@ -968,11 +970,11 @@ mod tests { #[test] fn measured_quantization_and_allocation_limits() { - for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90)] { + for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90), (44, 102)] { assert_eq!(quant_step(quant).unwrap(), expected); } assert!(matches!(quant_step(3), Err(RawError::Unsupported(_)))); - assert!(matches!(quant_step(44), Err(RawError::Unsupported(_)))); + assert!(matches!(quant_step(45), Err(RawError::Unsupported(_)))); for (width, height) in [(20_000, 20_000), (65_538, 2), (2, 65_538)] { let mut config = coding(); config.width = width; @@ -1003,6 +1005,12 @@ mod tests { assert!(matches!(qp_map(&[0, 0, 1, 4, 0x94], 1, 4), Err(RawError::Unsupported(_)))); } + #[test] + fn qp_168_is_accepted_and_169_is_not() { + assert_eq!(qp_map(&[0, 0, 1, 0x50, 0x94], 1, 4).unwrap().data, [168; 4]); + assert!(matches!(qp_map(&[0, 0, 1, 0x52, 0x94], 1, 4), Err(RawError::Unsupported(_)))); + } + #[test] fn odd_height_qp_map_uses_its_last_row_alone() { // EOS R7 C-RAW: 4732 sensor rows give a 1183-row QP map, so the last diff --git a/docs/cr3.md b/docs/cr3.md index ac13d3fda..9b0cc4c72 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -26,7 +26,7 @@ decoded. CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers support lossless RAW and three-level C-RAW with horizontal tiles. Uniform -quantization values 4–43 are independently verified. Version `0x200` C-RAW +quantization values 4–43 are independently verified; 44 continues the sequence (step 102, checked against a measured step on an EOS R50 C-RAW file). Version `0x200` C-RAW supports the `ff11`/`ff12`/`ff13` marker family, a single 14-bit Bayer tile and its adaptive quantization map. C-RAW is quantized, so next to clipped highlights its reconstruction can overshoot the sensor range by a few quantization steps From 8e9c5aff7ee1fa46097312a99e74fe40450fc8ac Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:07:11 +0200 Subject: [PATCH 19/42] LR-EDIT-COLOR-WB-PICKER: the selector button does what W does The White Balance Selector button in the Edit panel only armed the tool, so in Compare (which doesn't handle on-image tools) it did nothing while W opened the photo in Detail with the selector armed. The button now runs tool.wbPicker like W; a second click still disarms it. New headless test tests_wb_selector.rs clicks the button in Compare and expects Detail with the selector armed on the photo that was active; it fails without the fix (the view stays Compare). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/edit.rs | 7 +++- crates/ui-egui/src/tests_wb_selector.rs | 44 +++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/tests_wb_selector.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..cd55116d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -89,6 +89,8 @@ mod tests_titlebar; #[cfg(test)] mod tests_unsaved; #[cfg(test)] +mod tests_wb_selector; +#[cfg(test)] mod tests_zoom; #[cfg(test)] mod tests_zoom_keys; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 56f405ea3..5567c8beb 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -216,7 +216,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)") .clicked() { - app.ui.tool = if active { String::new() } else { "wbPicker".into() }; + if active { + app.ui.tool.clear(); + } else if let Err(e) = app.run("tool.wbPicker", json!({})) { + // the same as W: from Compare, Survey or the grid it opens the photo in Detail + app.toast(ui.ctx(), e); + } } }); }); diff --git a/crates/ui-egui/src/tests_wb_selector.rs b/crates/ui-egui/src/tests_wb_selector.rs new file mode 100644 index 000000000..32a881acd --- /dev/null +++ b/crates/ui-egui/src/tests_wb_selector.rs @@ -0,0 +1,44 @@ +//! Headless tests of the White Balance Selector's button. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The White Balance Selector button in the Edit panel does what W does: from Compare it opens +/// the active photo in Detail with the selector armed (it used to arm a tool Compare ignores). +/// Clicking it again disarms it. +#[test] +fn wb_selector_button_in_compare_opens_detail_like_w() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + // open the Color section, where the selector sits + if !h.app.widgets.iter().any(|(w, _)| w == "icon:wbPicker") { + h.request("ui.clickWidget", json!({"id": "section:color"}), T); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.view, ViewMode::Compare); + let active = h.app.session.selection.active; + let r = h.request("ui.clickWidget", json!({"id": "icon:wbPicker"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.view, ViewMode::Detail, "the button leaves Compare like W"); + assert_eq!(h.app.ui.tool, "wbPicker"); + assert_eq!(h.app.session.selection.active, active, "on the photo that was active in Compare"); + let r = h.request("ui.clickWidget", json!({"id": "icon:wbPicker"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.tool, "", "a second click disarms it"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..67ba5aca3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -188,7 +188,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | -| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking | +| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | | LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | From a940dab72760d706fe8132eecb7e50e0247581d8 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:37:52 +0300 Subject: [PATCH 20/42] web: stop the release zip shipping Cargo build artifacts lightcraft-web-.zip carried Cargo host build output next to the site: deps/ (81 files, 90.6 MB), build/ (149 files, 22.4 MB) and .fingerprint/ (297 files), ~113 MB that the app never references and that leaked build paths and crate names to anyone hosting the zip. Cause: xtask wrote the bundle to /web (SITE in packaging/web/package.sh) while building with --profile web. Cross-compiling with --target wasm32-unknown-unknown makes Cargo write the selected profile host artifacts (build scripts, proc-macros, deps/, build/, .fingerprint/) to //, so they landed in the bundle directory and package.sh copied the whole tree into the zip. Fix: write the bundle to /web-site, a directory that is not a Cargo profile name, and point SITE at it. The wasm32/web/ target artifacts and the web profile are unchanged, so the rust-cache keys and the CI web job keep working. Guard: package.sh now fails if deps/, build/ or .fingerprint/ exists in $SITE, and a unit test asserts the bundle directory is not a Cargo profile name, so the collision cannot come back silently. Reference (#793) --- docs/web.md | 2 +- packaging/web/package.sh | 12 +++++++++++- xtask/src/main.rs | 2 +- xtask/src/web.rs | 28 +++++++++++++++++++++++++--- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/web.md b/docs/web.md index 2e26f749b..ca0d98434 100644 --- a/docs/web.md +++ b/docs/web.md @@ -18,7 +18,7 @@ it's missing or a different version. Build, then serve: ```sh -cargo xtask web # → /web/{index.html, worker.js, lightcraft_web.js, lightcraft_web_bg.wasm} +cargo xtask web # → /web-site/{index.html, worker.js, lightcraft_web.js, lightcraft_web_bg.wasm} cargo xtask web --serve # build, then serve on http://127.0.0.1:8080/ (or `--serve 9000`) cargo xtask web --dev # unoptimized build with debug info (faster to compile, slow to run) ``` diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 8e8f0cf08..64b6d2149 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -15,8 +15,18 @@ if [ "${1:-}" != "--skip-build" ]; then (cd "$ROOT" && cargo xtask web) fi -SITE="$CARGO_TARGET_DIR/web" +SITE="$CARGO_TARGET_DIR/web-site" [ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +# Guard against Cargo build artifacts leaking into the release (issue #793): cross-compiling writes +# the selected profile's *host* artifacts (build scripts, proc-macros, deps/, build/, .fingerprint/) +# to //, so if SITE ever points at a profile directory again the zip would carry +# ~113 MB of build output. Fail loudly instead. +for d in deps build .fingerprint; do + if [ -e "$SITE/$d" ]; then + echo "error: $SITE/$d exists — $SITE holds Cargo build artifacts, not just the site (issue #793)" >&2 + exit 1 + fi +done # Paths must be relative so the site works under any prefix. if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cd226d6f2..0d74f93b8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -31,7 +31,7 @@ commands: Lightroom parity summary; --write refreshes the summary table in the document wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app) web [--serve [port]] [--dev] - build the browser app (apps/lightcraft-web) into /web/; + build the browser app (apps/lightcraft-web) into /web-site/; --serve serves it on http://127.0.0.1: (default 8080) ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure) corpus [--download] diff --git a/xtask/src/web.rs b/xtask/src/web.rs index 4e690d634..f10ba489b 100644 --- a/xtask/src/web.rs +++ b/xtask/src/web.rs @@ -1,5 +1,5 @@ //! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen` -//! into `/web/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm), +//! into `/web-site/` (index.html + worker.js + lightcraft_web.js + lightcraft_web_bg.wasm), //! with gzip and brotli precompressed copies (`*.gz`, `*.br`) next to each file. //! `--serve [port]` then serves that folder with a tiny static HTTP server (std only) that sends //! the cross-origin isolation headers (COOP/COEP) and the precompressed files when the browser @@ -13,6 +13,13 @@ use std::process::Command; use crate::{cargo, metadata, root, run as step}; const TARGET: &str = "wasm32-unknown-unknown"; +/// Cargo profile for the optimized (`--dev` off) browser build; see `[profile.web]` in `Cargo.toml`. +const WEB_PROFILE: &str = "web"; +/// Directory under `` that receives the bundle. It must **not** be a Cargo profile name: +/// cross-compiling with `--target wasm32-unknown-unknown` makes Cargo write the selected profile's +/// *host* artifacts (build scripts, proc-macros, `deps/`, `build/`, `.fingerprint/`) to +/// `//`, and `packaging/web/package.sh` zips the whole bundle directory (issue #793). +const SITE_DIR: &str = "web-site"; const CHINESE_FONT_FILE: &str = "lightcraft_zh_hans.otf"; const CHINESE_FONT_LICENSE_FILE: &str = "OFL-noto-sans-cjk-sc.txt"; @@ -100,14 +107,14 @@ pub fn run(args: &[&str]) -> Result<(), String> { check_bindgen(&want)?; let meta = metadata()?; let target_dir = PathBuf::from(meta["target_directory"].as_str().ok_or("cargo metadata: no target_directory")?); - let profile = if dev { "dev" } else { "web" }; + let profile = if dev { "dev" } else { WEB_PROFILE }; let mut c = cargo(); c.args(["build", "-p", "lightcraft-web", "--lib", "--target", TARGET, "--profile", profile]); step(c, &format!("cargo build -p lightcraft-web --target {TARGET} --profile {profile}"))?; let wasm = target_dir.join(TARGET).join(if dev { "debug" } else { profile }).join("lightcraft_web.wasm"); - let out = target_dir.join("web"); + let out = target_dir.join(SITE_DIR); std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?; let mut b = Command::new("wasm-bindgen"); b.arg(&wasm).args(["--target", "web", "--no-typescript", "--out-name", "lightcraft_web", "--out-dir"]).arg(&out); @@ -287,6 +294,21 @@ mod tests { assert!(v.starts_with("0.2."), "{v}"); } + #[test] + fn site_dir_is_not_a_cargo_profile() { + // Cross-compiling writes the selected profile's *host* artifacts (build scripts, + // proc-macros, `deps/`, `build/`, `.fingerprint/`) to `//`, so the bundle + // must not live in a profile directory or `packaging/web/package.sh` would zip them + // (issue #793). + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).unwrap(); + let is_profile = |name: &str| { + let header = format!("[profile.{name}]"); + manifest.lines().any(|l| l.trim() == header) + }; + assert!(is_profile(WEB_PROFILE), "`{WEB_PROFILE}` is no longer a profile in Cargo.toml"); + assert!(!is_profile(SITE_DIR), "`{SITE_DIR}` is a Cargo profile name; pick a different bundle directory"); + } + #[test] fn wasm_mime() { assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm"); From 7ce13cfd0728f3d8f3c77f8b2a639bf3cebf79a8 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 17:51:27 -0400 Subject: [PATCH 21/42] LR-META-EXIF-FULL: All Metadata shows the raw image's Compression, NEFCompression in words (#830) --- crates/meta/src/tags.rs | 781 +++++++++++++++++++++++----------------- 1 file changed, 450 insertions(+), 331 deletions(-) diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index 54e6cf7e2..4e44f3f81 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -1,331 +1,450 @@ -//! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info -//! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; -//! common enumerations are spelled out, everything else is shown as stored. Maker notes and -//! binary blobs are summarised by size. - -use lightcraft_tiff::{Ifd, Tiff, Value}; - -/// One metadata row. -#[derive(Clone, Debug, PartialEq)] -pub struct TagRow { - /// `TIFF`, `EXIF`, `GPS`, `Interop`. - pub group: &'static str, - pub tag: u16, - pub name: String, - pub value: String, -} - -const NAMES: &[(u16, &str)] = &[ - // TIFF / IFD0 - (0x00fe, "Subfile Type"), - (0x0100, "Image Width"), - (0x0101, "Image Height"), - (0x0102, "Bits Per Sample"), - (0x0103, "Compression"), - (0x0106, "Photometric Interpretation"), - (0x010e, "Image Description"), - (0x010f, "Make"), - (0x0110, "Model"), - (0x0112, "Orientation"), - (0x0115, "Samples Per Pixel"), - (0x0116, "Rows Per Strip"), - (0x0214, "Reference Black/White"), - (0x9216, "TIFF/EP Standard ID"), - (0x828d, "CFA Repeat Pattern Dim"), - (0x828e, "CFA Pattern"), - (0xa302, "CFA Pattern"), - (0x9217, "Sensing Method"), - (0xc4a5, "Print Image Matching"), - (0xa500, "Gamma"), - (0x011a, "X Resolution"), - (0x011b, "Y Resolution"), - (0x011c, "Planar Configuration"), - (0x0128, "Resolution Unit"), - (0x0131, "Software"), - (0x0132, "Date/Time Modified"), - (0x013b, "Artist"), - (0x013e, "White Point"), - (0x013f, "Primary Chromaticities"), - (0x0213, "YCbCr Positioning"), - (0x8298, "Copyright"), - (0xc612, "DNG Version"), - (0xc614, "Unique Camera Model"), - (0xc62f, "Camera Serial Number"), - // EXIF - (0x829a, "Exposure Time"), - (0x829d, "F-Number"), - (0x8822, "Exposure Program"), - (0x8824, "Spectral Sensitivity"), - (0x8827, "ISO Speed"), - (0x8830, "Sensitivity Type"), - (0x8832, "Recommended Exposure Index"), - (0x9000, "Exif Version"), - (0x9003, "Date/Time Original"), - (0x9004, "Date/Time Digitized"), - (0x9010, "Offset Time"), - (0x9011, "Offset Time Original"), - (0x9012, "Offset Time Digitized"), - (0x9101, "Components Configuration"), - (0x9102, "Compressed Bits Per Pixel"), - (0x9201, "Shutter Speed Value"), - (0x9202, "Aperture Value"), - (0x9203, "Brightness Value"), - (0x9204, "Exposure Compensation"), - (0x9205, "Max Aperture Value"), - (0x9206, "Subject Distance"), - (0x9207, "Metering Mode"), - (0x9208, "Light Source"), - (0x9209, "Flash"), - (0x920a, "Focal Length"), - (0x9214, "Subject Area"), - (0x927c, "Maker Note"), - (0x9286, "User Comment"), - (0x9290, "Sub-second Time"), - (0x9291, "Sub-second Time Original"), - (0x9292, "Sub-second Time Digitized"), - (0xa000, "FlashPix Version"), - (0xa001, "Color Space"), - (0xa002, "Pixel X Dimension"), - (0xa003, "Pixel Y Dimension"), - (0xa20e, "Focal Plane X Resolution"), - (0xa20f, "Focal Plane Y Resolution"), - (0xa210, "Focal Plane Resolution Unit"), - (0xa215, "Exposure Index"), - (0xa217, "Sensing Method"), - (0xa300, "File Source"), - (0xa301, "Scene Type"), - (0xa401, "Custom Rendered"), - (0xa402, "Exposure Mode"), - (0xa403, "White Balance"), - (0xa404, "Digital Zoom Ratio"), - (0xa405, "Focal Length in 35mm Film"), - (0xa406, "Scene Capture Type"), - (0xa407, "Gain Control"), - (0xa408, "Contrast"), - (0xa409, "Saturation"), - (0xa40a, "Sharpness"), - (0xa40c, "Subject Distance Range"), - (0xa420, "Image Unique ID"), - (0xa430, "Camera Owner Name"), - (0xa431, "Body Serial Number"), - (0xa432, "Lens Specification"), - (0xa433, "Lens Make"), - (0xa434, "Lens Model"), - (0xa435, "Lens Serial Number"), - // GPS - (0x0000, "GPS Version"), - (0x0001, "GPS Latitude Ref"), - (0x0002, "GPS Latitude"), - (0x0003, "GPS Longitude Ref"), - (0x0004, "GPS Longitude"), - (0x0005, "GPS Altitude Ref"), - (0x0006, "GPS Altitude"), - (0x0007, "GPS Time Stamp"), - (0x000c, "GPS Speed Ref"), - (0x000d, "GPS Speed"), - (0x0010, "GPS Img Direction Ref"), - (0x0011, "GPS Img Direction"), - (0x0012, "GPS Map Datum"), - (0x001d, "GPS Date Stamp"), -]; - -/// GPS tags reuse low numbers, so they are looked up separately. -fn name(group: &str, tag: u16) -> String { - let gps = group == "GPS"; - NAMES - .iter() - .find(|(t, n)| *t == tag && (n.starts_with("GPS") == gps || (gps && tag > 0x1f))) - .map(|(_, n)| n.to_string()) - .unwrap_or_else(|| format!("Tag 0x{tag:04X}")) -} - -fn rational(n: f64, d: f64) -> String { - if d == 0.0 { - return "—".into(); - } - let v = n / d; - if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() } -} - -fn list(v: &[T]) -> String { - let shown: Vec = v.iter().take(16).map(|x| x.to_string()).collect(); - if v.len() > 16 { format!("{} … ({} values)", shown.join(", "), v.len()) } else { shown.join(", ") } -} - -/// Spelled-out enumerations for the commonest tags. -fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> { - if group == "GPS" { - return None; - } - Some(match (tag, v) { - (0x0112, 1) => "Horizontal (normal)", - (0x0112, 3) => "Rotated 180°", - (0x0112, 6) => "Rotated 90° CW", - (0x0112, 8) => "Rotated 90° CCW", - (0x0112, 2) => "Mirrored horizontally", - (0x0112, 4) => "Mirrored vertically", - (0x0103, 1) => "Uncompressed", - (0x0103, 6) => "JPEG (old-style)", - (0x0103, 7) => "JPEG", - (0x0103, 8) => "Deflate", - (0x0103, 32773) => "PackBits", - (0x0103, 34892) => "Lossy JPEG", - (0x0106, 0) => "WhiteIsZero", - (0x0106, 1) => "BlackIsZero", - (0x0106, 2) => "RGB", - (0x0106, 6) => "YCbCr", - (0x0106, 32803) => "Color Filter Array", - (0x0106, 34892) => "Linear Raw", - (0x011c, 1) => "Chunky", - (0x011c, 2) => "Planar", - (0x0128, 2) => "inches", - (0x0128, 3) => "centimetres", - (0x8822, 1) => "Manual", - (0x8822, 2) => "Program", - (0x8822, 3) => "Aperture priority", - (0x8822, 4) => "Shutter priority", - (0x9207, 2) => "Center-weighted average", - (0x9207, 3) => "Spot", - (0x9207, 5) => "Pattern", - (0xa001, 1) => "sRGB", - (0xa001, 0xffff) => "Uncalibrated", - (0xa402, 0) => "Auto", - (0xa402, 1) => "Manual", - (0xa402, 2) => "Auto bracket", - (0xa403, 0) => "Auto", - (0xa403, 1) => "Manual", - (0xa406, 0) => "Standard", - (0xa406, 1) => "Landscape", - (0xa406, 2) => "Portrait", - (0xa406, 3) => "Night", - (0x9209, 0) => "No flash", - (0x9209, 1) => "Fired", - (0x9209, 5) => "Fired, return not detected", - (0x9209, 7) => "Fired, return detected", - (0x9209, 9) => "On, fired", - (0x9209, 16) => "Off, did not fire", - (0x9209, 24) => "Auto, did not fire", - (0x9209, 25) => "Auto, fired", - (0x9209, 32) => "No flash function", - (0x9208, 0) => "Unknown", - (0x9208, 1) => "Daylight", - (0x9208, 2) => "Fluorescent", - (0x9208, 3) => "Tungsten", - (0x9208, 4) => "Flash", - _ => return None, - }) -} - -fn value_text(group: &str, tag: u16, v: &Value) -> String { - let single = |x: i64| enumerated(group, tag, x).map(str::to_string).unwrap_or_else(|| x.to_string()); - match v { - Value::Ascii(s) => s.trim().to_string(), - Value::Short(a) if a.len() == 1 => single(a[0] as i64), - Value::Long(a) if a.len() == 1 => single(a[0] as i64), - Value::Short(a) => list(a), - Value::Long(a) => list(a), - Value::SShort(a) => list(a), - Value::SLong(a) => list(a), - Value::Long8(a) => list(a), - Value::SLong8(a) => list(a), - Value::Float(a) => list(a), - Value::Double(a) => list(a), - Value::Rational(a) if tag == 0x829a && group != "GPS" && a.len() == 1 && a[0].1 != 0 => { - // exposure time as a fraction of a second - let (n, d) = a[0]; - let s = n as f64 / d as f64; - if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) } - } - Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)), - Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)), - Value::Rational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), - Value::SRational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), - Value::Byte(b) | Value::Undefined(b) => { - // version tags and short printable values read as text - let printable = b.len() <= 64 && b.iter().all(|c| c.is_ascii_graphic() || *c == b' ' || *c == 0); - if printable && !b.is_empty() { - String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string() - } else if b.len() <= 8 { - list(b) - } else { - format!("({} bytes)", b.len()) - } - } - Value::SByte(a) => list(a), - Value::Ifd(a) => format!("(IFD at {})", list(a)), - Value::Ifd8(a) => format!("(IFD at {})", list(a)), - } -} - -fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec) { - for e in &ifd.entries { - // pointers to other IFDs and image data are structure, not metadata - if matches!(e.tag, 0x8769 | 0x8825 | 0xa005 | 0x014a | 0x0111 | 0x0117 | 0x0144 | 0x0145 | 0x0201 | 0x0202 | 0x02bc | 0x83bb | 0x8773) - && group != "GPS" - { - continue; - } - out.push(TagRow { group, tag: e.tag, name: name(group, e.tag), value: value_text(group, e.tag, &e.value) }); - } -} - -/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, -/// then the EXIF, GPS and interoperability IFDs. -pub fn tag_rows(tiff: &Tiff) -> Vec { - let mut out = Vec::new(); - if let Some(ifd0) = tiff.ifds.first() { - rows_of("TIFF", ifd0, &mut out); - if let Some(e) = &ifd0.exif { - rows_of("EXIF", e, &mut out); - if let Some(i) = &e.interop { - rows_of("Interop", i, &mut out); - } - } - if let Some(g) = &ifd0.gps { - rows_of("GPS", g, &mut out); - } - } - out -} - -/// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block -/// (JPEG, PNG, WebP, HEIF…). Empty when there is none. -pub fn file_tag_rows(bytes: &[u8]) -> Vec { - if Tiff::sniff(bytes).is_some() - && let Ok(t) = Tiff::parse(bytes) - { - return tag_rows(&t); - } - let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; - Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rows_from_written_exif() { - let m = crate::Metadata { - make: Some("Maker".into()), - model: Some("Model X".into()), - exposure_time: Some(1.0 / 250.0), - f_number: Some(2.8), - iso: Some(400), - focal_length: Some(35.0), - gps: Some(crate::Gps { latitude: 51.5, longitude: -0.12, altitude: Some(20.0) }), - ..Default::default() - }; - let exif = crate::write_exif(&m); - let t = Tiff::parse(crate::strip_exif_header(&exif)).unwrap(); - let rows = tag_rows(&t); - let get = |n: &str| rows.iter().find(|r| r.name == n).map(|r| r.value.clone()); - assert_eq!(get("Make").as_deref(), Some("Maker")); - assert_eq!(get("Exposure Time").as_deref(), Some("1/250 s")); - assert_eq!(get("F-Number").as_deref(), Some("f/2.8")); - assert_eq!(get("Focal Length").as_deref(), Some("35 mm")); - assert_eq!(get("ISO Speed").as_deref(), Some("400")); - assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}"); - assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out"); - } -} +//! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info +//! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; +//! common enumerations are spelled out, everything else is shown as stored. Maker notes and +//! binary blobs are summarised by size. + +use lightcraft_tiff::{Ifd, Tiff, Value}; + +/// One metadata row. +#[derive(Clone, Debug, PartialEq)] +pub struct TagRow { + /// `TIFF`, `EXIF`, `GPS`, `Interop`. + pub group: &'static str, + pub tag: u16, + pub name: String, + pub value: String, +} + +const NAMES: &[(u16, &str)] = &[ + // TIFF / IFD0 + (0x00fe, "Subfile Type"), + (0x0100, "Image Width"), + (0x0101, "Image Height"), + (0x0102, "Bits Per Sample"), + (0x0103, "Compression"), + (0x0106, "Photometric Interpretation"), + (0x010e, "Image Description"), + (0x010f, "Make"), + (0x0110, "Model"), + (0x0112, "Orientation"), + (0x0115, "Samples Per Pixel"), + (0x0116, "Rows Per Strip"), + (0x0214, "Reference Black/White"), + (0x9216, "TIFF/EP Standard ID"), + (0x828d, "CFA Repeat Pattern Dim"), + (0x828e, "CFA Pattern"), + (0xa302, "CFA Pattern"), + (0x9217, "Sensing Method"), + (0xc4a5, "Print Image Matching"), + (0xa500, "Gamma"), + (0x011a, "X Resolution"), + (0x011b, "Y Resolution"), + (0x011c, "Planar Configuration"), + (0x0128, "Resolution Unit"), + (0x0131, "Software"), + (0x0132, "Date/Time Modified"), + (0x013b, "Artist"), + (0x013e, "White Point"), + (0x013f, "Primary Chromaticities"), + (0x0213, "YCbCr Positioning"), + (0x8298, "Copyright"), + (0xc612, "DNG Version"), + (0xc614, "Unique Camera Model"), + (0xc62f, "Camera Serial Number"), + // EXIF + (0x829a, "Exposure Time"), + (0x829d, "F-Number"), + (0x8822, "Exposure Program"), + (0x8824, "Spectral Sensitivity"), + (0x8827, "ISO Speed"), + (0x8830, "Sensitivity Type"), + (0x8832, "Recommended Exposure Index"), + (0x9000, "Exif Version"), + (0x9003, "Date/Time Original"), + (0x9004, "Date/Time Digitized"), + (0x9010, "Offset Time"), + (0x9011, "Offset Time Original"), + (0x9012, "Offset Time Digitized"), + (0x9101, "Components Configuration"), + (0x9102, "Compressed Bits Per Pixel"), + (0x9201, "Shutter Speed Value"), + (0x9202, "Aperture Value"), + (0x9203, "Brightness Value"), + (0x9204, "Exposure Compensation"), + (0x9205, "Max Aperture Value"), + (0x9206, "Subject Distance"), + (0x9207, "Metering Mode"), + (0x9208, "Light Source"), + (0x9209, "Flash"), + (0x920a, "Focal Length"), + (0x9214, "Subject Area"), + (0x927c, "Maker Note"), + (0x9286, "User Comment"), + (0x9290, "Sub-second Time"), + (0x9291, "Sub-second Time Original"), + (0x9292, "Sub-second Time Digitized"), + (0xa000, "FlashPix Version"), + (0xa001, "Color Space"), + (0xa002, "Pixel X Dimension"), + (0xa003, "Pixel Y Dimension"), + (0xa20e, "Focal Plane X Resolution"), + (0xa20f, "Focal Plane Y Resolution"), + (0xa210, "Focal Plane Resolution Unit"), + (0xa215, "Exposure Index"), + (0xa217, "Sensing Method"), + (0xa300, "File Source"), + (0xa301, "Scene Type"), + (0xa401, "Custom Rendered"), + (0xa402, "Exposure Mode"), + (0xa403, "White Balance"), + (0xa404, "Digital Zoom Ratio"), + (0xa405, "Focal Length in 35mm Film"), + (0xa406, "Scene Capture Type"), + (0xa407, "Gain Control"), + (0xa408, "Contrast"), + (0xa409, "Saturation"), + (0xa40a, "Sharpness"), + (0xa40c, "Subject Distance Range"), + (0xa420, "Image Unique ID"), + (0xa430, "Camera Owner Name"), + (0xa431, "Body Serial Number"), + (0xa432, "Lens Specification"), + (0xa433, "Lens Make"), + (0xa434, "Lens Model"), + (0xa435, "Lens Serial Number"), + // GPS + (0x0000, "GPS Version"), + (0x0001, "GPS Latitude Ref"), + (0x0002, "GPS Latitude"), + (0x0003, "GPS Longitude Ref"), + (0x0004, "GPS Longitude"), + (0x0005, "GPS Altitude Ref"), + (0x0006, "GPS Altitude"), + (0x0007, "GPS Time Stamp"), + (0x000c, "GPS Speed Ref"), + (0x000d, "GPS Speed"), + (0x0010, "GPS Img Direction Ref"), + (0x0011, "GPS Img Direction"), + (0x0012, "GPS Map Datum"), + (0x001d, "GPS Date Stamp"), +]; + +/// GPS tags reuse low numbers, so they are looked up separately. +fn name(group: &str, tag: u16) -> String { + let gps = group == "GPS"; + NAMES + .iter() + .find(|(t, n)| *t == tag && (n.starts_with("GPS") == gps || (gps && tag > 0x1f))) + .map(|(_, n)| n.to_string()) + .unwrap_or_else(|| format!("Tag 0x{tag:04X}")) +} + +fn rational(n: f64, d: f64) -> String { + if d == 0.0 { + return "—".into(); + } + let v = n / d; + if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() } +} + +fn list(v: &[T]) -> String { + let shown: Vec = v.iter().take(16).map(|x| x.to_string()).collect(); + if v.len() > 16 { format!("{} … ({} values)", shown.join(", "), v.len()) } else { shown.join(", ") } +} + +/// Spelled-out enumerations for the commonest tags. +fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> { + if group == "GPS" { + return None; + } + Some(match (tag, v) { + (0x0112, 1) => "Horizontal (normal)", + (0x0112, 3) => "Rotated 180°", + (0x0112, 6) => "Rotated 90° CW", + (0x0112, 8) => "Rotated 90° CCW", + (0x0112, 2) => "Mirrored horizontally", + (0x0112, 4) => "Mirrored vertically", + (0x0103, 1) => "Uncompressed", + (0x0103, 6) => "JPEG (old-style)", + (0x0103, 7) => "JPEG", + (0x0103, 8) => "Deflate", + (0x0103, 32773) => "PackBits", + (0x0103, 34892) => "Lossy JPEG", + (0x0103, 34713) => "Nikon NEF Compressed", + (0x0103, 32767) => "Sony ARW Compressed", + (0x0103, 52546) => "JPEG XL", + (0x0106, 0) => "WhiteIsZero", + (0x0106, 1) => "BlackIsZero", + (0x0106, 2) => "RGB", + (0x0106, 6) => "YCbCr", + (0x0106, 32803) => "Color Filter Array", + (0x0106, 34892) => "Linear Raw", + (0x011c, 1) => "Chunky", + (0x011c, 2) => "Planar", + (0x0128, 2) => "inches", + (0x0128, 3) => "centimetres", + (0x8822, 1) => "Manual", + (0x8822, 2) => "Program", + (0x8822, 3) => "Aperture priority", + (0x8822, 4) => "Shutter priority", + (0x9207, 2) => "Center-weighted average", + (0x9207, 3) => "Spot", + (0x9207, 5) => "Pattern", + (0xa001, 1) => "sRGB", + (0xa001, 0xffff) => "Uncalibrated", + (0xa402, 0) => "Auto", + (0xa402, 1) => "Manual", + (0xa402, 2) => "Auto bracket", + (0xa403, 0) => "Auto", + (0xa403, 1) => "Manual", + (0xa406, 0) => "Standard", + (0xa406, 1) => "Landscape", + (0xa406, 2) => "Portrait", + (0xa406, 3) => "Night", + (0x9209, 0) => "No flash", + (0x9209, 1) => "Fired", + (0x9209, 5) => "Fired, return not detected", + (0x9209, 7) => "Fired, return detected", + (0x9209, 9) => "On, fired", + (0x9209, 16) => "Off, did not fire", + (0x9209, 24) => "Auto, did not fire", + (0x9209, 25) => "Auto, fired", + (0x9209, 32) => "No flash function", + (0x9208, 0) => "Unknown", + (0x9208, 1) => "Daylight", + (0x9208, 2) => "Fluorescent", + (0x9208, 3) => "Tungsten", + (0x9208, 4) => "Flash", + _ => return None, + }) +} + +fn value_text(group: &str, tag: u16, v: &Value) -> String { + let single = |x: i64| enumerated(group, tag, x).map(str::to_string).unwrap_or_else(|| x.to_string()); + match v { + Value::Ascii(s) => s.trim().to_string(), + Value::Short(a) if a.len() == 1 => single(a[0] as i64), + Value::Long(a) if a.len() == 1 => single(a[0] as i64), + Value::Short(a) => list(a), + Value::Long(a) => list(a), + Value::SShort(a) => list(a), + Value::SLong(a) => list(a), + Value::Long8(a) => list(a), + Value::SLong8(a) => list(a), + Value::Float(a) => list(a), + Value::Double(a) => list(a), + Value::Rational(a) if tag == 0x829a && group != "GPS" && a.len() == 1 && a[0].1 != 0 => { + // exposure time as a fraction of a second + let (n, d) = a[0]; + let s = n as f64 / d as f64; + if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) } + } + Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)), + Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)), + Value::Rational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), + Value::SRational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), + Value::Byte(b) | Value::Undefined(b) => { + // version tags and short printable values read as text + let printable = b.len() <= 64 && b.iter().all(|c| c.is_ascii_graphic() || *c == b' ' || *c == 0); + if printable && !b.is_empty() { + String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string() + } else if b.len() <= 8 { + list(b) + } else { + format!("({} bytes)", b.len()) + } + } + Value::SByte(a) => list(a), + Value::Ifd(a) => format!("(IFD at {})", list(a)), + Value::Ifd8(a) => format!("(IFD at {})", list(a)), + } +} + +fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec) { + for e in &ifd.entries { + // pointers to other IFDs and image data are structure, not metadata + if matches!(e.tag, 0x8769 | 0x8825 | 0xa005 | 0x014a | 0x0111 | 0x0117 | 0x0144 | 0x0145 | 0x0201 | 0x0202 | 0x02bc | 0x83bb | 0x8773) + && group != "GPS" + { + continue; + } + out.push(TagRow { group, tag: e.tag, name: name(group, e.tag), value: value_text(group, e.tag, &e.value) }); + } +} + +/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw +/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's. +fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> { + ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892))) +} + +/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation. +fn nef_compression(v: u32) -> Option<&'static str> { + Some(match v { + 1 => "Lossy (type 1)", + 2 => "Uncompressed", + 3 => "Lossless", + 4 => "Lossy (type 2)", + 5 => "Striped packed 12 bits", + 6 => "Uncompressed (reduced to 12 bit)", + 7 => "Unpacked 12 bits", + 8 => "Small", + 9 => "Packed 12 bits", + 10 => "Packed 14 bits", + 13 => "High Efficiency", + 14 => "High Efficiency*", + _ => return None, + }) +} + +/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values. +fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> { + let ifd0 = tiff.ifds.first()?; + let make = ifd0.string(0x010f)?; + if !make.to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let e = ifd0.exif.as_ref()?.get(0x927c)?; + let mn = lightcraft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + nef_compression(mn.ifd.u32(0x0093)?) +} + +/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, +/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor +/// image's, not IFD0's thumbnail's. +pub fn tag_rows(tiff: &Tiff) -> Vec { + let mut out = Vec::new(); + if let Some(ifd0) = tiff.ifds.first() { + rows_of("TIFF", ifd0, &mut out); + if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103)) + && let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103) + { + row.value = value_text("TIFF", 0x0103, c); + } + if let Some(e) = &ifd0.exif { + rows_of("EXIF", e, &mut out); + if let Some(i) = &e.interop { + rows_of("Interop", i, &mut out); + } + } + if let Some(g) = &ifd0.gps { + rows_of("GPS", g, &mut out); + } + } + out +} + +/// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block +/// (JPEG, PNG, WebP, HEIF…). Empty when there is none. +pub fn file_tag_rows(bytes: &[u8]) -> Vec { + if Tiff::sniff(bytes).is_some() + && let Ok(t) = Tiff::parse(bytes) + { + let mut rows = tag_rows(&t); + if let Some(words) = nikon_nef_compression(&t, bytes) + && let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103) + { + row.value = words.to_string(); + } + return rows; + } + let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; + Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rows_from_written_exif() { + let m = crate::Metadata { + make: Some("Maker".into()), + model: Some("Model X".into()), + exposure_time: Some(1.0 / 250.0), + f_number: Some(2.8), + iso: Some(400), + focal_length: Some(35.0), + gps: Some(crate::Gps { latitude: 51.5, longitude: -0.12, altitude: Some(20.0) }), + ..Default::default() + }; + let exif = crate::write_exif(&m); + let t = Tiff::parse(crate::strip_exif_header(&exif)).unwrap(); + let rows = tag_rows(&t); + let get = |n: &str| rows.iter().find(|r| r.name == n).map(|r| r.value.clone()); + assert_eq!(get("Make").as_deref(), Some("Maker")); + assert_eq!(get("Exposure Time").as_deref(), Some("1/250 s")); + assert_eq!(get("F-Number").as_deref(), Some("f/2.8")); + assert_eq!(get("Focal Length").as_deref(), Some("35 mm")); + assert_eq!(get("ISO Speed").as_deref(), Some("400")); + assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}"); + assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out"); + } + + fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec { + let mut e = Vec::new(); + e.extend(tag.to_le_bytes()); + e.extend(ty.to_le_bytes()); + e.extend(count.to_le_bytes()); + e.extend(val.to_le_bytes()); + e + } + + fn ifd(entries: &[Vec]) -> Vec { + let mut b = (entries.len() as u16).to_le_bytes().to_vec(); + entries.iter().for_each(|e| b.extend(e)); + b.extend(0u32.to_le_bytes()); + b + } + + /// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression` + /// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value. + fn synthetic_raw(nef: Option) -> Vec { + let make = b"NIKON CORPORATION"; + let mut note = b"NikonII*".to_vec(); + note.extend(ifd(&[entry(0x0093, 3, 1, nef.unwrap_or(0) as u32)])); + let ifd0_len = 2 + 4 * 12 + 4; + let sub_at = 8 + ifd0_len; + let sub = ifd(&[entry(0x0103, 3, 1, 34713), entry(0x0106, 3, 1, 32803)]); + let exif_at = sub_at + sub.len(); + let exif_len = 2 + 12 + 4; + let make_at = exif_at + exif_len; + let note_at = make_at + make.len(); + let mut b = b"II*".to_vec(); + b.extend(ifd(&[ + entry(0x0103, 3, 1, 1), + entry(0x010f, 2, make.len() as u32, make_at as u32), + entry(0x014a, 4, 1, sub_at as u32), + entry(0x8769, 4, 1, exif_at as u32), + ])); + b.extend(sub); + b.extend(ifd(&[entry(0x927c, 7, note.len() as u32, if nef.is_some() { note_at as u32 } else { 0 })])); + b.extend(make); + b.extend(note); + b + } + + fn compression(rows: &[TagRow]) -> Vec<&str> { + rows.iter().filter(|r| r.group == "TIFF" && r.tag == 0x0103).map(|r| r.value.as_str()).collect() + } + + #[test] + fn raw_compression_is_the_sensor_images_not_the_thumbnails() { + let b = synthetic_raw(None); + assert_eq!(compression(&file_tag_rows(&b)), ["Nikon NEF Compressed"]); + } + + #[test] + fn nef_compression_is_spelled_out_from_the_maker_note() { + for (v, words) in [(3, "Lossless"), (10, "Packed 14 bits"), (2, "Uncompressed"), (14, "High Efficiency*")] { + let b = synthetic_raw(Some(v)); + assert_eq!(compression(&file_tag_rows(&b)), [words]); + } + } + + #[test] + fn plain_tiff_keeps_its_own_compression() { + let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); + assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]); + } +} From a67d571656dcd8f4bae9974ec91e7fdc35b473a2 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:33:40 +0200 Subject: [PATCH 22/42] LR-VIEW-COMPARE: a filmstrip click shows the photo in Compare In Compare a filmstrip click made the clicked photo active without showing it, so the Edit panel and the rating keys acted on a photo on neither side (a headless test rated the hidden photo 2 -> 3 stars). The click now puts the photo into the active side (the candidate after entering Compare, the select after clicking the select's photo); a photo already on the other side swaps the two. The active photo is always one of the two shown. Other views keep the usual selection clicks. New UI command compare.set {id, side?: "select" | "candidate"} for the same through the control channel and MCP. New headless tests in tests_compare.rs: the active photo stays on screen and no hidden photo is rated (Fit and 100 %), and replace / swap / explicit side / unknown side. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 7 ++- crates/ui-egui/src/panels/compare.rs | 24 ++++++++ crates/ui-egui/src/panels/detail.rs | 7 ++- crates/ui-egui/src/tests_compare.rs | 84 ++++++++++++++++++++++++++++ docs/parity-checklist.md | 4 +- 6 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/tests_compare.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..31003a543 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,8 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_compare; +#[cfg(test)] mod tests_crop_rotate; #[cfg(test)] mod tests_curve; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aff0326a4..798c1ce7c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -66,6 +66,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), + ("compare.set", "Show in Compare", None, ""), ("compare.makeSelect", "Make Candidate the Select", None, "View"), ("view.autoAdvance", "Auto Advance", None, "Photo"), ("view.filmstrip", "Filmstrip", Some("/"), "View"), @@ -385,6 +386,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::swap(app), + "compare.set" => { + let Some(id) = p.get("id").and_then(Value::as_u64) else { return Some(Err("compare.set needs `id`".into())) }; + crate::panels::compare::set_photo(app, lightcraft_catalog::PhotoId(id), p.get("side").and_then(Value::as_str)) + } "compare.makeSelect" => crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; @@ -1614,7 +1619,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "file.backupLibrary" => app.services.backup_library.is_some(), "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), - "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, + "compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() } diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 77ee4242f..d758a148d 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -73,6 +73,30 @@ pub fn swap(app: &mut LightcraftApp) -> Result { Ok(Value::Null) } +/// Show photo `id` on one side of the pair: `side` "select" or "candidate", else the side that is +/// active (the one the Edit panel and the rating keys act on). A photo already on the other side +/// swaps the two. The photo becomes the active one, so what is edited is always on screen. +pub fn set_photo(app: &mut LightcraftApp, id: PhotoId, side: Option<&str>) -> Result { + if app.session.catalog.photo(id).is_none_or(|p| p.deleted) { + return Err(format!("no photo {}", id.0)); + } + let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?; + let to_select = match side { + Some("select") => true, + Some("candidate") => false, + Some(other) => return Err(format!("unknown side `{other}` (select or candidate)")), + None => app.session.active() == Some(sel), + }; + let (a, b) = match (to_select, id == sel, id == cand) { + (true, _, true) | (false, true, _) => (cand, sel), + (_, true, _) | (_, _, true) => (sel, cand), + (true, false, false) => (id, cand), + (false, false, false) => (sel, id), + }; + select_pair(app, a, b, id); + Ok(json!({"select": a.0, "candidate": b.0})) +} + /// The candidate becomes the select; the next photo becomes the candidate. pub fn make_select(app: &mut LightcraftApp) -> Result { let (_, b) = compare_pair(app).ok_or("nothing to compare")?; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..0f5573dc9 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2030,7 +2030,12 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); } } - if resp.clicked() { + if resp.clicked() && app.ui.view == crate::state::ViewMode::Compare { + // Compare: the clicked photo replaces the active side (or swaps with the other one) + if let Err(e) = app.run("compare.set", json!({"id": id.0})) { + app.toast(ui.ctx(), e); + } + } else if resp.clicked() { let m = ui.input(|i| i.modifiers); let mode = if m.command { "toggle" diff --git a/crates/ui-egui/src/tests_compare.rs b/crates/ui-egui/src/tests_compare.rs new file mode 100644 index 000000000..42f5fc027 --- /dev/null +++ b/crates/ui-egui/src/tests_compare.rs @@ -0,0 +1,84 @@ +//! Headless tests of Compare with the filmstrip. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Zoom; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + Headless::new(app, [1400.0, 900.0], 1.0) +} + +/// In Compare, the photo the Edit panel and the rating keys act on is always one of the two on +/// screen: a filmstrip click must not leave the pair showing while a third photo becomes active. +#[test] +fn filmstrip_click_in_compare_keeps_the_active_photo_on_screen() { + let mut h = demo(); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).map(|p| p.rating); + for zoom in [Zoom::Fit, Zoom::Percent(100.0)] { + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.zoom = zoom; + h.settle(SETTLE); + let target = vis[4]; + let before = rating(&h, target); + let r = h.request("ui.clickWidget", json!({"id": format!("film:{target}")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let (sel, cand) = h.app.ui.compare.expect("still comparing"); + let active = h.app.session.selection.active.map(|p| p.0).expect("an active photo"); + // a rating key must not change a photo that isn't shown + h.request("ui.key", json!({"key": "3"}), T); + if target != sel && target != cand { + assert_eq!(rating(&h, target), before, "zoom {zoom:?}: the hidden photo {target} was rated"); + } + assert!(active == sel || active == cand, "zoom {zoom:?}: active photo {active} is not on screen (compare shows {sel} | {cand})"); + } +} + +/// A filmstrip click in Compare puts the photo into the active side: the candidate after +/// entering Compare, the select after clicking the select's pane; a photo already on the other +/// side swaps the two. `compare.set` does the same for agents, with an explicit `side`. +#[test] +fn filmstrip_click_replaces_the_active_side_in_compare() { + let mut h = demo(); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + let (sel, cand) = (r["result"]["select"].as_u64().unwrap(), r["result"]["candidate"].as_u64().unwrap()); + h.settle(SETTLE); + let click_film = |h: &mut Headless, id: u64| { + let r = h.request("ui.clickWidget", json!({"id": format!("film:{id}")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + }; + let active = |h: &Headless| h.app.session.selection.active.map(|p| p.0); + let others: Vec = vis.iter().copied().filter(|v| *v != sel && *v != cand).take(3).collect(); + // the candidate is active after entering Compare: it is replaced + click_film(&mut h, others[0]); + assert_eq!(h.app.ui.compare, Some((sel, others[0]))); + assert_eq!(active(&h), Some(others[0])); + // the select's photo, clicked in the filmstrip: the two swap, it stays active + click_film(&mut h, sel); + assert_eq!(h.app.ui.compare, Some((others[0], sel))); + assert_eq!(active(&h), Some(sel)); + // a side chosen explicitly + let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[1], "side": "select"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.compare, Some((others[1], sel))); + assert_eq!(active(&h), Some(others[1])); + // the select is active now: the next filmstrip click replaces it + click_film(&mut h, others[2]); + assert_eq!(h.app.ui.compare, Some((others[2], sel))); + let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[0], "side": "left"}}), T); + assert_eq!(r["ok"], false, "unknown side is an error: {r}"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..2ffbe891a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session | -| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | @@ -675,7 +675,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | -| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked | +| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | From 8f3f168802d59a28a805a4b6a572e805c1288c77 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 17:59:11 -0400 Subject: [PATCH 23/42] LR-META-EXIF-FULL: raw sensor IFD Compression 7 reads Lossless JPEG (#830) --- crates/meta/src/tags.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index 4e44f3f81..cdddc04be 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -319,7 +319,8 @@ pub fn tag_rows(tiff: &Tiff) -> Vec { if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103)) && let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103) { - row.value = value_text("TIFF", 0x0103, c); + // lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy + row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) }; } if let Some(e) = &ifd0.exif { rows_of("EXIF", e, &mut out); @@ -442,6 +443,19 @@ mod tests { } } + #[test] + fn dng_raw_sub_ifd_jpeg_is_lossless_but_a_plain_jpeg_tiff_is_not() { + let b = synthetic_raw(None); + // switch the sensor sub-IFD's Compression from 34713 to 7 + let pos = b.windows(12).position(|w| w == entry(0x0103, 3, 1, 34713).as_slice()).unwrap(); + let mut dng = b.clone(); + dng[pos + 8] = 7; + dng[pos + 9] = 0; + assert_eq!(compression(&file_tag_rows(&dng)), ["Lossless JPEG"]); + let plain = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 7)])].concat(); + assert_eq!(compression(&file_tag_rows(&plain)), ["JPEG"]); + } + #[test] fn plain_tiff_keeps_its_own_compression() { let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); From 40d7b86ecb3162512a5a3c65b6f0f77ffa22c662 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:16:07 +0200 Subject: [PATCH 24/42] LR-VIEW-BEFOREAFTER: click tools work in the Before view of side by side / top-bottom In the side-by-side and top/bottom Before/After layouts a click in the Before view was mapped to image coordinates through the After view's rectangle, so the White Balance Selector sampled the wrong point (at Fit, the photo's edge). The click tools in general_interaction now use the Before view's image rectangle when the pointer is in the Before view's pane; the pane decides, since zoomed in an image reaches past its pane. New headless test tests_before_after.rs clicks the same image point in the After and Before views (side by side, top/bottom; Fit and 100 %) and expects the same white balance. Without the fix: Before 8202 K / +116, After 2710 K / +33. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 14 ++++- crates/ui-egui/src/tests_before_after.rs | 71 ++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/tests_before_after.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..d9bafc7d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,8 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_before_after; +#[cfg(test)] mod tests_crop_rotate; #[cfg(test)] mod tests_curve; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..3caa79068 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -685,7 +685,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), - _ => general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect), + _ => { + // side by side / stacked: Before shows the same frame, so a click in its view (white + // balance, colour samples, targeted adjustment) means the same point of the photo. + // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + let map = match (split, areas.first(), pointer) { + (true, Some(before), Some(q)) if before.contains(q) => { + CanvasMap::new(&frame, fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan)) + } + _ => map, + }; + general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect) + } } } // drawn and hit-tested above the loupe and its tools: clicks on it pan diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs new file mode 100644 index 000000000..96fab9cbe --- /dev/null +++ b/crates/ui-egui/src/tests_before_after.rs @@ -0,0 +1,71 @@ +//! Headless tests of clicks in the two-view Before / After layouts. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{BeforeAfter, ViewMode, Zoom}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// Click at a screen position with real egui pointer events (as `ui.pointer` sends them). +fn click_at(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(pos)); + h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.settle(SETTLE); +} + +/// The white-balance selector picks the same point of the photo whichever view of a side-by-side +/// or top/bottom Before / After layout is clicked, at Fit and zoomed in. Clicks on the Before +/// view used to be mapped through the After view's rectangle, so they sampled the photo's edge. +#[test] +fn wb_selector_picks_the_same_point_in_the_before_view() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[2]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T); + h.app.ui.view = ViewMode::Detail; + let wb = |h: &Headless| h.app.session.catalog.photo(id).map(|p| (p.develop.wb.temp, p.develop.wb.tint)); + // lay the view out and arm the selector; returns the After view's image rect and the canvas + let arm = |h: &mut Headless, mode: BeforeAfter, zoom: Zoom| { + h.request("engine.execute", json!({"command": "develop.reset"}), T); + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + h.app.ui.pan = Default::default(); + h.settle(SETTLE); + let r = h.request("engine.execute", json!({"command": "tool.wbPicker"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + (h.app.image_rect.expect("After view on screen"), h.app.canvas_rect.expect("canvas")) + }; + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] { + // the Before view is the After view moved by one pane: measured at Fit, where both are + // centred in their panes and so mirror each other about the canvas centre + let (img, canvas) = arm(&mut h, mode, Zoom::Fit); + let shift = match mode { + BeforeAfter::SideBySide => egui::vec2(img.left() + img.right() - 2.0 * canvas.center().x, 0.0), + _ => egui::vec2(0.0, img.top() + img.bottom() - 2.0 * canvas.center().y), + }; + assert!(shift.length() > 100.0, "{mode:?}: two views, {shift:?} apart"); + h.request("engine.execute", json!({"command": "tool.wbPicker"}), T); + for zoom in [Zoom::Fit, Zoom::Percent(100.0)] { + let mut picked = Vec::new(); + for before_view in [false, true] { + let (img, canvas) = arm(&mut h, mode, zoom); + // the middle of the After pane (on the photo at Fit and zoomed in), or the same + // point of the photo in the Before view + let after = canvas.center() + shift / 2.0; + assert!(img.contains(after), "{mode:?} {zoom:?}: {after:?} on the photo {img:?}"); + click_at(&mut h, if before_view { after - shift } else { after }); + assert_eq!(h.app.ui.tool, "", "{mode:?} {zoom:?}: the selector is done after one click"); + picked.push(wb(&h)); + } + assert_ne!(picked[0], Some((6500.0, 0.0)), "{mode:?} {zoom:?}: the click picked a white balance"); + assert_eq!(picked[1], picked[0], "{mode:?} {zoom:?}: the Before view picks the After view's point"); + } + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..a76808021 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -130,7 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | From 6807421f6446c666de9452fbb53d8a5d090c1cea Mon Sep 17 00:00:00 2001 From: john01px2022 <121329615+john01px2022@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:09:44 -0700 Subject: [PATCH 25/42] LR-CROP-STRAIGHTEN: the rotate pointer faces away from its side of the crop box The crop tool's rotate pointer always bulged up, which only reads right above the box. It now turns to bulge away from the nearest point of the box outline: right beside the right edge, down below it, left beside the left edge, diagonally off a corner, and following a box that is already rotated. icons::paint_turned draws an icon turned about its centre; icons::paint is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 18 ++++++++- crates/ui-egui/src/panels/detail.rs | 61 +++++++++++++++++++++++++++-- docs/parity-checklist.md | 2 +- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9a4718895..0cf998e0f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -90,10 +90,16 @@ struct Pen<'a> { r: Rect, s: Stroke, k: f32, + /// (cos, sin) of a turn about the grid's centre, clockwise on screen ([`paint_turned`]). + turn: Option<(f32, f32)>, } impl Pen<'_> { fn pt(&self, x: f32, y: f32) -> Pos2 { + let (x, y) = match self.turn { + Some((c, s)) => (10.0 + (x - 10.0) * c - (y - 10.0) * s, 10.0 + (x - 10.0) * s + (y - 10.0) * c), + None => (x, y), + }; pos2(self.r.min.x + x * self.k, self.r.min.y + y * self.k) } fn line(&self, pts: &[(f32, f32)]) { @@ -144,10 +150,20 @@ fn star_pts(cx: f32, cy: f32, r: f32) -> Vec<(f32, f32)> { /// Paint `icon` centred in `rect` (square area used) with `color`. pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { + paint_with(p, rect, icon, color, None); +} + +/// [`paint`], turned `radians` clockwise about its centre. Only for icons drawn with lines, arcs +/// and circles (rectangles stay square to the screen). +pub fn paint_turned(p: &Painter, rect: Rect, icon: Icon, color: Color32, radians: f32) { + paint_with(p, rect, icon, color, Some((radians.cos(), radians.sin()))); +} + +fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<(f32, f32)>) { let side = rect.width().min(rect.height()); let r = Rect::from_center_size(rect.center(), vec2(side, side)); let k = side / 20.0; - let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k }; + let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k, turn }; use Icon::*; match icon { Sidebar => { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..77c731c2d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1285,7 +1285,7 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons None if inside(hq) && !rotating => egui::CursorIcon::Move, // a drag here rotates: no system cursor shows that, so draw a curved double arrow None => { - rotate_cursor(ui, hq); + rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq))); egui::CursorIcon::None } }; @@ -1383,15 +1383,38 @@ fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter { ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new(name))) } -/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo. -fn rotate_cursor(ui: &egui::Ui, at: Pos2) { +/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo, +/// turned `turn` radians clockwise ([`rotate_cursor_turn`]). +fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) { let p = top_painter(ui, "crop-rotate-cursor"); let r = Rect::from_center_size(at, vec2(22.0, 22.0)); p.circle_filled(at, 12.0, Color32::from_black_alpha(150)); - crate::icons::paint(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE); + crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn); register(ui.ctx(), "cropRotateCursor", r); } +/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges +/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge, +/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest +/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it +/// the other way, still away from that edge. +fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 { + let nearest = (0..pts.len()) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + a + ab * t + }) + .min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q))); + let Some(nearest) = nearest else { return 0.0 }; + let away = if inside { nearest - q } else { q - nearest }; + if away.length() < 0.5 { + return 0.0; + } + away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2 +} + /// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the /// pointer's other side when it would run past an edge. fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { @@ -2143,6 +2166,36 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R #[cfg(test)] mod tests { + /// The rotation pointer's arc bulges away from the side of the crop box it is on, and follows + /// a box that is itself rotated. + #[test] + fn rotate_cursor_faces_away_from_the_crop_box() { + use egui::{Pos2, pos2}; + use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + let near = |a: f32, b: f32| ((a - b + PI).rem_euclid(2.0 * PI) - PI).abs() < 1e-3; + let pts = [pos2(100.0, 100.0), pos2(300.0, 100.0), pos2(300.0, 200.0), pos2(100.0, 200.0)]; + let turn = |x: f32, y: f32| super::rotate_cursor_turn(&pts, pos2(x, y), false); + assert!(near(turn(200.0, 80.0), 0.0), "above: as drawn"); + assert!(near(turn(320.0, 150.0), FRAC_PI_2), "right"); + assert!(near(turn(200.0, 230.0), PI), "below"); + assert!(near(turn(70.0, 150.0), -FRAC_PI_2), "left"); + assert!(near(turn(290.0, 60.0), 0.0), "above the top edge near its end: still up"); + assert!(near(turn(320.0, 80.0), FRAC_PI_4), "off the top-right corner: diagonal"); + assert!(near(super::rotate_cursor_turn(&pts, pos2(200.0, 110.0), true), 0.0), "dragged back over the top edge: still up"); + // a box turned 90°: its top edge is now on the right + let turned = [pts[3], pts[0], pts[1], pts[2]].map(|p| pos2(400.0 - p.y, p.x)); + assert!(near(super::rotate_cursor_turn(&turned, pos2(420.0, 200.0), false), FRAC_PI_2)); + // a box turned 30°: straight out from its top edge is 30° clockwise of up + let c = pos2(200.0, 150.0); + let rot = |p: Pos2| { + let (s, co) = (30f32.to_radians().sin(), 30f32.to_radians().cos()); + let d = p - c; + pos2(c.x + d.x * co - d.y * s, c.y + d.x * s + d.y * co) + }; + let tilted = pts.map(rot); + assert!(near(super::rotate_cursor_turn(&tilted, rot(pos2(200.0, 60.0)), false), 30f32.to_radians())); + } + use super::{film_label, texture_side}; /// Issue #652: a native host reports the GPU's texture limit in the first frame's input only. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..ef7c04c89 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -246,7 +246,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot) | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | From 7d27cf8bfbe7611c43a245b89b617e30fb30463c Mon Sep 17 00:00:00 2001 From: john01px2022 <121329615+john01px2022@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:18:57 -0700 Subject: [PATCH 26/42] LR-CROP-STRAIGHTEN: test the rotate pointer off all four crop corners Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 77c731c2d..2967e6483 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2180,7 +2180,11 @@ mod tests { assert!(near(turn(200.0, 230.0), PI), "below"); assert!(near(turn(70.0, 150.0), -FRAC_PI_2), "left"); assert!(near(turn(290.0, 60.0), 0.0), "above the top edge near its end: still up"); - assert!(near(turn(320.0, 80.0), FRAC_PI_4), "off the top-right corner: diagonal"); + // off each corner: diagonally out from it + assert!(near(turn(80.0, 80.0), -FRAC_PI_4), "top-left corner"); + assert!(near(turn(320.0, 80.0), FRAC_PI_4), "top-right corner"); + assert!(near(turn(320.0, 220.0), 3.0 * FRAC_PI_4), "bottom-right corner"); + assert!(near(turn(80.0, 220.0), -3.0 * FRAC_PI_4), "bottom-left corner"); assert!(near(super::rotate_cursor_turn(&pts, pos2(200.0, 110.0), true), 0.0), "dragged back over the top edge: still up"); // a box turned 90°: its top edge is now on the right let turned = [pts[3], pts[0], pts[1], pts[2]].map(|p| pos2(400.0 - p.y, p.x)); From d76a4121e60771873b690a5220708525d8cd697d Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:32:34 +0200 Subject: [PATCH 27/42] LR-PROF-CAMERACOLOR: near-black pixels no longer turn white in the camera look (#820) HueSat::apply restores luminance with before / after. Near black, where noise makes some channels negative, the hue/saturation table can bring after close to 0, and the gain turned single pixels white (isolated white dots in dark areas, most visible at fit). Luminance is now restored only if the table kept at least 25 % of it; such colours keep their colour, like black and non-finite ones. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index e14b1bd65..b12e968ad 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -833,6 +833,10 @@ fn to_prophoto() -> Mat3 { PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) } +/// [`HueSat::apply`] restores the luminance only if the table kept at least this share of it; a colour the +/// table darkens more (only near-black noise, whose channels go negative) keeps its colour. +const MIN_LUMINANCE_KEPT: f32 = 0.25; + /// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes /// hue and saturation only: luminance is restored, the camera tone curve owns it. pub(crate) struct HueSat<'a> { @@ -852,7 +856,9 @@ impl<'a> HueSat<'a> { let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; let out = mul(&self.from, self.table.apply(mul(&self.to, rgb))); let (before, after) = (luminance_2020(rgb), luminance_2020(out)); - if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb } + // restoring the luminance divides by the table's: near black, where noise makes some channels + // negative, the table can bring it close to 0, and the gain would turn the pixel white (#820) + if before > 0.0 && after > MIN_LUMINANCE_KEPT * before && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb } } } @@ -1439,6 +1445,31 @@ mod tests { assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); } + #[test] + fn hue_sat_never_turns_near_black_noise_white() { + // a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0 + }; + let mut worst = 0.0f32; + for _ in 0..200_000 { + let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()]; + if luminance_2020(rgb) <= 0.0 { + continue; + } + let out = hue_sat.apply(rgb); + let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs())); + worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale); + } + assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); + } + fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { let area = lightcraft_raw::Rect::new(0, 0, 2, 2); RawImage { From dac0d4c6e2569af18b9abe1126271d565bfddb11 Mon Sep 17 00:00:00 2001 From: THEtheChad <880190+THEtheChad@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:59:03 +0000 Subject: [PATCH 28/42] test(engine): LRC-LIB-LIBFOLDERS normalize sync fixture paths Rebuild fixture path components with native separators so expected paths match directory scans on Windows, including files that have been removed. This is a prerequisite for Windows validation and overlaps the path-helper fix in upstream PR #660; it can be dropped once that change lands. Co-Authored-By: GPT-6 Astra --- crates/engine/src/tests_sync.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 22dce0af8..28d7311f0 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -32,7 +32,9 @@ impl Scratch { Scratch(dir) } fn path(&self, rel: &str) -> String { - self.0.join(rel).to_string_lossy().to_string() + // Fixtures use `/`; rebuild components so expected paths match the native separators + // returned by directory scans, including for files that have already been removed. + self.0.join(rel).components().collect::().to_string_lossy().into_owned() } } From aec298c116c568f457c86b44caf4206fe59c6983 Mon Sep 17 00:00:00 2001 From: THEtheChad <880190+THEtheChad@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:59:03 +0000 Subject: [PATCH 29/42] test(engine): LRC-LIB-LIBFOLDERS cover mixed Windows separators Exercise both separator directions for folder and individual-file imports. Verify duplicate detection, stable photo IDs, added/deleted file detection, and reuse of a confirmed scan when the folder spelling changes. Co-Authored-By: GPT-6 Astra --- crates/engine/src/tests_sync.rs | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 28d7311f0..39378b100 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -105,6 +105,68 @@ fn an_unchanged_folder_has_no_changes() { assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}"); } +#[cfg(windows)] +#[test] +fn importing_and_synchronizing_with_different_windows_separators_keeps_photo_identity() { + for forward_import in [true, false] { + for import_folder in [true, false] { + let dir = Scratch::new(&format!("separators-{forward_import}-{import_folder}")); + let folder = dir.path("trip"); + let a = dir.path("trip/a.png"); + let b = dir.path("trip/day1/b.png"); + write_png(&a, 1); + write_png(&b, 2); + let spelling = |path: &str, forward: bool| if forward { path.replace('\\', "/") } else { path.to_string() }; + let import_path = spelling(&folder, forward_import); + let sync_path = spelling(&folder, !forward_import); + assert_ne!(import_path, sync_path, "exercise distinct Windows path spellings"); + let inputs = if import_folder { vec![import_path.clone()] } else { vec![spelling(&a, forward_import), spelling(&b, forward_import)] }; + let mut s = Session::new().with_fs(); + let imported = s.execute("library.import", &json!({"paths": inputs})).unwrap(); + assert_eq!(imported["imported"].as_array().unwrap().len(), 2, "{imported}"); + let before = s.execute("catalog.query", &json!({})).unwrap(); + let photos = before["photos"].as_array().unwrap(); + let a_id = photos.iter().find(|p| p["fileName"] == "a.png").unwrap()["id"].clone(); + let b_id = photos.iter().find(|p| p["fileName"] == "b.png").unwrap()["id"].clone(); + + let duplicate = s.execute("library.import", &json!({"paths": [sync_path]})).unwrap(); + assert!(duplicate["imported"].as_array().unwrap().is_empty(), "{duplicate}"); + assert_eq!(duplicate["duplicates"].as_array().unwrap().len(), 2, "{duplicate}"); + assert_eq!(s.execute("catalog.query", &json!({})).unwrap(), before, "reimport preserves the photos and their ids"); + + std::fs::remove_file(&b).unwrap(); + let c = dir.path("trip/day2/c.png"); + write_png(&c, 3); + let changes = scan(&mut s, &sync_path); + assert_eq!(changes["new"].as_array().unwrap().len(), 1, "{changes}"); + assert_eq!(Path::new(changes["new"][0]["path"].as_str().unwrap()), Path::new(&c)); + assert_eq!(changes["missing"].as_array().unwrap().len(), 1, "{changes}"); + assert_eq!(changes["missing"][0]["id"], b_id, "the missing photo keeps its id"); + assert!(changes["moved"].as_array().unwrap().is_empty(), "{changes}"); + + // Switching spelling again must consume the confirmed scan of this same folder. + let synced = s.execute("folder.synchronize", &json!({"path": import_path, "scanned": true, "removeMissing": true})).unwrap(); + assert_eq!( + (synced["imported"].as_u64(), synced["removed"].as_u64(), synced["relinked"].as_u64()), + (Some(1), Some(1), Some(0)), + "{synced}" + ); + assert!(synced["failed"].as_array().unwrap().is_empty(), "{synced}"); + let after = s.execute("catalog.query", &json!({})).unwrap(); + let photos = after["photos"].as_array().unwrap(); + assert_eq!(photos.len(), 2, "{after}"); + assert!(photos.iter().any(|p| p["id"] == a_id && p["fileName"] == "a.png"), "{after}"); + assert!(photos.iter().any(|p| p["fileName"] == "c.png" && p["id"] != a_id && p["id"] != b_id), "{after}"); + for path in [&import_path, &sync_path] { + let changes = scan(&mut s, path); + for kind in ["new", "missing", "moved", "metadata"] { + assert!(changes[kind].as_array().unwrap().is_empty(), "{path}: {changes}"); + } + } + } + } +} + #[test] fn files_added_to_the_folder_or_a_folder_inside_it_are_new() { let dir = Scratch::new("new"); From 21d71feb042c1e5b92324eab590c36e973eb6434 Mon Sep 17 00:00:00 2001 From: robmdunn <1900604+robmdunn@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:12:35 -0400 Subject: [PATCH 30/42] LR-HDR-EXPORT: HDR renders keep the camera tone curve in the midtones Raws with a fitted camera look (e.g. Nikon NEF) rendered HDR with the generic tone curve instead, more than a stop darker through the midtones than the SDR render: PQ JPEG XL / AVIF exports looked dark everywhere, and gain map JPEGs carried negative gains, so they went dark on HDR displays. ToneMap::camera_hdr keeps the camera curve up to 1.5 EV over grey and lifts highlights as ToneMap::hdr does. Co-Authored-By: Claude --- crates/pipeline/src/finish.rs | 7 +++--- crates/pipeline/src/tests_hdr.rs | 32 +++++++++++++++++++++++++ crates/pipeline/src/tone.rs | 40 +++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index fce3b8324..886ec9eee 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -275,12 +275,13 @@ impl FinishParams { /// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited /// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here. /// `peak` is the HDR render's peak relative to SDR white ([`lightcraft_develop::Hdr::peak`]); at 1.0 (SDR) -/// every arm is the SDR tone map. The camera's look is bounded at white, so HDR renders use the HDR tone map. +/// every arm is the SDR tone map. An HDR render keeps the camera's look through the midtones and +/// lifts its highlights past white ([`ToneMap::camera_hdr`]), so it matches the SDR render below them. pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { let l = &s.light; match s.process.process() { - Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) { - Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks), + Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw) { + Some(curve) => ToneMap::camera_hdr(curve, l.contrast, l.whites, l.blacks, peak), None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak), None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), }, diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs index bacc0eaa3..158019a4c 100644 --- a/crates/pipeline/src/tests_hdr.rs +++ b/crates/pipeline/src/tests_hdr.rs @@ -61,6 +61,38 @@ fn hdr_midtones_match_the_sdr_render() { assert!(n > 100, "the scene should have midtones and shadows ({n})"); } +#[test] +fn hdr_keeps_the_camera_tone_curve_in_the_midtones() { + // A raw with a fitted camera look (brighter midtones than the generic curve): its HDR render + // used to drop the look and come out more than a stop darker than the SDR render. + let knots = std::array::from_fn(|i| { + let x = 0.004 * 1.18f32.powi(i as i32); + [x, 1.0 - (-2.0 * x).exp()] + }); + let info = SourceInfo { camera_tone: crate::tone::CameraTone::new(knots), ..raw() }; + let src = sunset(); + let render_at = |s: &DevelopSettings, depth| { + let req = RenderRequest { depth, ..RenderRequest::fit(240, 240) }; + match render(&src, &info, s, &req).deep.map(|d| d.samples) { + Some(DeepSamples::F32(v)) => v, + other => panic!("expected float samples, got {other:?}"), + } + }; + let hdr = render_at(&hdr_on(3.0), OutputDepth::F32Hdr); + let sdr = render_at(&DevelopSettings::default(), OutputDepth::F32Linear); + let mut n = 0; + for (h, s) in hdr.as_chunks::<3>().0.iter().zip(sdr.as_chunks::<3>().0) { + if s.iter().all(|v| *v < 0.4) { + for (a, b) in h.iter().zip(s) { + assert!((a - b).abs() < 2e-3, "{h:?} vs {s:?}"); + } + n += 1; + } + } + assert!(n > 100, "the scene should have midtones and shadows ({n})"); + assert!(hdr.iter().any(|v| *v > 1.0), "highlights go past SDR white"); +} + #[test] fn f32hdr_without_hdr_is_the_sdr_float_render() { let src = sunset(); diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs index f9602e996..d4d2d129a 100644 --- a/crates/pipeline/src/tone.rs +++ b/crates/pipeline/src/tone.rs @@ -103,13 +103,24 @@ pub struct ToneMap { impl ToneMap { pub fn camera(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64) -> ToneMap { + ToneMap::camera_hdr(curve, contrast, whites, blacks, 1.0) + } + + /// [`ToneMap::camera`] for an HDR render reaching `peak` (linear, SDR white = 1; 1 = SDR + /// exactly): the camera's look up to 1.5 EV over grey, so midtones match the SDR render (and a + /// gain map only lifts highlights), then the highlights gain up to `peak − 1` more as in + /// [`ToneMap::hdr`]. + pub fn camera_hdr(curve: &CameraTone, contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap { + let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 }; let adjustment = Self::display(contrast, whites, blacks); let neutral = contrast == 0.0 && whites == 0.0 && blacks == 0.0; + let top = (2.9 - 1.6 * (whites as f32 / 100.0) + peak.log2() + 1.0).max(HDR_FROM_EV + 0.5); let lut = (0..LUT_N) .map(|i| { let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32; let y = curve.apply(GREY * 2f32.powf(ev)); - if neutral { y } else { adjustment.apply(y) } + let o = if neutral { y } else { adjustment.apply(y) }; + if peak > 1.0 { (o + (peak - 1.0) * smooth(HDR_FROM_EV, top, ev)).clamp(0.0, peak) } else { o } }) .collect(); ToneMap { lut, chroma: curve.chroma } @@ -353,6 +364,33 @@ mod tests { assert_eq!(ToneMap::hdr(0.0, 0.0, 0.0, f32::NAN).lut(), ToneMap::new(0.0, 0.0, 0.0).lut()); } + #[test] + fn camera_hdr_keeps_the_camera_look_and_lifts_highlights() { + let curve = CameraTone::new(knots()).unwrap().with_chroma([1.2, 1.1, 1.0, 1.0, 0.9, 0.8, 0.7, 0.6]).unwrap(); + for (c, w, b) in [(0.0, 0.0, 0.0), (40.0, -30.0, -20.0)] { + let sdr = ToneMap::camera(&curve, c, w, b); + assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, 1.0).lut(), sdr.lut()); + assert_eq!(ToneMap::camera_hdr(&curve, c, w, b, f32::NAN).lut(), sdr.lut()); + for peak in [2.0f32, 8.0] { + let t = ToneMap::camera_hdr(&curve, c, w, b, peak); + assert_eq!(t.chroma_lut(), sdr.chroma_lut()); + let mut prev = -1.0; + for i in 0..2400 { + let y = 1e-5 * 1.01f32.powi(i); + let o = t.apply(y); + assert!((0.0..=peak).contains(&o), "{peak} at {y}: {o}"); + assert!(o >= prev - 1e-6, "{peak} at {y}: {o} < {prev}"); + prev = o; + // the camera's midtones, not the generic HDR curve's (issue: dark HDR exports) + if y <= GREY * 2f32.powf(HDR_FROM_EV - 0.01) { + assert!((o - sdr.apply(y)).abs() < 1e-6, "midtones differ at {y}: {o} vs {}", sdr.apply(y)); + } + } + assert!(t.apply(4.0) > 1.0 && t.apply(4.0) > sdr.apply(4.0) + 0.5, "{peak}: highlights lifted"); + } + } + } + #[test] fn display_hdr_passes_hdr_sources_and_keeps_sdr() { assert_eq!(ToneMap::display_hdr(20.0, 10.0, -5.0, 1.0).lut(), ToneMap::display(20.0, 10.0, -5.0).lut()); From d9d1a5a7309d5b315f9906de3015b6478d800ae2 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:59:36 +0700 Subject: [PATCH 31/42] fix(ui): show completion for library maintenance and Auto Sync (#875) --- crates/ui-egui/src/lib.rs | 19 +++++++++++++++++++ crates/ui-egui/src/tests_activity.rs | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f853fe166..5c801a126 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -427,6 +427,25 @@ impl LightcraftApp { // Keep it hidden after release; O / the overlay eye can show it again. self.ui.mask_overlay = false; } + if r.is_ok() { + // These commands finish synchronously, so their completion would otherwise + // be invisible once the activity stack is gone. Never show success on error. + let completed = match id { + "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), + "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Clear Preview Cache"))), + "develop.autoSync" => Some(format!( + "{}: {}", + i18n::tr("Auto Sync"), + i18n::tr(if self.session.auto_sync { "On" } else { "Off" }) + )), + _ => None, + }; + if let Some(message) = completed { + // Menu clicks can arrive before the next frame updates last_time. + let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time); + self.ui.toast = Some((message, now + TOAST_SECONDS, None)); + } + } if r.is_ok() && id == "photo.label" { let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); let text = match label { diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index ca2a6de7e..8833ddc79 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -393,3 +393,22 @@ fn rows_added_later_grow_the_stack() { assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id()); } } + +#[test] +fn immediate_library_commands_and_auto_sync_show_completion() { + let mut h = demo(); + assert!(h.app.run("library.clearPreviews", json!({})).is_ok()); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("✓ Clear Preview Cache")); + + assert_eq!(h.app.run("develop.autoSync", json!({"on": true})).unwrap()["on"], true); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: On")); + + assert_eq!(h.app.run("develop.autoSync", json!({"on": false})).unwrap()["on"], false); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Auto Sync: Off")); + + // This demo has no on-disk library to optimize. A failed command must not + // report completion (or accidentally keep its preceding success toast). + h.app.ui.toast = None; + assert!(h.app.run("library.compact", json!({})).is_err()); + assert!(h.app.ui.toast.is_none()); +} From 38756b89d0ef64b2b576a6bbb5b829b0e0ae6564 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:04:36 +0700 Subject: [PATCH 32/42] fix(query): saturate pagination values in 32-bit web builds --- crates/engine/src/cmd/query.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index fcf56f7b6..6c232860d 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -59,6 +59,12 @@ fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result { } } +// Catalog queries also run in the 32-bit web build. A narrowing cast wraps +// an out-of-range offset to zero, returning the first page instead of no photos. +fn pagination_usize(value: u64) -> usize { + usize::try_from(value).unwrap_or(usize::MAX) +} + pub fn specs() -> Vec { vec![ cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| { @@ -69,8 +75,8 @@ pub fn specs() -> Vec { } else { s.visible_cloned() }; - let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; - let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize; + let off = pagination_usize(p.get("offset").and_then(Value::as_u64).unwrap_or(0)); + let lim = pagination_usize(p.get("limit").and_then(Value::as_u64).unwrap_or(200)); let items: Vec = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect(); Ok(json!({"total": ids.len(), "photos": items})) }), @@ -237,3 +243,17 @@ pub fn specs() -> Vec { }), ] } + +#[cfg(test)] +mod pagination_tests { + use super::*; + + #[test] + fn offsets_and_limits_saturate_instead_of_wrapping_on_wasm() { + assert_eq!(pagination_usize(0), 0); + assert_eq!(pagination_usize(200), 200); + assert_eq!(pagination_usize(u64::MAX), usize::MAX); + #[cfg(target_pointer_width = "32")] + assert_eq!(pagination_usize(u64::from(u32::MAX) + 1), usize::MAX); + } +} From 385348b258514e4d788fabd5cb923d4fd594cec1 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:24:09 +0200 Subject: [PATCH 33/42] LR-PROF-CAMERACOLOR: the camera-look guard blends in smoothly instead of switching (#820) Between a kept luminance share of 0.15 and 0.25 the table colour is now blended in with a smoothstep, so noisy shadows show no step between pixels that get the table colour and pixels that keep theirs. At 0.25 and above pixels render exactly as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 58 +++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index b12e968ad..d47c135a5 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -833,9 +833,21 @@ fn to_prophoto() -> Mat3 { PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) } -/// [`HueSat::apply`] restores the luminance only if the table kept at least this share of it; a colour the -/// table darkens more (only near-black noise, whose channels go negative) keeps its colour. +/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour +/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps +/// its colour; in between the two are blended, so noisy shadows show no step. const MIN_LUMINANCE_KEPT: f32 = 0.25; +const NO_LUMINANCE_KEPT: f32 = 0.15; + +/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to +/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between. +fn restore_weight(share: f32) -> f32 { + if !share.is_finite() { + return 0.0; + } + let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} /// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes /// hue and saturation only: luminance is restored, the camera tone curve owns it. @@ -858,7 +870,17 @@ impl<'a> HueSat<'a> { let (before, after) = (luminance_2020(rgb), luminance_2020(out)); // restoring the luminance divides by the table's: near black, where noise makes some channels // negative, the table can bring it close to 0, and the gain would turn the pixel white (#820) - if before > 0.0 && after > MIN_LUMINANCE_KEPT * before && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb } + if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) { + return rgb; + } + let w = restore_weight(after / before); + if w >= 1.0 { + return out.map(|v| v * before / after); + } + if w <= 0.0 { + return rgb; + } + std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) } } @@ -1470,6 +1492,36 @@ mod tests { assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); } + #[test] + fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { + assert_eq!(restore_weight(0.0), 0.0); + assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0); + assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0); + assert_eq!(restore_weight(1.0), 1.0); + assert_eq!(restore_weight(f32::NAN), 0.0); + // monotonic and without a step: neighbouring shares 0.001 apart differ by little + let mut last = 0.0f32; + for i in 0..=400 { + let w = restore_weight(i as f32 * 0.001); + assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001); + last = w; + } + } + + #[test] + fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() { + // the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT + let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let hue_sat = HueSat::new(&table).unwrap(); + let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) }; + for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] { + let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb))); + let (before, after) = (luminance_2020(rgb), luminance_2020(out)); + assert!(after >= MIN_LUMINANCE_KEPT * before); + assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after)); + } + } + fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage { let area = lightcraft_raw::Rect::new(0, 0, 2, 2); RawImage { From ad3c7315bb407137351ec7b0b0ba29f57ecb5413 Mon Sep 17 00:00:00 2001 From: ingolfteetz Date: Sat, 10 Oct 2026 23:42:36 +0200 Subject: [PATCH 34/42] Tests: hold the MCP export-cancel test's export after its first photo until cancelled (#674) export_progress_and_cancel failed every time on a fast machine (M5 Max, 4 export lanes): the 8 demo photos exported within the 100 ms progress throttle, so the first progress above 0 was the final 8/8 and the cancel arrived after the export had finished (a reply to the cancelled request, or all 8 files on disk). The test now wraps Headless in a test-only backend that, at the first photo boundary, keeps polling the server's real progress hook (which answers pings and reads cancels) until the cancel is read or the test releases it, bounded at 20 s so a broken cancel fails rather than hangs. The first export is released once the mid-batch ping is answered (that was the same race), the second only ends by the cancel. That makes the outcome exact, so the check tightens from "1..7 files" to "exactly the one completed photo", which also proves the photos the other lanes had already rendered are never written. No production code changes. Co-Authored-By: Claude Opus 5.5 --- crates/mcp/tests/e2e.rs | 59 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/e2e.rs index ef2e49b6b..8b0396490 100644 --- a/crates/mcp/tests/e2e.rs +++ b/crates/mcp/tests/e2e.rs @@ -4,8 +4,11 @@ use std::io::{BufRead, BufReader, Cursor, Write}; use std::net::TcpListener; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; -use lightcraft_mcp::{Backend, Headless, Remote, Server, base64_decode}; +use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode}; use serde_json::{Value, json}; /// Feed a whole session through `Server::serve` and return the replies by id. @@ -154,8 +157,50 @@ fn remote_unreachable_is_a_tool_error() { assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable")); } +/// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written: +/// it keeps asking the server's progress hook at that photo boundary (which answers pings and +/// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or +/// cancel sent after the first progress then lands mid-batch, however fast the machine exports. +struct HoldAfterFirst { + inner: Headless, + hold: Arc, +} + +impl Backend for HoldAfterFirst { + fn call(&mut self, method: &str, params: Value) -> Result { + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + self.inner.has_ui() + } + + fn describe(&self) -> String { + self.inner.describe() + } + + fn set_progress(&mut self, hook: Option) { + let hold = self.hold.clone(); + self.inner.set_progress(hook.map(|mut hook| -> ProgressHook { + Box::new(move |done, total, name| { + // bounded, so a cancel that is never read fails the test instead of hanging it + let until = Instant::now() + Duration::from_secs(20); + while done == 1 && done < total && hold.load(Ordering::SeqCst) && Instant::now() < until { + if !hook(done, total, name) { + return false; + } + std::thread::sleep(Duration::from_millis(5)); + } + hook(done, total, name) + }) + })); + } +} + /// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation /// between complete photos, unrelated output preservation, and a usable session afterwards. +/// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or +/// the cancel is read, so neither can finish first on a fast machine. #[test] fn export_progress_and_cancel() { let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); @@ -163,7 +208,9 @@ fn export_progress_and_cancel() { 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 hold = Arc::new(AtomicBool::new(true)); + let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() }; + let server = std::thread::spawn(move || Server::new(Box::new(backend)).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() { @@ -173,7 +220,7 @@ fn export_progress_and_cancel() { } } }); - let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress"); + let next = || rx.recv_timeout(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(); @@ -197,6 +244,8 @@ fn export_progress_and_cancel() { } } else if value["id"] == 3 { answered = true; + // the ping was answered mid-batch: let the export run to the end + hold.store(false, Ordering::SeqCst); } else { assert_eq!(value["id"], 2, "{value}"); assert_eq!(value["result"]["isError"], false, "{value}"); @@ -210,6 +259,8 @@ fn export_progress_and_cancel() { std::fs::create_dir_all(&out).unwrap(); let decoy = out.join("photo999.png"); std::fs::write(&decoy, b"unrelated existing output").unwrap(); + // this export waits after its first photo until the cancel below is read + hold.store(true, Ordering::SeqCst); 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(); @@ -234,7 +285,7 @@ fn export_progress_and_cancel() { } 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:?}"); + assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}"); for path in &photos { lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file"); } From 2c8520371ca6c88e9f79a5cd22f02bdb09c98a81 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:44 +0200 Subject: [PATCH 35/42] Dropping photos with XMP sidecars no longer imports the sidecars as presets (#936) --- crates/ui-egui/src/lib.rs | 36 +++++++++++++++++++++++++++++++++++- docs/parity-checklist.md | 2 +- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f853fe166..b0b51b25f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -900,7 +900,7 @@ impl LightcraftApp { { self.toast(ctx, e); } - let (presets, photos): (Vec, Vec) = dropped.into_iter().partition(|p| is_preset_file(p)); + let (presets, photos) = split_presets(dropped); if !presets.is_empty() { let _ = self.run("file.importPresets", serde_json::json!({"paths": presets})); } @@ -1121,8 +1121,42 @@ pub fn is_preset_file(path: &str) -> bool { ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str()) } +/// Splits dropped files into presets and everything else. An `.xmp` dropped next to a photo with the same +/// name (`IMG_1.xmp` or `IMG_1.CR2.xmp` beside `IMG_1.CR2`) is that photo's sidecar, not a preset, and is left out. +pub fn split_presets(dropped: Vec) -> (Vec, Vec) { + let dir_of = |p: &str| std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_lowercase()).unwrap_or_default(); + let stem_of = |p: &str| std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + let name_of = |p: &str| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + let is_xmp = |p: &str| std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp")); + let photo_names: Vec<(String, String)> = dropped + .iter() + .filter(|p| !is_preset_file(p)) + .flat_map(|p| [(dir_of(p), stem_of(p)), (dir_of(p), name_of(p))]) + .collect(); + let (mut presets, mut photos) = (Vec::new(), Vec::new()); + for p in dropped { + if !is_preset_file(&p) { + photos.push(p); + } else if is_xmp(&p) && photo_names.contains(&(dir_of(&p), stem_of(&p))) { + // a photo's sidecar travels with the photo + } else { + presets.push(p); + } + } + (presets, photos) +} + #[cfg(test)] mod drop_tests { + #[test] + fn photo_sidecars_are_not_presets() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]); + let (presets, photos) = super::split_presets(dropped); + assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"])); + assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"])); + } + #[test] fn detection_results_are_summarised_in_words() { let r = diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..0b67da5a5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -77,7 +77,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| 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-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 (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); 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 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; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); 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; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet), 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.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Hasselblad 3FR and FFF (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`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, 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 (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, 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), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), 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, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | From a7382aacfb6b4fb675b0458717bc4409b78b632f Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 22:20:03 +1100 Subject: [PATCH 36/42] Masking: Color Range "Pick" samples the photo With the Masking panel open the loupe routes clicks to mask_overlay, so the colorRange eyedropper armed by the Pick button (handled only in general_interaction) never ran and no sample was taken. mask_overlay now handles the eyedropper (crosshair cursor, click samples, shift-click adds) through a shared color_range_pick helper. Fixes #954 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels/detail.rs | 35 ++++++++++++----- crates/ui-egui/src/tests_masking.rs | 60 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..d997559ab 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1148,16 +1148,7 @@ fn general_interaction( return; } if app.ui.tool == "colorRange" { - // click: sample the colour for the selected mask's colour range; ⇧-click adds (up to 5) - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let add = ui.input(|i| i.modifiers.shift); - if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) { - app.toast(ui.ctx(), e); - } - } + color_range_pick(app, ui, resp, map); return; } if app.ui.tool == "pointColor" { @@ -1546,6 +1537,21 @@ fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { /// 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`]). +/// Colour Range eyedropper: a click samples the colour under it for the selected mask's +/// colour range, at normalized frame coordinates (crop, rotation and zoom aware); +/// ⇧-click adds a sample (up to 5). +fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) { + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let add = ui.input(|i| i.modifiers.shift); + if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) { + app.toast(ui.ctx(), e); + } + } +} + fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { let tint = { let [r, g, b] = app.ui.mask_overlay_color; @@ -1638,6 +1644,15 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } } + // Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction + // never runs while it is open), so the eyedropper is handled here too. + if app.ui.tool == "colorRange" { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + color_range_pick(app, ui, resp, map); + return; + } // Object tool: a click includes what's under it, ⌥-click leaves it out if app.ui.tool == "object" { if resp.hovered() { diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index b95fa1ab2..bda58d65e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1126,3 +1126,63 @@ fn ai_masks_without_the_model_offer_the_download() { assert!(develop(&h).masks.is_empty()); assert!(t.elapsed() < SETTLE, "{:?}", t.elapsed()); } + +fn color_samples(h: &Headless) -> serde_json::Value { + let d = develop(h); + let m = d.masks.last().expect("a mask"); + let Some(MaskShape::ColorRange { samples, .. }) = m.components.iter().map(|c| &c.shape).find(|s| matches!(s, MaskShape::ColorRange { .. })) + else { + panic!("colour range") + }; + serde_json::to_value(samples).expect("samples") +} + +/// Same samples, up to sub-pixel rounding and the preview level sampled. +#[track_caller] +fn assert_close(a: &serde_json::Value, b: &serde_json::Value) { + let flat = |v: &serde_json::Value| -> Vec { + v.as_array().unwrap().iter().flat_map(|s| s.as_array().unwrap().iter().map(|x| x.as_f64().unwrap())).collect() + }; + let (a, b) = (flat(a), flat(b)); + assert_eq!(a.len(), b.len()); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 0.05), "{a:?} vs {b:?}"); +} + +/// Regression: the Colour Range "Pick" button armed the eyedropper, but with the Masking panel +/// open the loupe's clicks went to the mask overlay, so no sample was ever taken. +#[test] +fn color_range_pick_button_samples_the_photo() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "colorRange"})); + h.app.ui.tool.clear(); + h.settle(SETTLE); + click(&mut h, "button:colorPick0"); + assert_eq!(h.app.ui.tool, "colorRange", "Pick arms the eyedropper"); + brush_at(&mut h, 0.25, 0.5); + let ui_sample = color_samples(&h); + assert_eq!(ui_sample.as_array().map(Vec::len), Some(1), "a click samples a colour"); + // the sample is the colour at that normalized point + exec(&mut h, "mask.sampleColor", json!({"x": 0.25, "y": 0.5})); + assert_close(&color_samples(&h), &ui_sample); + // ⇧-click adds + let r = h.request("ui.pointer", json!({"shift": true, "events": [{"kind": "down", "x": 0.7, "y": 0.2}, {"kind": "up", "x": 0.7, "y": 0.2}]}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(color_samples(&h).as_array().map(Vec::len), Some(2), "⇧-click adds a sample"); + + // cropped and zoomed: the click samples the photo point under it + exec(&mut h, "crop.set", json!({"rect": [0.2, 0.1, 0.9, 0.8]})); + h.app.ui.zoom = crate::state::Zoom::Percent(200.0); + h.app.ui.pan = (0.5, 0.5); + h.settle(SETTLE); + brush_at(&mut h, 0.5, 0.5); + let ui_sample = color_samples(&h); + assert_eq!(ui_sample.as_array().map(Vec::len), Some(1)); + // (the centre of the crop box, in normalized source coordinates) + let c = develop(&h).crop.geometry.rect; + exec(&mut h, "mask.sampleColor", json!({"x": (c.x0 + c.x1) / 2.0, "y": (c.y0 + c.y1) / 2.0})); + assert_close(&color_samples(&h), &ui_sample); // a cropped, zoomed pick maps to the same frame point + // Pick again ends picking + click(&mut h, "button:colorPick0"); + assert!(h.app.ui.tool.is_empty()); + h.settle(SETTLE); +} From 2bc33268d2b010ff2019f3d1d58a599065740163 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:40:34 +0000 Subject: [PATCH 37/42] Issue #793: the last two mentions of target/web point at target/web-site (from #888) Co-Authored-By: Vikram Bhaskaran Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4 --- apps/lightcraft-web/src/lib.rs | 2 +- docs/localization-zh-hans.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs index 04ec89b54..58966e6e8 100644 --- a/apps/lightcraft-web/src/lib.rs +++ b/apps/lightcraft-web/src/lib.rs @@ -1,6 +1,6 @@ //! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM. //! -//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see +//! Build with `cargo xtask web` (→ `target/web-site/`), then serve that folder over HTTP (see //! `docs/web.md`). Differences from the desktop host: //! - the library lives in browser storage (OPFS, or IndexedDB where OPFS can't be written from the //! main thread): catalog journal, presets, view state and prefs are mirrored in memory and diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index 4b9d887ab..e87431582 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -14,7 +14,7 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日 - 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。 - 网页版将 Noto Sans CJK SC 作为独立的 `lightcraft_zh_hans.otf` 文件随站点分发,页面启动时加载; 这样可显示简体中文,也避免把约 16 MB 字体放进受单文件大小限制的 WASM。部署时需上传整个 - `target/web/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。 + `target/web-site/` 目录。未指定 `CRAFT_FONTS_DIR` 的开发构建仍缺少中文字体。 - 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。 ## 翻译的维护 From 69b90ee7c40ed213969c41b4a66abeeee4c3b507 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:40:41 +0000 Subject: [PATCH 38/42] LR-META-EXIF-FULL: tags.rs back to LF line endings (#831 mixed in CRLF) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4 --- crates/meta/src/tags.rs | 928 ++++++++++++++++++++-------------------- 1 file changed, 464 insertions(+), 464 deletions(-) diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index cdddc04be..39e26097d 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -1,464 +1,464 @@ -//! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info -//! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; -//! common enumerations are spelled out, everything else is shown as stored. Maker notes and -//! binary blobs are summarised by size. - -use lightcraft_tiff::{Ifd, Tiff, Value}; - -/// One metadata row. -#[derive(Clone, Debug, PartialEq)] -pub struct TagRow { - /// `TIFF`, `EXIF`, `GPS`, `Interop`. - pub group: &'static str, - pub tag: u16, - pub name: String, - pub value: String, -} - -const NAMES: &[(u16, &str)] = &[ - // TIFF / IFD0 - (0x00fe, "Subfile Type"), - (0x0100, "Image Width"), - (0x0101, "Image Height"), - (0x0102, "Bits Per Sample"), - (0x0103, "Compression"), - (0x0106, "Photometric Interpretation"), - (0x010e, "Image Description"), - (0x010f, "Make"), - (0x0110, "Model"), - (0x0112, "Orientation"), - (0x0115, "Samples Per Pixel"), - (0x0116, "Rows Per Strip"), - (0x0214, "Reference Black/White"), - (0x9216, "TIFF/EP Standard ID"), - (0x828d, "CFA Repeat Pattern Dim"), - (0x828e, "CFA Pattern"), - (0xa302, "CFA Pattern"), - (0x9217, "Sensing Method"), - (0xc4a5, "Print Image Matching"), - (0xa500, "Gamma"), - (0x011a, "X Resolution"), - (0x011b, "Y Resolution"), - (0x011c, "Planar Configuration"), - (0x0128, "Resolution Unit"), - (0x0131, "Software"), - (0x0132, "Date/Time Modified"), - (0x013b, "Artist"), - (0x013e, "White Point"), - (0x013f, "Primary Chromaticities"), - (0x0213, "YCbCr Positioning"), - (0x8298, "Copyright"), - (0xc612, "DNG Version"), - (0xc614, "Unique Camera Model"), - (0xc62f, "Camera Serial Number"), - // EXIF - (0x829a, "Exposure Time"), - (0x829d, "F-Number"), - (0x8822, "Exposure Program"), - (0x8824, "Spectral Sensitivity"), - (0x8827, "ISO Speed"), - (0x8830, "Sensitivity Type"), - (0x8832, "Recommended Exposure Index"), - (0x9000, "Exif Version"), - (0x9003, "Date/Time Original"), - (0x9004, "Date/Time Digitized"), - (0x9010, "Offset Time"), - (0x9011, "Offset Time Original"), - (0x9012, "Offset Time Digitized"), - (0x9101, "Components Configuration"), - (0x9102, "Compressed Bits Per Pixel"), - (0x9201, "Shutter Speed Value"), - (0x9202, "Aperture Value"), - (0x9203, "Brightness Value"), - (0x9204, "Exposure Compensation"), - (0x9205, "Max Aperture Value"), - (0x9206, "Subject Distance"), - (0x9207, "Metering Mode"), - (0x9208, "Light Source"), - (0x9209, "Flash"), - (0x920a, "Focal Length"), - (0x9214, "Subject Area"), - (0x927c, "Maker Note"), - (0x9286, "User Comment"), - (0x9290, "Sub-second Time"), - (0x9291, "Sub-second Time Original"), - (0x9292, "Sub-second Time Digitized"), - (0xa000, "FlashPix Version"), - (0xa001, "Color Space"), - (0xa002, "Pixel X Dimension"), - (0xa003, "Pixel Y Dimension"), - (0xa20e, "Focal Plane X Resolution"), - (0xa20f, "Focal Plane Y Resolution"), - (0xa210, "Focal Plane Resolution Unit"), - (0xa215, "Exposure Index"), - (0xa217, "Sensing Method"), - (0xa300, "File Source"), - (0xa301, "Scene Type"), - (0xa401, "Custom Rendered"), - (0xa402, "Exposure Mode"), - (0xa403, "White Balance"), - (0xa404, "Digital Zoom Ratio"), - (0xa405, "Focal Length in 35mm Film"), - (0xa406, "Scene Capture Type"), - (0xa407, "Gain Control"), - (0xa408, "Contrast"), - (0xa409, "Saturation"), - (0xa40a, "Sharpness"), - (0xa40c, "Subject Distance Range"), - (0xa420, "Image Unique ID"), - (0xa430, "Camera Owner Name"), - (0xa431, "Body Serial Number"), - (0xa432, "Lens Specification"), - (0xa433, "Lens Make"), - (0xa434, "Lens Model"), - (0xa435, "Lens Serial Number"), - // GPS - (0x0000, "GPS Version"), - (0x0001, "GPS Latitude Ref"), - (0x0002, "GPS Latitude"), - (0x0003, "GPS Longitude Ref"), - (0x0004, "GPS Longitude"), - (0x0005, "GPS Altitude Ref"), - (0x0006, "GPS Altitude"), - (0x0007, "GPS Time Stamp"), - (0x000c, "GPS Speed Ref"), - (0x000d, "GPS Speed"), - (0x0010, "GPS Img Direction Ref"), - (0x0011, "GPS Img Direction"), - (0x0012, "GPS Map Datum"), - (0x001d, "GPS Date Stamp"), -]; - -/// GPS tags reuse low numbers, so they are looked up separately. -fn name(group: &str, tag: u16) -> String { - let gps = group == "GPS"; - NAMES - .iter() - .find(|(t, n)| *t == tag && (n.starts_with("GPS") == gps || (gps && tag > 0x1f))) - .map(|(_, n)| n.to_string()) - .unwrap_or_else(|| format!("Tag 0x{tag:04X}")) -} - -fn rational(n: f64, d: f64) -> String { - if d == 0.0 { - return "—".into(); - } - let v = n / d; - if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() } -} - -fn list(v: &[T]) -> String { - let shown: Vec = v.iter().take(16).map(|x| x.to_string()).collect(); - if v.len() > 16 { format!("{} … ({} values)", shown.join(", "), v.len()) } else { shown.join(", ") } -} - -/// Spelled-out enumerations for the commonest tags. -fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> { - if group == "GPS" { - return None; - } - Some(match (tag, v) { - (0x0112, 1) => "Horizontal (normal)", - (0x0112, 3) => "Rotated 180°", - (0x0112, 6) => "Rotated 90° CW", - (0x0112, 8) => "Rotated 90° CCW", - (0x0112, 2) => "Mirrored horizontally", - (0x0112, 4) => "Mirrored vertically", - (0x0103, 1) => "Uncompressed", - (0x0103, 6) => "JPEG (old-style)", - (0x0103, 7) => "JPEG", - (0x0103, 8) => "Deflate", - (0x0103, 32773) => "PackBits", - (0x0103, 34892) => "Lossy JPEG", - (0x0103, 34713) => "Nikon NEF Compressed", - (0x0103, 32767) => "Sony ARW Compressed", - (0x0103, 52546) => "JPEG XL", - (0x0106, 0) => "WhiteIsZero", - (0x0106, 1) => "BlackIsZero", - (0x0106, 2) => "RGB", - (0x0106, 6) => "YCbCr", - (0x0106, 32803) => "Color Filter Array", - (0x0106, 34892) => "Linear Raw", - (0x011c, 1) => "Chunky", - (0x011c, 2) => "Planar", - (0x0128, 2) => "inches", - (0x0128, 3) => "centimetres", - (0x8822, 1) => "Manual", - (0x8822, 2) => "Program", - (0x8822, 3) => "Aperture priority", - (0x8822, 4) => "Shutter priority", - (0x9207, 2) => "Center-weighted average", - (0x9207, 3) => "Spot", - (0x9207, 5) => "Pattern", - (0xa001, 1) => "sRGB", - (0xa001, 0xffff) => "Uncalibrated", - (0xa402, 0) => "Auto", - (0xa402, 1) => "Manual", - (0xa402, 2) => "Auto bracket", - (0xa403, 0) => "Auto", - (0xa403, 1) => "Manual", - (0xa406, 0) => "Standard", - (0xa406, 1) => "Landscape", - (0xa406, 2) => "Portrait", - (0xa406, 3) => "Night", - (0x9209, 0) => "No flash", - (0x9209, 1) => "Fired", - (0x9209, 5) => "Fired, return not detected", - (0x9209, 7) => "Fired, return detected", - (0x9209, 9) => "On, fired", - (0x9209, 16) => "Off, did not fire", - (0x9209, 24) => "Auto, did not fire", - (0x9209, 25) => "Auto, fired", - (0x9209, 32) => "No flash function", - (0x9208, 0) => "Unknown", - (0x9208, 1) => "Daylight", - (0x9208, 2) => "Fluorescent", - (0x9208, 3) => "Tungsten", - (0x9208, 4) => "Flash", - _ => return None, - }) -} - -fn value_text(group: &str, tag: u16, v: &Value) -> String { - let single = |x: i64| enumerated(group, tag, x).map(str::to_string).unwrap_or_else(|| x.to_string()); - match v { - Value::Ascii(s) => s.trim().to_string(), - Value::Short(a) if a.len() == 1 => single(a[0] as i64), - Value::Long(a) if a.len() == 1 => single(a[0] as i64), - Value::Short(a) => list(a), - Value::Long(a) => list(a), - Value::SShort(a) => list(a), - Value::SLong(a) => list(a), - Value::Long8(a) => list(a), - Value::SLong8(a) => list(a), - Value::Float(a) => list(a), - Value::Double(a) => list(a), - Value::Rational(a) if tag == 0x829a && group != "GPS" && a.len() == 1 && a[0].1 != 0 => { - // exposure time as a fraction of a second - let (n, d) = a[0]; - let s = n as f64 / d as f64; - if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) } - } - Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)), - Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)), - Value::Rational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), - Value::SRational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), - Value::Byte(b) | Value::Undefined(b) => { - // version tags and short printable values read as text - let printable = b.len() <= 64 && b.iter().all(|c| c.is_ascii_graphic() || *c == b' ' || *c == 0); - if printable && !b.is_empty() { - String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string() - } else if b.len() <= 8 { - list(b) - } else { - format!("({} bytes)", b.len()) - } - } - Value::SByte(a) => list(a), - Value::Ifd(a) => format!("(IFD at {})", list(a)), - Value::Ifd8(a) => format!("(IFD at {})", list(a)), - } -} - -fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec) { - for e in &ifd.entries { - // pointers to other IFDs and image data are structure, not metadata - if matches!(e.tag, 0x8769 | 0x8825 | 0xa005 | 0x014a | 0x0111 | 0x0117 | 0x0144 | 0x0145 | 0x0201 | 0x0202 | 0x02bc | 0x83bb | 0x8773) - && group != "GPS" - { - continue; - } - out.push(TagRow { group, tag: e.tag, name: name(group, e.tag), value: value_text(group, e.tag, &e.value) }); - } -} - -/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw -/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's. -fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> { - ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892))) -} - -/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation. -fn nef_compression(v: u32) -> Option<&'static str> { - Some(match v { - 1 => "Lossy (type 1)", - 2 => "Uncompressed", - 3 => "Lossless", - 4 => "Lossy (type 2)", - 5 => "Striped packed 12 bits", - 6 => "Uncompressed (reduced to 12 bit)", - 7 => "Unpacked 12 bits", - 8 => "Small", - 9 => "Packed 12 bits", - 10 => "Packed 14 bits", - 13 => "High Efficiency", - 14 => "High Efficiency*", - _ => return None, - }) -} - -/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values. -fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> { - let ifd0 = tiff.ifds.first()?; - let make = ifd0.string(0x010f)?; - if !make.to_ascii_uppercase().starts_with("NIKON") { - return None; - } - let e = ifd0.exif.as_ref()?.get(0x927c)?; - let mn = lightcraft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; - nef_compression(mn.ifd.u32(0x0093)?) -} - -/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, -/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor -/// image's, not IFD0's thumbnail's. -pub fn tag_rows(tiff: &Tiff) -> Vec { - let mut out = Vec::new(); - if let Some(ifd0) = tiff.ifds.first() { - rows_of("TIFF", ifd0, &mut out); - if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103)) - && let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103) - { - // lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy - row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) }; - } - if let Some(e) = &ifd0.exif { - rows_of("EXIF", e, &mut out); - if let Some(i) = &e.interop { - rows_of("Interop", i, &mut out); - } - } - if let Some(g) = &ifd0.gps { - rows_of("GPS", g, &mut out); - } - } - out -} - -/// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block -/// (JPEG, PNG, WebP, HEIF…). Empty when there is none. -pub fn file_tag_rows(bytes: &[u8]) -> Vec { - if Tiff::sniff(bytes).is_some() - && let Ok(t) = Tiff::parse(bytes) - { - let mut rows = tag_rows(&t); - if let Some(words) = nikon_nef_compression(&t, bytes) - && let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103) - { - row.value = words.to_string(); - } - return rows; - } - let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; - Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rows_from_written_exif() { - let m = crate::Metadata { - make: Some("Maker".into()), - model: Some("Model X".into()), - exposure_time: Some(1.0 / 250.0), - f_number: Some(2.8), - iso: Some(400), - focal_length: Some(35.0), - gps: Some(crate::Gps { latitude: 51.5, longitude: -0.12, altitude: Some(20.0) }), - ..Default::default() - }; - let exif = crate::write_exif(&m); - let t = Tiff::parse(crate::strip_exif_header(&exif)).unwrap(); - let rows = tag_rows(&t); - let get = |n: &str| rows.iter().find(|r| r.name == n).map(|r| r.value.clone()); - assert_eq!(get("Make").as_deref(), Some("Maker")); - assert_eq!(get("Exposure Time").as_deref(), Some("1/250 s")); - assert_eq!(get("F-Number").as_deref(), Some("f/2.8")); - assert_eq!(get("Focal Length").as_deref(), Some("35 mm")); - assert_eq!(get("ISO Speed").as_deref(), Some("400")); - assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}"); - assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out"); - } - - fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec { - let mut e = Vec::new(); - e.extend(tag.to_le_bytes()); - e.extend(ty.to_le_bytes()); - e.extend(count.to_le_bytes()); - e.extend(val.to_le_bytes()); - e - } - - fn ifd(entries: &[Vec]) -> Vec { - let mut b = (entries.len() as u16).to_le_bytes().to_vec(); - entries.iter().for_each(|e| b.extend(e)); - b.extend(0u32.to_le_bytes()); - b - } - - /// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression` - /// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value. - fn synthetic_raw(nef: Option) -> Vec { - let make = b"NIKON CORPORATION"; - let mut note = b"NikonII*".to_vec(); - note.extend(ifd(&[entry(0x0093, 3, 1, nef.unwrap_or(0) as u32)])); - let ifd0_len = 2 + 4 * 12 + 4; - let sub_at = 8 + ifd0_len; - let sub = ifd(&[entry(0x0103, 3, 1, 34713), entry(0x0106, 3, 1, 32803)]); - let exif_at = sub_at + sub.len(); - let exif_len = 2 + 12 + 4; - let make_at = exif_at + exif_len; - let note_at = make_at + make.len(); - let mut b = b"II*".to_vec(); - b.extend(ifd(&[ - entry(0x0103, 3, 1, 1), - entry(0x010f, 2, make.len() as u32, make_at as u32), - entry(0x014a, 4, 1, sub_at as u32), - entry(0x8769, 4, 1, exif_at as u32), - ])); - b.extend(sub); - b.extend(ifd(&[entry(0x927c, 7, note.len() as u32, if nef.is_some() { note_at as u32 } else { 0 })])); - b.extend(make); - b.extend(note); - b - } - - fn compression(rows: &[TagRow]) -> Vec<&str> { - rows.iter().filter(|r| r.group == "TIFF" && r.tag == 0x0103).map(|r| r.value.as_str()).collect() - } - - #[test] - fn raw_compression_is_the_sensor_images_not_the_thumbnails() { - let b = synthetic_raw(None); - assert_eq!(compression(&file_tag_rows(&b)), ["Nikon NEF Compressed"]); - } - - #[test] - fn nef_compression_is_spelled_out_from_the_maker_note() { - for (v, words) in [(3, "Lossless"), (10, "Packed 14 bits"), (2, "Uncompressed"), (14, "High Efficiency*")] { - let b = synthetic_raw(Some(v)); - assert_eq!(compression(&file_tag_rows(&b)), [words]); - } - } - - #[test] - fn dng_raw_sub_ifd_jpeg_is_lossless_but_a_plain_jpeg_tiff_is_not() { - let b = synthetic_raw(None); - // switch the sensor sub-IFD's Compression from 34713 to 7 - let pos = b.windows(12).position(|w| w == entry(0x0103, 3, 1, 34713).as_slice()).unwrap(); - let mut dng = b.clone(); - dng[pos + 8] = 7; - dng[pos + 9] = 0; - assert_eq!(compression(&file_tag_rows(&dng)), ["Lossless JPEG"]); - let plain = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 7)])].concat(); - assert_eq!(compression(&file_tag_rows(&plain)), ["JPEG"]); - } - - #[test] - fn plain_tiff_keeps_its_own_compression() { - let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); - assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]); - } -} +//! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info +//! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; +//! common enumerations are spelled out, everything else is shown as stored. Maker notes and +//! binary blobs are summarised by size. + +use lightcraft_tiff::{Ifd, Tiff, Value}; + +/// One metadata row. +#[derive(Clone, Debug, PartialEq)] +pub struct TagRow { + /// `TIFF`, `EXIF`, `GPS`, `Interop`. + pub group: &'static str, + pub tag: u16, + pub name: String, + pub value: String, +} + +const NAMES: &[(u16, &str)] = &[ + // TIFF / IFD0 + (0x00fe, "Subfile Type"), + (0x0100, "Image Width"), + (0x0101, "Image Height"), + (0x0102, "Bits Per Sample"), + (0x0103, "Compression"), + (0x0106, "Photometric Interpretation"), + (0x010e, "Image Description"), + (0x010f, "Make"), + (0x0110, "Model"), + (0x0112, "Orientation"), + (0x0115, "Samples Per Pixel"), + (0x0116, "Rows Per Strip"), + (0x0214, "Reference Black/White"), + (0x9216, "TIFF/EP Standard ID"), + (0x828d, "CFA Repeat Pattern Dim"), + (0x828e, "CFA Pattern"), + (0xa302, "CFA Pattern"), + (0x9217, "Sensing Method"), + (0xc4a5, "Print Image Matching"), + (0xa500, "Gamma"), + (0x011a, "X Resolution"), + (0x011b, "Y Resolution"), + (0x011c, "Planar Configuration"), + (0x0128, "Resolution Unit"), + (0x0131, "Software"), + (0x0132, "Date/Time Modified"), + (0x013b, "Artist"), + (0x013e, "White Point"), + (0x013f, "Primary Chromaticities"), + (0x0213, "YCbCr Positioning"), + (0x8298, "Copyright"), + (0xc612, "DNG Version"), + (0xc614, "Unique Camera Model"), + (0xc62f, "Camera Serial Number"), + // EXIF + (0x829a, "Exposure Time"), + (0x829d, "F-Number"), + (0x8822, "Exposure Program"), + (0x8824, "Spectral Sensitivity"), + (0x8827, "ISO Speed"), + (0x8830, "Sensitivity Type"), + (0x8832, "Recommended Exposure Index"), + (0x9000, "Exif Version"), + (0x9003, "Date/Time Original"), + (0x9004, "Date/Time Digitized"), + (0x9010, "Offset Time"), + (0x9011, "Offset Time Original"), + (0x9012, "Offset Time Digitized"), + (0x9101, "Components Configuration"), + (0x9102, "Compressed Bits Per Pixel"), + (0x9201, "Shutter Speed Value"), + (0x9202, "Aperture Value"), + (0x9203, "Brightness Value"), + (0x9204, "Exposure Compensation"), + (0x9205, "Max Aperture Value"), + (0x9206, "Subject Distance"), + (0x9207, "Metering Mode"), + (0x9208, "Light Source"), + (0x9209, "Flash"), + (0x920a, "Focal Length"), + (0x9214, "Subject Area"), + (0x927c, "Maker Note"), + (0x9286, "User Comment"), + (0x9290, "Sub-second Time"), + (0x9291, "Sub-second Time Original"), + (0x9292, "Sub-second Time Digitized"), + (0xa000, "FlashPix Version"), + (0xa001, "Color Space"), + (0xa002, "Pixel X Dimension"), + (0xa003, "Pixel Y Dimension"), + (0xa20e, "Focal Plane X Resolution"), + (0xa20f, "Focal Plane Y Resolution"), + (0xa210, "Focal Plane Resolution Unit"), + (0xa215, "Exposure Index"), + (0xa217, "Sensing Method"), + (0xa300, "File Source"), + (0xa301, "Scene Type"), + (0xa401, "Custom Rendered"), + (0xa402, "Exposure Mode"), + (0xa403, "White Balance"), + (0xa404, "Digital Zoom Ratio"), + (0xa405, "Focal Length in 35mm Film"), + (0xa406, "Scene Capture Type"), + (0xa407, "Gain Control"), + (0xa408, "Contrast"), + (0xa409, "Saturation"), + (0xa40a, "Sharpness"), + (0xa40c, "Subject Distance Range"), + (0xa420, "Image Unique ID"), + (0xa430, "Camera Owner Name"), + (0xa431, "Body Serial Number"), + (0xa432, "Lens Specification"), + (0xa433, "Lens Make"), + (0xa434, "Lens Model"), + (0xa435, "Lens Serial Number"), + // GPS + (0x0000, "GPS Version"), + (0x0001, "GPS Latitude Ref"), + (0x0002, "GPS Latitude"), + (0x0003, "GPS Longitude Ref"), + (0x0004, "GPS Longitude"), + (0x0005, "GPS Altitude Ref"), + (0x0006, "GPS Altitude"), + (0x0007, "GPS Time Stamp"), + (0x000c, "GPS Speed Ref"), + (0x000d, "GPS Speed"), + (0x0010, "GPS Img Direction Ref"), + (0x0011, "GPS Img Direction"), + (0x0012, "GPS Map Datum"), + (0x001d, "GPS Date Stamp"), +]; + +/// GPS tags reuse low numbers, so they are looked up separately. +fn name(group: &str, tag: u16) -> String { + let gps = group == "GPS"; + NAMES + .iter() + .find(|(t, n)| *t == tag && (n.starts_with("GPS") == gps || (gps && tag > 0x1f))) + .map(|(_, n)| n.to_string()) + .unwrap_or_else(|| format!("Tag 0x{tag:04X}")) +} + +fn rational(n: f64, d: f64) -> String { + if d == 0.0 { + return "—".into(); + } + let v = n / d; + if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() } +} + +fn list(v: &[T]) -> String { + let shown: Vec = v.iter().take(16).map(|x| x.to_string()).collect(); + if v.len() > 16 { format!("{} … ({} values)", shown.join(", "), v.len()) } else { shown.join(", ") } +} + +/// Spelled-out enumerations for the commonest tags. +fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> { + if group == "GPS" { + return None; + } + Some(match (tag, v) { + (0x0112, 1) => "Horizontal (normal)", + (0x0112, 3) => "Rotated 180°", + (0x0112, 6) => "Rotated 90° CW", + (0x0112, 8) => "Rotated 90° CCW", + (0x0112, 2) => "Mirrored horizontally", + (0x0112, 4) => "Mirrored vertically", + (0x0103, 1) => "Uncompressed", + (0x0103, 6) => "JPEG (old-style)", + (0x0103, 7) => "JPEG", + (0x0103, 8) => "Deflate", + (0x0103, 32773) => "PackBits", + (0x0103, 34892) => "Lossy JPEG", + (0x0103, 34713) => "Nikon NEF Compressed", + (0x0103, 32767) => "Sony ARW Compressed", + (0x0103, 52546) => "JPEG XL", + (0x0106, 0) => "WhiteIsZero", + (0x0106, 1) => "BlackIsZero", + (0x0106, 2) => "RGB", + (0x0106, 6) => "YCbCr", + (0x0106, 32803) => "Color Filter Array", + (0x0106, 34892) => "Linear Raw", + (0x011c, 1) => "Chunky", + (0x011c, 2) => "Planar", + (0x0128, 2) => "inches", + (0x0128, 3) => "centimetres", + (0x8822, 1) => "Manual", + (0x8822, 2) => "Program", + (0x8822, 3) => "Aperture priority", + (0x8822, 4) => "Shutter priority", + (0x9207, 2) => "Center-weighted average", + (0x9207, 3) => "Spot", + (0x9207, 5) => "Pattern", + (0xa001, 1) => "sRGB", + (0xa001, 0xffff) => "Uncalibrated", + (0xa402, 0) => "Auto", + (0xa402, 1) => "Manual", + (0xa402, 2) => "Auto bracket", + (0xa403, 0) => "Auto", + (0xa403, 1) => "Manual", + (0xa406, 0) => "Standard", + (0xa406, 1) => "Landscape", + (0xa406, 2) => "Portrait", + (0xa406, 3) => "Night", + (0x9209, 0) => "No flash", + (0x9209, 1) => "Fired", + (0x9209, 5) => "Fired, return not detected", + (0x9209, 7) => "Fired, return detected", + (0x9209, 9) => "On, fired", + (0x9209, 16) => "Off, did not fire", + (0x9209, 24) => "Auto, did not fire", + (0x9209, 25) => "Auto, fired", + (0x9209, 32) => "No flash function", + (0x9208, 0) => "Unknown", + (0x9208, 1) => "Daylight", + (0x9208, 2) => "Fluorescent", + (0x9208, 3) => "Tungsten", + (0x9208, 4) => "Flash", + _ => return None, + }) +} + +fn value_text(group: &str, tag: u16, v: &Value) -> String { + let single = |x: i64| enumerated(group, tag, x).map(str::to_string).unwrap_or_else(|| x.to_string()); + match v { + Value::Ascii(s) => s.trim().to_string(), + Value::Short(a) if a.len() == 1 => single(a[0] as i64), + Value::Long(a) if a.len() == 1 => single(a[0] as i64), + Value::Short(a) => list(a), + Value::Long(a) => list(a), + Value::SShort(a) => list(a), + Value::SLong(a) => list(a), + Value::Long8(a) => list(a), + Value::SLong8(a) => list(a), + Value::Float(a) => list(a), + Value::Double(a) => list(a), + Value::Rational(a) if tag == 0x829a && group != "GPS" && a.len() == 1 && a[0].1 != 0 => { + // exposure time as a fraction of a second + let (n, d) = a[0]; + let s = n as f64 / d as f64; + if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) } + } + Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)), + Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)), + Value::Rational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), + Value::SRational(a) => a.iter().map(|(n, d)| rational(*n as f64, *d as f64)).collect::>().join(", "), + Value::Byte(b) | Value::Undefined(b) => { + // version tags and short printable values read as text + let printable = b.len() <= 64 && b.iter().all(|c| c.is_ascii_graphic() || *c == b' ' || *c == 0); + if printable && !b.is_empty() { + String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string() + } else if b.len() <= 8 { + list(b) + } else { + format!("({} bytes)", b.len()) + } + } + Value::SByte(a) => list(a), + Value::Ifd(a) => format!("(IFD at {})", list(a)), + Value::Ifd8(a) => format!("(IFD at {})", list(a)), + } +} + +fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec) { + for e in &ifd.entries { + // pointers to other IFDs and image data are structure, not metadata + if matches!(e.tag, 0x8769 | 0x8825 | 0xa005 | 0x014a | 0x0111 | 0x0117 | 0x0144 | 0x0145 | 0x0201 | 0x0202 | 0x02bc | 0x83bb | 0x8773) + && group != "GPS" + { + continue; + } + out.push(TagRow { group, tag: e.tag, name: name(group, e.tag), value: value_text(group, e.tag, &e.value) }); + } +} + +/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw +/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's. +fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> { + ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892))) +} + +/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation. +fn nef_compression(v: u32) -> Option<&'static str> { + Some(match v { + 1 => "Lossy (type 1)", + 2 => "Uncompressed", + 3 => "Lossless", + 4 => "Lossy (type 2)", + 5 => "Striped packed 12 bits", + 6 => "Uncompressed (reduced to 12 bit)", + 7 => "Unpacked 12 bits", + 8 => "Small", + 9 => "Packed 12 bits", + 10 => "Packed 14 bits", + 13 => "High Efficiency", + 14 => "High Efficiency*", + _ => return None, + }) +} + +/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values. +fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> { + let ifd0 = tiff.ifds.first()?; + let make = ifd0.string(0x010f)?; + if !make.to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let e = ifd0.exif.as_ref()?.get(0x927c)?; + let mn = lightcraft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + nef_compression(mn.ifd.u32(0x0093)?) +} + +/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, +/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor +/// image's, not IFD0's thumbnail's. +pub fn tag_rows(tiff: &Tiff) -> Vec { + let mut out = Vec::new(); + if let Some(ifd0) = tiff.ifds.first() { + rows_of("TIFF", ifd0, &mut out); + if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103)) + && let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103) + { + // lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy + row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) }; + } + if let Some(e) = &ifd0.exif { + rows_of("EXIF", e, &mut out); + if let Some(i) = &e.interop { + rows_of("Interop", i, &mut out); + } + } + if let Some(g) = &ifd0.gps { + rows_of("GPS", g, &mut out); + } + } + out +} + +/// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block +/// (JPEG, PNG, WebP, HEIF…). Empty when there is none. +pub fn file_tag_rows(bytes: &[u8]) -> Vec { + if Tiff::sniff(bytes).is_some() + && let Ok(t) = Tiff::parse(bytes) + { + let mut rows = tag_rows(&t); + if let Some(words) = nikon_nef_compression(&t, bytes) + && let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103) + { + row.value = words.to_string(); + } + return rows; + } + let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; + Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rows_from_written_exif() { + let m = crate::Metadata { + make: Some("Maker".into()), + model: Some("Model X".into()), + exposure_time: Some(1.0 / 250.0), + f_number: Some(2.8), + iso: Some(400), + focal_length: Some(35.0), + gps: Some(crate::Gps { latitude: 51.5, longitude: -0.12, altitude: Some(20.0) }), + ..Default::default() + }; + let exif = crate::write_exif(&m); + let t = Tiff::parse(crate::strip_exif_header(&exif)).unwrap(); + let rows = tag_rows(&t); + let get = |n: &str| rows.iter().find(|r| r.name == n).map(|r| r.value.clone()); + assert_eq!(get("Make").as_deref(), Some("Maker")); + assert_eq!(get("Exposure Time").as_deref(), Some("1/250 s")); + assert_eq!(get("F-Number").as_deref(), Some("f/2.8")); + assert_eq!(get("Focal Length").as_deref(), Some("35 mm")); + assert_eq!(get("ISO Speed").as_deref(), Some("400")); + assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}"); + assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out"); + } + + fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec { + let mut e = Vec::new(); + e.extend(tag.to_le_bytes()); + e.extend(ty.to_le_bytes()); + e.extend(count.to_le_bytes()); + e.extend(val.to_le_bytes()); + e + } + + fn ifd(entries: &[Vec]) -> Vec { + let mut b = (entries.len() as u16).to_le_bytes().to_vec(); + entries.iter().for_each(|e| b.extend(e)); + b.extend(0u32.to_le_bytes()); + b + } + + /// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression` + /// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value. + fn synthetic_raw(nef: Option) -> Vec { + let make = b"NIKON CORPORATION"; + let mut note = b"NikonII*".to_vec(); + note.extend(ifd(&[entry(0x0093, 3, 1, nef.unwrap_or(0) as u32)])); + let ifd0_len = 2 + 4 * 12 + 4; + let sub_at = 8 + ifd0_len; + let sub = ifd(&[entry(0x0103, 3, 1, 34713), entry(0x0106, 3, 1, 32803)]); + let exif_at = sub_at + sub.len(); + let exif_len = 2 + 12 + 4; + let make_at = exif_at + exif_len; + let note_at = make_at + make.len(); + let mut b = b"II*".to_vec(); + b.extend(ifd(&[ + entry(0x0103, 3, 1, 1), + entry(0x010f, 2, make.len() as u32, make_at as u32), + entry(0x014a, 4, 1, sub_at as u32), + entry(0x8769, 4, 1, exif_at as u32), + ])); + b.extend(sub); + b.extend(ifd(&[entry(0x927c, 7, note.len() as u32, if nef.is_some() { note_at as u32 } else { 0 })])); + b.extend(make); + b.extend(note); + b + } + + fn compression(rows: &[TagRow]) -> Vec<&str> { + rows.iter().filter(|r| r.group == "TIFF" && r.tag == 0x0103).map(|r| r.value.as_str()).collect() + } + + #[test] + fn raw_compression_is_the_sensor_images_not_the_thumbnails() { + let b = synthetic_raw(None); + assert_eq!(compression(&file_tag_rows(&b)), ["Nikon NEF Compressed"]); + } + + #[test] + fn nef_compression_is_spelled_out_from_the_maker_note() { + for (v, words) in [(3, "Lossless"), (10, "Packed 14 bits"), (2, "Uncompressed"), (14, "High Efficiency*")] { + let b = synthetic_raw(Some(v)); + assert_eq!(compression(&file_tag_rows(&b)), [words]); + } + } + + #[test] + fn dng_raw_sub_ifd_jpeg_is_lossless_but_a_plain_jpeg_tiff_is_not() { + let b = synthetic_raw(None); + // switch the sensor sub-IFD's Compression from 34713 to 7 + let pos = b.windows(12).position(|w| w == entry(0x0103, 3, 1, 34713).as_slice()).unwrap(); + let mut dng = b.clone(); + dng[pos + 8] = 7; + dng[pos + 9] = 0; + assert_eq!(compression(&file_tag_rows(&dng)), ["Lossless JPEG"]); + let plain = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 7)])].concat(); + assert_eq!(compression(&file_tag_rows(&plain)), ["JPEG"]); + } + + #[test] + fn plain_tiff_keeps_its_own_compression() { + let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); + assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]); + } +} From a33dc70a3c1b1662e666bbb925bc1042804b7c03 Mon Sep 17 00:00:00 2001 From: squareSphere29 <18124696+squareSphere29@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:50:55 +0800 Subject: [PATCH 39/42] fix: EXIF values under histogram run together (#913) - Filter empty EXIF values instead of rendering empty strings at fixed positions - Distribute remaining items evenly across available width using (i + 0.5) / count - Apply left/center/right alignment dynamically based on item position - Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing (cherry picked from commit 28ab23e6332deb79ef6655576fb6881f45c090bf) --- crates/ui-egui/src/panels/edit.rs | 37 ++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 5567c8beb..7dd0168d8 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -566,21 +566,28 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // EXIF strip if let Some(ph) = app.session.catalog.photo(id) { let m = &ph.meta; - let items = [ - m.iso.map(|i| format!("ISO {i}")).unwrap_or_default(), - m.focal_mm.map(|f| format!("{f:.0}mm")).unwrap_or_default(), - m.aperture.map(|a| format!("f/{a:.1}")).unwrap_or_default(), - m.shutter.clone(), - ]; - let y = r.bottom() - 13.0; - for (i, s) in items.iter().enumerate() { - let x = r.left() + 18.0 + (r.width() - 36.0) * i as f32 / 3.0; - let align = match i { - 0 => Align2::LEFT_CENTER, - 3 => Align2::RIGHT_CENTER, - _ => Align2::CENTER_CENTER, - }; - p.text(pos2(x, y), align, s, t.font(12.5), t.text_label); + let items: Vec = [ + m.iso.map(|i| format!("ISO {i}")), + m.focal_mm.map(|f| format!("{f:.0}mm")), + m.aperture.map(|a| format!("f/{a:.1}")), + if m.shutter.is_empty() { None } else { Some(m.shutter.clone()) }, + ] + .into_iter() + .flatten() + .collect(); + + if !items.is_empty() { + let y = r.bottom() - 13.0; + let count = items.len() as f32; + for (i, s) in items.iter().enumerate() { + let x = r.left() + 18.0 + (r.width() - 36.0) * (i as f32 + 0.5) / count; + let align = match i { + 0 => Align2::LEFT_CENTER, + _ if i == items.len() - 1 => Align2::RIGHT_CENTER, + _ => Align2::CENTER_CENTER, + }; + p.text(pos2(x, y), align, s, t.font(12.5), t.text_label); + } } } } From 761a91c233dd410f664a773179d25dd46860f4b9 Mon Sep 17 00:00:00 2001 From: squareSphere29 <18124696+squareSphere29@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:08:41 +0800 Subject: [PATCH 40/42] fix: EXIF values under histogram run together (#913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter empty EXIF values and join with ' · ' separator (matches grid.rs pattern) - Single centered text element — no overlap possible - Fixes 'ISO 1000200mm' visual collision when focal length or aperture missing (cherry picked from commit 44a29d7fc7eaba8369f72324c05828bcade3acd2) --- crates/ui-egui/src/panels/edit.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 7dd0168d8..fcbd02412 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -566,7 +566,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // EXIF strip if let Some(ph) = app.session.catalog.photo(id) { let m = &ph.meta; - let items: Vec = [ + let parts: Vec = [ m.iso.map(|i| format!("ISO {i}")), m.focal_mm.map(|f| format!("{f:.0}mm")), m.aperture.map(|a| format!("f/{a:.1}")), @@ -576,18 +576,11 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { .flatten() .collect(); - if !items.is_empty() { + if !parts.is_empty() { + let text = parts.join(" · "); let y = r.bottom() - 13.0; - let count = items.len() as f32; - for (i, s) in items.iter().enumerate() { - let x = r.left() + 18.0 + (r.width() - 36.0) * (i as f32 + 0.5) / count; - let align = match i { - 0 => Align2::LEFT_CENTER, - _ if i == items.len() - 1 => Align2::RIGHT_CENTER, - _ => Align2::CENTER_CENTER, - }; - p.text(pos2(x, y), align, s, t.font(12.5), t.text_label); - } + let x = r.left() + r.width() / 2.0; + p.text(pos2(x, y), Align2::CENTER_CENTER, text, t.font(12.5), t.text_label); } } } From e2a38c26e0082192e74e1334d55451a44fa8e530 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:40:59 +0000 Subject: [PATCH 41/42] Masking: keep mask_overlay's doc comment on mask_overlay (#957 put the eyedropper between them) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4 --- crates/ui-egui/src/panels/detail.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 68aa9579d..d7e4c8e1c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1619,9 +1619,6 @@ fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool { 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`]). /// Colour Range eyedropper: a click samples the colour under it for the selected mask's /// colour range, at normalized frame coordinates (crop, rotation and zoom aware); /// ⇧-click adds a sample (up to 5). @@ -1637,6 +1634,9 @@ fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Respons } } +/// 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`]). fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { let tint = { let [r, g, b] = app.ui.mask_overlay_color; From 47fe7f70be2fe01fc1cde1abe4ab45c63592f6e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:57 +0000 Subject: [PATCH 42/42] Batch 26: cargo fmt over the merged PRs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BKWB9TCnvzJHV6xMLAj2J4 --- crates/ui-egui/src/lib.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1aed82c84..89ff0c88c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,11 +42,11 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] -mod tests_copy_paste_dialogs; +mod tests_before_after; #[cfg(test)] mod tests_compare; #[cfg(test)] -mod tests_before_after; +mod tests_copy_paste_dialogs; #[cfg(test)] mod tests_crop_rotate; #[cfg(test)] @@ -451,11 +451,7 @@ impl LightcraftApp { let completed = match id { "library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))), "library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Clear Preview Cache"))), - "develop.autoSync" => Some(format!( - "{}: {}", - i18n::tr("Auto Sync"), - i18n::tr(if self.session.auto_sync { "On" } else { "Off" }) - )), + "develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))), _ => None, }; if let Some(message) = completed { @@ -1167,11 +1163,8 @@ pub fn split_presets(dropped: Vec) -> (Vec, Vec) { let stem_of = |p: &str| std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); let name_of = |p: &str| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); let is_xmp = |p: &str| std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp")); - let photo_names: Vec<(String, String)> = dropped - .iter() - .filter(|p| !is_preset_file(p)) - .flat_map(|p| [(dir_of(p), stem_of(p)), (dir_of(p), name_of(p))]) - .collect(); + let photo_names: Vec<(String, String)> = + dropped.iter().filter(|p| !is_preset_file(p)).flat_map(|p| [(dir_of(p), stem_of(p)), (dir_of(p), name_of(p))]).collect(); let (mut presets, mut photos) = (Vec::new(), Vec::new()); for p in dropped { if !is_preset_file(&p) {