From 2c94b7fa1f4de14522993110ad4286bb952ce2b4 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Mon, 5 Oct 2026 23:51:43 -0700 Subject: [PATCH 1/9] Export: protect originals imported through differently named symlinks Co-Authored-By: OpenAI Codex --- crates/engine/src/originals.rs | 11 +++++++++++ crates/engine/src/tests_export.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 59a5f6a2..d20d2fe8 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -58,6 +58,17 @@ impl OriginalGuard { } } } + // An imported symlink can have a different name from its original. A write to + // that original would change what the library reads through the link. Only do + // this fallback for existing targets that the name index did not protect. + for (p, what) in self.by_name.values().flatten() { + if std::fs::read_link(p).is_ok() && same_file(p, target) { + return Err(format!( + "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + target.display() + )); + } + } Ok(()) } } diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index dcd74cb4..42260133 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -323,3 +323,29 @@ fn a_failed_export_write_keeps_the_previous_file() { assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +#[test] +fn export_protects_the_target_of_an_imported_symlink() { + use crate::export::{Conflict, Destination}; + let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original"); + drop(original_session); + let alias = dir.join("alias.jpg"); + std::os::unix::fs::symlink(&src, &alias).unwrap(); + let sidecar = dir.join("metadata.xmp"); + std::fs::write(&sidecar, b"").unwrap(); + std::os::unix::fs::symlink(&sidecar, alias.with_extension("xmp")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [alias.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap(); + let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() }; + let to = Destination { dir: String::new(), exact: Some(src.to_string_lossy().to_string()) }; + let result = disk_batch(&mut s, id, &o, &to); + assert!(result.is_err(), "export replaced the original through its imported alias: {result:?}"); + assert_eq!(std::fs::read(&src).unwrap(), original); + assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err()); + assert!(s.execute("export.checkTarget", &json!({"path": sidecar.to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(&sidecar).unwrap(), b""); + let _ = std::fs::remove_dir_all(&dir); +} From 6ad215e5b93337368d6230fcc32de1ec6172e211 Mon Sep 17 00:00:00 2001 From: Yu Zhi Chen Date: Wed, 7 Oct 2026 04:21:14 +0800 Subject: [PATCH 2/9] feat(ui): improve radial mask resizing and rotation - Added 4 control handles on the edge of the radial gradient mask to resize rx and ry independently. - Added a rotation handle to adjust the mask angle. - Changed drag gesture handling to store the original mask shape to prevent drift from accumulated relative deltas. - Holding the Option/Alt key while dragging a handle scales the mask proportionally. - Snaps the mask to a perfect circle (1:1 aspect ratio) when the aspect ratio is within 5%. --- crates/ui-egui/src/panels/detail.rs | 80 ++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75c..700c03f6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -51,6 +51,7 @@ pub enum Gesture { mask: u32, comp: usize, handle: u8, + original_shape: MaskShape, }, /// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates). Guide { @@ -867,10 +868,49 @@ pub(crate) fn component_pin(shape: &MaskShape) -> Option { } /// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. -fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape { +fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); match shape.clone() { - MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle { + 1 | 3 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_rx = (dx * co + dy * s).abs().max(0.01); + let mut new_ry = ry; + if alt { + new_ry = ry * (new_rx / rx.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_ry = new_rx; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 2 | 4 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_ry = (dx * (-s) + dy * co).abs().max(0.01); + let mut new_rx = rx; + if alt { + new_rx = rx * (new_ry / ry.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_rx = new_ry; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 5 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let new_angle = dy.atan2(dx).to_degrees(); + MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert } + } + _ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + }, MaskShape::Linear { start, end } => match handle { 1 => MaskShape::Linear { start: at, end }, 2 => MaskShape::Linear { start, end: at }, @@ -915,6 +955,26 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons }) .collect(); p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230)))); + + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, q)); + } + + let (s, co) = angle.to_radians().sin_cos(); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 5, rot_q)); } MaskShape::Linear { start, end } if sel => { let (a, b) = (map.screen(*start), map.screen(*end)); @@ -1003,21 +1063,25 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let m = d.masks.iter().find(|m| Some(m.id) == active)?; matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) }); - if let Some((mask, comp, handle)) = grip { + if let Some((mask, comp, handle)) = grip + && let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()) + { if Some(mask) != active { let _ = app.run("mask.select", json!({"id": mask})); } let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - app.gesture = Some(Gesture::MaskHandle { mask, comp, handle }); + app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape }); } } if resp.dragged() - && let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) - && let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape) + && let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) { let n = map.norm(q); - let n0 = map.norm(q - resp.drag_delta()); - let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n); + let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q); + let n0 = map.norm(press_origin); + let dn = Point::new(n.x - n0.x, n.y - n0.y); + let alt = ui.input(|i| i.modifiers.alt); + let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt); let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); } if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { From 22c6aa69181861eb045cc3b45c1f16599c5ca7ce Mon Sep 17 00:00:00 2001 From: Trey Reynolds Date: Tue, 6 Oct 2026 17:21:46 -0500 Subject: [PATCH 3/9] Issue #150: Raw: NEF colour from the camera JPEG; 12-bit NEF black level LightCraft has no colour matrix for Nikon cameras. Because of this, NEF files showed colours that were too gray and too green. - Colour: LightCraft now calculates a colour matrix and a tone curve from the JPEG preview in each NEF file. Sony ARW files use the same procedure and the same quality checks. White balance is relative to the as-shot look, as for ARW files. - One function, camera_preview::file_local_look, selects the formats for the fit and for the relative white balance. Photo::relative_wb in the catalog uses the same formats. - Black level: Nikon stores the black level (maker note 0x003d) in 14-bit units, also in 12-bit files. The decoder read it as a 12-bit value. Because of this, 12-bit files from the D750, D780, D850, D7500 and Z 50 were too dark. Now the decoder divides the value by 4 for 12-bit files. - The corpus gets the D7500 12-bit and 14-bit lossless samples. A test compares their levels. The render cache version increases to 9. Results on 13 NEF files that LightCraft can decode: LightCraft accepted the fit for all 13 files. The mean colour difference from the camera JPEG (CIE76 dE) decreased from 13-46 to 2.9-7.8. One file with mixed light improved by a small quantity only (12.7 to 12.0). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 12 ++++---- crates/catalog/src/model.rs | 22 +++++++++++-- crates/engine/src/camera_preview.rs | 48 +++++++++++++++++++++++++---- crates/engine/src/files.rs | 4 +-- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/nef.rs | 39 +++++++++++++++++++++-- crates/raw/tests/corpus.rs | 29 +++++++++++++++++ docs/camera-preview-colour.md | 12 ++++++-- docs/parity.md | 6 ++-- docs/whats-new.md | 4 +++ xtask/src/main.rs | 8 +++++ 11 files changed, 159 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a..9f73c1f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,8 +23,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| | **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | -| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | +| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~40 corpus files vs >1,000 models) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW and NEF have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | @@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | User | Readiness | What blocks them | |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | -| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) | +| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW and NEF preview estimates are only a starting point) | | Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models | @@ -46,8 +46,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*. 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use - matrices the files carry themselves, then chart shots. Sony ARW's file-local fit (matrix + tone curve from its own - JPEG) is the first step; generalise it to the other makes' raws (NEF, RW2, PEF, ORF…), then validate fidelity. + matrices the files carry themselves, then chart shots. Sony ARW and Nikon NEF have the file-local fit (matrix + tone + curve from their own JPEG); generalise it to the other makes' raws (RW2, PEF, ORF…), then validate fidelity. 2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. @@ -82,7 +82,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | | M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) | -| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW and NEF preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 8b400bb5..f6658454 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -331,10 +331,11 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW reader has vendor WB multipliers but no measured camera illuminant. - /// Use adjustments relative to the camera's as-shot look, as for rendered photographs. + /// The current ARW and NEF readers have vendor WB multipliers but no measured camera + /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered + /// photographs (the engine's `camera_preview::file_local_look` covers the same formats). pub fn relative_wb(&self) -> bool { - self.develops_raw() && self.format.eq_ignore_ascii_case("ARW") + self.develops_raw() && ["ARW", "NEF", "NRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). @@ -421,4 +422,19 @@ mod edited_tests { p.develop = Arc::new(DevelopSettings::default()); assert!(!p.is_edited(), "a full reset is unedited too"); } + + #[test] + fn sony_and_nikon_raws_use_relative_white_balance() { + for (name, format, relative) in [("a.arw", "ARW", true), ("a.nef", "NEF", true), ("a.nrw", "nrw", true), ("a.dng", "DNG", false)] { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); + p.kind = MediaKind::Raw; + p.as_shot_wb = Some((5200.0, 4.0)); + assert_eq!(p.relative_wb(), relative, "{format}"); + let wb = p.camera_defaults().wb; + assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}"); + // shown from the embedded preview: a rendered image, not a relative-WB raw + p.preview_only = Some("unsupported".into()); + assert!(!p.relative_wb()); + } + } } diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 54b308b1..41bbdca8 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1,12 +1,13 @@ -//! Estimate an ARW starting look from its own JPEG. Colour and luminance are fitted separately; -//! the JPEG supplies correspondences only, never output pixels or a replacement for RAW editing. +//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF) from its +//! own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, +//! never output pixels or a replacement for RAW editing. use lightcraft_color::{Mat3, luminance_2020}; use lightcraft_pipeline::tone::{CameraTone, ToneMap}; use lightcraft_raster::{ Rgb32f, resample::{Filter, fit}, }; -use lightcraft_raw::{RawImage, color::CameraTransform}; +use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform}; #[derive(Clone, Copy, Debug)] pub(crate) struct CameraLook { @@ -14,8 +15,16 @@ pub(crate) struct CameraLook { pub tone: CameraTone, } +/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix: +/// their starting look is fitted to the file's own JPEG, and white balance is relative to the +/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the +/// same formats by file extension. +pub(crate) fn file_local_look(format: RawFormat) -> bool { + matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw) +} + pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { - if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw { + if !transform.matrix_is_fallback || !file_local_look(raw.format) { return None; } let jpeg = lightcraft_raw::embedded_preview(bytes)?; @@ -38,7 +47,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain))); let look = fit_pairs(&sensor, &reference)?; if lightcraft_pipeline::profiling() { - eprintln!("[profile] ARW camera look: {:?}, {:?}", look.matrix.0, look.tone); + eprintln!("[profile] {:?} camera look: {:?}, {:?}", raw.format, look.matrix.0, look.tone); } Some(look) } @@ -137,7 +146,11 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { } } if lightcraft_pipeline::profiling() { - eprintln!("[profile] ARW holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt()); + eprintln!( + "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)", + (before / samples as f64).sqrt(), + (after / samples as f64).sqrt() + ); } if !after.is_finite() || samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { return None; @@ -248,6 +261,29 @@ mod tests { assert!(fit_pairs(&sensor, &reference).is_some()); } + #[test] + fn sony_and_nikon_raws_get_a_file_local_look() { + assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw].into_iter().all(file_local_look)); + assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look)); + } + + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white + /// balance is relative to the as-shot look. + #[test] + fn corpus_nef_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-d7500-lossless14.nef"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); + } + #[test] fn rejects_monochrome_invalid_and_unrelated_previews() { let mut sensor = Rgb32f::new(32, 32); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index f4a57a6f..b268333c 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -132,7 +132,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { } let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); // Vendor RGB multipliers do not identify an absolute illuminant without camera calibration. - let relative = raw.format == lightcraft_raw::RawFormat::Arw && !lightcraft_raw::color::has_matrix(&raw.color); + let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color); let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) }); let embedded_lens = embedded_lens(&raw); return Ok(ProbeInfo { @@ -285,7 +285,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); } let (temp, tint) = xy_to_temp_tint(xy); - let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback; + let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; let camera_tone = camera_look.map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone })); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index c61470d0..bab0c5c7 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 8; +pub const RENDER_CACHE_VERSION: u64 = 9; /// 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 e81ada36..bf386bb4 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -56,10 +56,17 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { if info.compression == t::compression::NIKON { compressed(bytes, &info, mn.as_ref())? } else { uncompressed(bytes, &info, tiff.order)? }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float NEF".into())) }; + // Maker note 0x003d is in 14-bit units whatever the sample depth: 12-bit files from the D750, D780, D850, + // D7500 and Z 50 store 600 / 1008 / 400 / 400 / 1008 while their darkest samples are 150 / ~252 / 99 / 99 / ~250 + // (our measurements on CC0 raw.pixls.us samples; the same bodies' 14-bit files match the tag as is). + let black_scale = if bits == 12 { 0.25 } else { 1.0 }; let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_LEVEL)).as_deref() { - Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => { - BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } - } + Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => BlackLevel { + repeat_rows: 2, + repeat_cols: 2, + values: [a, b, c, d].iter().map(|v| (**v * black_scale) as f32).collect(), + ..Default::default() + }, _ => BlackLevel::uniform(0.0), }; let wb = mn @@ -145,6 +152,11 @@ mod tests { use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; fn nef(compression: u16, bits: u16, strips: Vec>, w: u32, h: u32, rps: u32) -> Vec { + nef_with_note(compression, bits, strips, w, h, rps, None) + } + + /// [`nef`] with an optional maker note (`Nikon\0` v2 header + embedded big-endian TIFF holding `note`). + fn nef_with_note(compression: u16, bits: u16, strips: Vec>, w: u32, h: u32, rps: u32, note: Option) -> Vec { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); @@ -158,10 +170,31 @@ mod tests { let mut ifd0 = IfdBuilder::new(); ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into())); ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into())); + if let Some(mn) = note { + let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec(); + bytes.extend(TiffWriter::new(ByteOrder::Big, false).write(&[mn]).unwrap()); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(bytes)); + ifd0.set_child(t::EXIF_IFD, exif); + } ifd0.add_sub_ifd(raw); TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } + #[test] + fn black_level_tag_is_in_14_bit_units() { + let (w, h) = (16usize, 4usize); + let words: Vec = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect(); + let note = || { + let mut mn = IfdBuilder::new(); + mn.set(BLACK_LEVEL, Value::Short(vec![400, 404, 408, 412])); + mn + }; + let black = |bits| crate::decode(&nef_with_note(1, bits, vec![words.clone()], w as u32, h as u32, h as u32, Some(note()))).unwrap().black; + assert_eq!(black(12).values, vec![100.0, 101.0, 102.0, 103.0]); + assert_eq!(black(14).values, vec![400.0, 404.0, 408.0, 412.0]); + } + #[test] fn uncompressed_word16_and_packed12() { let (w, h) = (16usize, 6usize); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index b92d03f8..ad187032 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -194,6 +194,35 @@ fn corpus_nef_compressed_matches_uncompressed() { } } +/// raw.pixls.us has the same D7500 scene as 12- and 14-bit lossless compressed NEF. Both store black level 400 in +/// maker note 0x003d (14-bit units): after subtracting the black level and scaling to white, the two must agree. +#[test] +fn corpus_nef_12_bit_black_level_matches_14_bit() { + let dir = corpus_root().join("raw"); + let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef"))) + else { + eprintln!("skip: D7500 NEF pair absent"); + return; + }; + let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); + assert_eq!((a.bits, b.bits), (12, 14)); + assert!((a.black.values[0] - 100.0).abs() < 1.0 && (b.black.values[0] - 400.0).abs() < 1.0, "{:?} {:?}", a.black, b.black); + let normalized = |img: &lightcraft_raw::RawImage| { + let (black, white) = (img.black.values[0] as f64, img.white[0] as f64); + block_means(img).into_iter().map(|v| (v - black) / (white - black)).collect::>() + }; + // the two shots aren't pixel-aligned (handheld): compare the distributions, not block by block + let (mut na, mut nb) = (normalized(&a), normalized(&b)); + na.sort_by(f64::total_cmp); + nb.sort_by(f64::total_cmp); + for q in [0.1, 0.5, 0.9] { + let (x, y) = (na[(na.len() as f64 * q) as usize], nb[(nb.len() as f64 * q) as usize]); + eprintln!("D7500 12- vs 14-bit: normalized quantile {q}: {x:.4} vs {y:.4}"); + // with the tag read as 12-bit units, the 12-bit values would sit below black (negative) + assert!(x > 0.0 && (0.8..1.25).contains(&(x / y)), "quantile {q}: {x} vs {y}"); + } +} + /// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and /// look table; we read them (and render with them). Camera-written DNGs here carry none. #[test] diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 93aada18..6c847c89 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,6 +1,6 @@ -# Sony ARW starting look +# Sony ARW and Nikon NEF starting look -ARW decoding supplies a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The ARW loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost. +ARW and NEF decoding supply a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate @@ -28,6 +28,12 @@ Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-displa Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). +### Nikon NEF + +The same fit, gates and relative WB apply to NEF/NRW (issue #150: NEFs rendered muted and greenish). Public CC0 samples from raw.pixls.us (D750 12/14-bit, D780 12-bit, D850 12/14-bit, D7500 12/14-bit lossless and lossy, Z 50 12-bit) plus five local D7500 shots of an indoor event, rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): all 13 decodable files accepted, held-out RMS 0.11–0.24 → 0.01–0.09, ΔE 13–46 → 2.9–7.8 with matching mean lightness. The exception is a mixed-light D7500 scene (faces lit by a purple screen): accepted (RMS 0.156 → 0.057) but only ΔE 12.7 → 12.0, rendered warmer and lighter than the camera JPEG; the linear-display error the gate measures is dominated by the bright screen. Files that still open as the embedded preview (lossy-after-split, uncompressed data labelled compressed) are not fitted. + +Measuring this found a decoder bug: 12-bit NEFs store maker note `0x003d` BlackLevel in 14-bit units (D750 600, D780/Z 50 1008, D850/D7500 400, while their darkest samples are 150 / ~252 / 99), so 12-bit files rendered nearly black or with crushed shadows before any fit. The decoder now scales it to the sample depth; the fits for the same D7500 scene at 12 and 14 bits then agree (held-out RMS 0.042–0.049 vs 0.050–0.054; before, the 12-bit fits had to desaturate to compensate). + On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification. -This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. +This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony and Nikon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. diff --git a/docs/parity.md b/docs/parity.md index fc94d08a..f1d6486c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -59,7 +59,7 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). Take the first one nobody is working on. -1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW now gets a guarded file-local fit to its own embedded JPEG (colour matrix + tone curve, relative WB; 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. +1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW and Nikon NEF now get a guarded file-local fit to their own embedded JPEG (colour matrix + tone curve, relative WB; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, Nikon "lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see `crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`. @@ -122,7 +122,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Missing: CR3, compressed RAF/ORF, RW2 v4, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW and Nikon NEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045dead..c25638f9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -124,6 +124,10 @@ - Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions. - Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map. - ⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming. +- Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted, + greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs + (e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was + read in the wrong units. ### Viewing and sharing - Slideshow, second window, All Metadata, System Info. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fe4e1948..3cfcd3b8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -281,6 +281,14 @@ const RAW_SAMPLES: &[(&str, &str)] = &[ "nef-nikon-d7000-lossy12.nef", "https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", ), + ( + "nef-nikon-d7500-lossless12.nef", + "https://raw.pixls.us/getfile.php/1532/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + ), + ( + "nef-nikon-d7500-lossless14.nef", + "https://raw.pixls.us/getfile.php/1534/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + ), ( "nrw-nikon-b700-uncompressed.nrw", "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", From 6bda431c4b9d58dab370d07960eec2f72720b454 Mon Sep 17 00:00:00 2001 From: Maurice Rogers Date: Tue, 6 Oct 2026 19:30:23 -0600 Subject: [PATCH 4/9] LR-LIB-DELETE: restore from Recently Deleted; re-adding a deleted photo shows where it is Photos in Recently Deleted could not be restored from the app: photo.restore and photo.deletePermanently existed only as engine commands, and adding the same file again just said "1 duplicate skipped" (the duplicate check counts deleted photos), with the side panel that lists Recently Deleted collapsed by default. - Restore and Delete Permanently in the Photo menu and the photo context menu, in place of Delete, when the selection is in Recently Deleted. - The filmstrip gets the photo context menu (it had none). - An import that only finds files the library already has selects those photos: in Recently Deleted (side panel opened, how to restore or delete permanently and import afresh) or in All Photos. Mixed imports note how many are in Recently Deleted. The message stays 6 s. - Import review badges say "In Recently Deleted"; badges are translated. - Japanese strings, parity rows, what's new; headless regression test. --- crates/engine/src/cmd/library.rs | 4 +- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/src/headless.rs | 81 ++++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 53 ++++++++++++++++- crates/ui-egui/src/lib.rs | 7 ++- crates/ui-egui/src/menubar.rs | 18 +++++- crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/panels/grid.rs | 9 ++- docs/parity.md | 12 ++-- docs/whats-new.md | 5 ++ 11 files changed, 183 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 74e5d5b0..cba1c338 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -453,11 +453,11 @@ pub fn specs() -> Vec { s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); Ok(v) }), - cmd!("photo.restore", "Restore", [], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { + cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { id, deleted: false }))), - cmd!("photo.deletePermanently", "Delete Permanently", [], None, "{ids?}", has_selection, |s, p| { + cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let t = s.targets(p); let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::>(); s.commit("Delete Permanently", Op::Batch { ops })?; diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 9da942f8..354205c8 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -100,5 +100,8 @@ "… and {more} more": "…ほか{more}件", "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", - "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚" + "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚", + " ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)", + "Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4e5c71a5..2a6f18a0 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1017,5 +1017,6 @@ "Quit": "終了", "Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。", "Back Up Library…": "ライブラリをバックアップ…", - "Restore Library from Backup…": "バックアップからライブラリを復元…" + "Restore Library from Backup…": "バックアップからライブラリを復元…", + "In Recently Deleted": "最近削除した写真内" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a78fe661..a6b8fc43 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1236,6 +1236,87 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo + /// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once + /// restored a re-add selects it in All Photos. + #[test] + fn readding_a_deleted_photo_shows_it_in_recently_deleted() { + let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let file = dir.join("flower.png"); + std::fs::write(&file, png).unwrap(); + let paths = vec![file.to_string_lossy().to_string()]; + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + // frames until the import has finished, so its (timed) toast is read before it expires + let finish_import = |h: &mut Headless| { + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "import finished"); + }; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + h.settle(SETTLE); + let id = h.app.session.selection.active.expect("imported photo selected"); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted); + let photo_items = |app: &LightcraftApp| -> Vec { + let bar = crate::menubar::menu_bar(app); + let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1; + items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect() + }; + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); + + h.app.ui.left_panel = false; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); + assert_eq!(h.app.session.selection.ids, vec![id]); + assert!(h.app.ui.left_panel, "the side panel listing Recently Deleted is opened"); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("Recently Deleted") && toast.contains("Restore"), "{toast}"); + let items = photo_items(&h.app); + assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); + assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); + // a right-click on its filmstrip thumbnail opens the photo menu + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu"); + + let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored"); + crate::import::start_paths(&mut h.app, paths).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id]); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("All Photos"), "{toast}"); + + // deleted permanently, the file imports afresh as a new photo + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).is_none()); + crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap(); + h.settle(SETTLE); + let fresh = h.app.session.selection.active.expect("re-imported photo selected"); + assert_ne!(fresh, id); + assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted); + let _ = std::fs::remove_dir_all(&dir); + } + /// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across /// the import's batches. #[test] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 7e520bb3..3ac7c9eb 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -105,6 +105,8 @@ pub struct ImportTask { params: Value, pub imported: usize, pub duplicates: usize, + /// The library photos the skipped duplicates match (shown when nothing new was added). + existing: Vec, pub failed: usize, /// Move: originals moved, and sources left in place (reported by the engine with a reason). pub moved: usize, @@ -563,6 +565,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc let len = |k: &str| v[k].as_array().map_or(0, Vec::len); task.imported += len("imported"); task.duplicates += len("duplicates"); + task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); task.failed += len("failed"); task.moved += len("moved"); task.kept += len("kept"); @@ -601,6 +604,11 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { let _ = app.run("library.select", json!({"ids": [f]})); } let plural = |n: usize| if n == 1 { "" } else { "s" }; + // nothing new, only files the library already has: show where they are (Recently Deleted is + // easy to miss, and the side panel that lists it starts collapsed) + if task.imported == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) { + return; + } let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) } else { @@ -614,6 +622,10 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } if task.duplicates > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + let deleted = deleted_of(app, &task.existing).len(); + if deleted > 0 { + msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); + } } if task.failed > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); @@ -621,6 +633,43 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { app.toast(ctx, msg); } +/// The photos among `ids` that are in Recently Deleted (each once). +fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { + let mut v: Vec = + ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel +/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. +fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { + let deleted = deleted_of(app, existing); + let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; + ids.sort_unstable(); + ids.dedup(); + ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); + if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { + return false; + } + let _ = app.run("library.select", json!({"ids": ids})); + app.ui.left_panel = true; + let n = ids.len(); + let plural = if n == 1 { "" } else { "s" }; + let msg = if kind == "all" { + crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) + } else { + crate::i18n::tr_format!( + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", + n, + plural + ) + }; + app.toast_for(ctx, msg, 6.0); + true +} + /// The progress window while an import runs, with Cancel (files already copied or added stay; /// nothing new is started). pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { @@ -949,14 +998,16 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); } + let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); let badge = match (&c.duplicate, &c.error) { + (Some(_), _) if in_trash => Some("In Recently Deleted"), (Some(r), _) if r == "path" => Some("In library"), (Some(_), _) => Some("Duplicate"), (None, Some(_)) => Some("Unreadable"), _ => None, }; if let Some(b) = badge { - let g = p.layout_no_wrap(b.to_string(), t.semibold(10.0), Color32::WHITE); + let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); let br = Rect::from_min_size(pos2(img.right() - g.size().x - 12.0, img.top() + 6.0), g.size() + vec2(8.0, 4.0)); p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8addc61b..20e54730 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -344,8 +344,13 @@ impl LightcraftApp { } pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { + self.toast_for(ctx, text, 1.4); + } + + /// A toast that stays `secs` seconds (messages that say where to look or what to do next). + pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into, secs: f64) { let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + 1.4)); + self.ui.toast = Some((text.into(), t + secs)); } fn drain_control(&mut self, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e..30d8f6f6 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -192,6 +192,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.tagFromTracklog", "---", "photo.delete", + "photo.restore", + "photo.deletePermanently", ], ), ( @@ -252,11 +254,20 @@ const HIDDEN: &[&str] = &[ "preset.create", ]; -/// Items only some hosts have are left out of the others' menus (the web build's library backup). +/// The selection includes a photo in Recently Deleted. +pub(crate) fn selection_deleted(app: &LightcraftApp) -> bool { + let s = &app.session; + s.selection.ids.iter().copied().chain(s.selection.active).any(|id| s.catalog.photo(id).is_some_and(|p| p.deleted)) +} + +/// Items only some hosts have are left out of the others' menus (the web build's library backup); +/// Restore and Delete Permanently replace Delete for photos in Recently Deleted. fn host_supports(app: &LightcraftApp, id: &str) -> bool { match id { "file.backupLibrary" => app.services.backup_library.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), + "photo.restore" | "photo.deletePermanently" => selection_deleted(app), + "photo.delete" => !selection_deleted(app), _ => true, } } @@ -828,8 +839,9 @@ mod tests { let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); assert_eq!(titles, MENUS); let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - // every engine command with a menu path is reachable (parameterized ones via submenus) - for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) { + // every engine command with a menu path is reachable (parameterized ones via submenus; items + // that follow the state, like Restore for deleted photos, when it applies) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); } for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75c..3448fbf5 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1300,6 +1300,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }; let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode})); } + // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) + resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); } }); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 760abbfe..edc4662f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -905,7 +905,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); - if ui.button(crate::i18n::tr("Delete Photo")).clicked() { + if crate::menubar::selection_deleted(app) { + if ui.button(crate::i18n::tr("Restore")).clicked() { + let _ = app.run("photo.restore", json!({})); + } + if ui.button(crate::i18n::tr("Delete Permanently")).clicked() { + let _ = app.run("photo.deletePermanently", json!({})); + } + } else if ui.button(crate::i18n::tr("Delete Photo")).clicked() { let _ = app.run("photo.delete", json!({})); } } diff --git a/docs/parity.md b/docs/parity.md index fc94d08a..04861b1b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 15 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) | -| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 387 | 30 | 90 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.6% of 158. ## Top gaps @@ -148,7 +148,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | @@ -169,7 +169,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | 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 | +| 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 | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) | | 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 | @@ -600,7 +600,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-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-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 | +| 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), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045dead..92bad949 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,6 +83,11 @@ changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel. ### Library +- Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the + Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no + longer just says "duplicate skipped": it opens the side panel on Recently Deleted with the photo selected and says + how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits, + Cmd+Shift+R, keeps the photo and clears its edits.) - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets `img_1-1.JPG`; on case-insensitive volumes the case change still goes through. From 13a3eff2e813f88b3df9daf198cd88d0fce93f83 Mon Sep 17 00:00:00 2001 From: Maurice Rogers Date: Tue, 6 Oct 2026 20:00:48 -0600 Subject: [PATCH 5/9] LR-IMP-FORMATS: CR3 full-size preview and metadata from the ISO-BMFF container CR3 files imported as preview-only with the 1620x1080 PRVW JPEG and no metadata (no camera, capture time, exposure, lens or GPS): nothing read the container. - crates/meta/src/cr3.rs: bounded ISO base media box parser for CR3 (Canon uuid box with CMT1-4 and THMB, tracks with their sample entries, CMP1 coding header and sample location, XMP uuid box). Every size/offset checked; depth and box count bounded. - Metadata: CMT1 (IFD0), CMT2 (Exif) and CMT4 (GPS) merged into one TIFF Exif block, so CR3 reads like any TIFF-based raw; XMP from its uuid box. - Preview: the full-size JPEG track (6960x4640 on an EOS R6 Mark III) before PRVW/THMB; imports report the full image size. - Tests: synthetic CR3 (boxes, tracks, merged Exif), truncation/corruption sweep, JPEG track preferred over PRVW with fallback. Raw (CRX) data is still not decoded; CR3 stays preview-only. --- ROADMAP.md | 2 +- crates/meta/src/container.rs | 5 + crates/meta/src/cr3.rs | 344 +++++++++++++++++++++++++++++++++++ crates/meta/src/lib.rs | 4 +- crates/raw/src/preview.rs | 40 +++- docs/parity.md | 2 +- docs/whats-new.md | 4 + 7 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 crates/meta/src/cr3.rs diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a..6593c9b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | | Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) | -| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only | +| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models | diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 7e6c7270..8dda6b55 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,8 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the +//! XMP `uuid` box. use lightcraft_tiff::{Tiff, tags}; @@ -34,6 +36,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded { _ => Embedded::default(), }; } + if let Some(c) = crate::cr3::parse_cr3(bytes) { + return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; + } if bytes.starts_with(&[0xff, 0xd8]) { jpeg_segments(bytes) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs new file mode 100644 index 00000000..18d33ef7 --- /dev/null +++ b/crates/meta/src/cr3.rs @@ -0,0 +1,344 @@ +//! Canon CR3: the ISO base media file container (ISO/IEC 14496-12 box structure; CR3 layout from Laurent +//! Clévy's public CR3 format notes, confirmed black-box on EOS R6 Mark III files). +//! +//! - `ftyp` with major brand `crx `. +//! - `moov` holds a Canon `uuid` box (85c0b687-820f-11e0-8111-f4ce462b6a48) whose children are `CMT1` (a TIFF +//! stream: IFD0), `CMT2` (TIFF: the Exif IFD), `CMT3` (TIFF: the Canon maker note), `CMT4` (TIFF: GPS) and the +//! `THMB` thumbnail, then one `trak` per stream: a full-size JPEG, a reduced raw, the full raw (`CRAW` sample +//! entries; raws carry a `CMP1` coding header) and timed metadata (`CTMD`). +//! - A top-level `uuid` box with the XMP uuid (be7acfcb-97a9-42e8-9c71-999491e3afac, XMP spec part 3) holds the +//! XMP packet. +//! +//! Every size and offset is checked against the buffer; nesting and box counts are bounded. + +/// A track's sample-entry kind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Cr3TrackKind { + /// `CRAW` with a `JPEG` child: the full-size JPEG. + Jpeg, + /// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file). + Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> }, + /// Anything else (`CTMD` timed metadata, unknown entries). + Other([u8; 4]), +} + +/// One track's single sample: where it is in the file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3Track { + pub kind: Cr3TrackKind, + /// Byte offset and length of the sample (the first one) in the file, when the tables give them. + pub data: Option<(usize, usize)>, +} + +/// The parts of a CR3 file LightCraft reads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Cr3<'a> { + /// `CMT1`…`CMT4` (index 0…3). + pub cmt: [Option<&'a [u8]>; 4], + pub thumbnail: Option<&'a [u8]>, + pub xmp: Option<&'a [u8]>, + pub tracks: Vec, +} + +const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; +const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; +const MAX_DEPTH: usize = 12; +const MAX_BOXES: usize = 4096; +/// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes. +const CRAW_CHILDREN_AT: usize = 82; + +/// Does this look like a CR3 file (`ftyp` box with major brand `crx `)? +pub fn is_cr3(bytes: &[u8]) -> bool { + bytes.get(4..12) == Some(b"ftypcrx ".as_slice()) +} + +/// Parse a CR3 file's boxes; `None` when it is not a CR3 file. +pub fn parse_cr3(bytes: &[u8]) -> Option> { + if !is_cr3(bytes) { + return None; + } + let mut out = Cr3::default(); + let mut budget = MAX_BOXES; + for b in boxes(bytes, 0, bytes.len(), &mut budget) { + match &b.kind { + b"moov" => moov(bytes, &b, &mut out, &mut budget), + b"uuid" if b.uuid == Some(XMP_UUID) => out.xmp = bytes.get(b.body..b.end), + _ => {} + } + } + Some(out) +} + +struct BoxRef { + kind: [u8; 4], + /// Start of the payload (after the header and, for `uuid`, the 16-byte uuid). + body: usize, + end: usize, + uuid: Option<[u8; 16]>, +} + +/// The boxes directly inside `[from, to)`; stops at the first malformed header. +fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec { + let mut out = Vec::new(); + let mut at = from; + let to = to.min(bytes.len()); + while at.saturating_add(8) <= to && *budget > 0 { + *budget -= 1; + let (Some(size), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) else { break }; + let mut kind4 = [0u8; 4]; + kind4.copy_from_slice(kind); + let (len, mut hdr) = match size { + 0 => (to - at, 8), + 1 => match be64(bytes, at + 8).and_then(|v| usize::try_from(v).ok()) { + Some(l) => (l, 16), + None => break, + }, + n => (n as usize, 8), + }; + let Some(end) = at.checked_add(len).filter(|&e| e <= to && len >= hdr) else { break }; + let mut uuid = None; + if &kind4 == b"uuid" { + let Some(u) = bytes.get(at + hdr..at + hdr + 16).filter(|_| at + hdr + 16 <= end) else { break }; + let mut a = [0u8; 16]; + a.copy_from_slice(u); + uuid = Some(a); + hdr += 16; + } + out.push(BoxRef { kind: kind4, body: at + hdr, end, uuid }); + at = end; + } + out +} + +fn moov<'a>(bytes: &'a [u8], m: &BoxRef, out: &mut Cr3<'a>, budget: &mut usize) { + for b in boxes(bytes, m.body, m.end, budget) { + match &b.kind { + b"uuid" if b.uuid == Some(CANON_UUID) => { + for c in boxes(bytes, b.body, b.end, budget) { + let slot = match &c.kind { + b"CMT1" => 0, + b"CMT2" => 1, + b"CMT3" => 2, + b"CMT4" => 3, + b"THMB" => { + out.thumbnail = bytes.get(c.body..c.end); + continue; + } + _ => continue, + }; + out.cmt[slot] = bytes.get(c.body..c.end); + } + } + b"trak" => { + if let Some(t) = trak(bytes, &b, budget) { + out.tracks.push(t); + } + } + _ => {} + } + } +} + +/// Descend `trak` → `mdia` → `minf` → `stbl` and read its sample entry and first sample's location. +fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option { + let mut stbl = None; + let mut stack = vec![(t.body, t.end, 0usize)]; + while let Some((from, to, depth)) = stack.pop() { + if depth > MAX_DEPTH { + continue; + } + for b in boxes(bytes, from, to, budget) { + match &b.kind { + b"mdia" | b"minf" => stack.push((b.body, b.end, depth + 1)), + b"stbl" => stbl = Some(b), + _ => {} + } + } + } + let stbl = stbl?; + let (mut kind, mut size, mut offset) = (None, None, None); + for b in boxes(bytes, stbl.body, stbl.end, budget) { + match &b.kind { + // full box: version/flags, entry count, entries + b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), + // version/flags, sample_size (0 = per-sample table), count, sizes + b"stsz" => { + size = match be32(bytes, b.body + 4)? { + 0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12), + 0 => None, + n => Some(n), + } + } + b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()), + b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize), + _ => {} + } + } + let data = match (offset, size) { + (Some(o), Some(s)) if o.checked_add(s as usize).is_some_and(|e| e <= bytes.len()) => Some((o, s as usize)), + _ => None, + }; + Some(Cr3Track { kind: kind?, data }) +} + +fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind { + if &e.kind != b"CRAW" { + return Cr3TrackKind::Other(e.kind); + } + let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else { + return Cr3TrackKind::Other(e.kind); + }; + let mut cmp1 = None; + for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) { + match &c.kind { + b"JPEG" => return Cr3TrackKind::Jpeg, + b"CMP1" => cmp1 = Some((c.body, c.end - c.body)), + _ => {} + } + } + Cr3TrackKind::Raw { width, height, cmp1 } +} + +fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]])) +} +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) +} +fn be64(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + let mut a = [0u8; 8]; + a.copy_from_slice(s); + Some(u64::from_be_bytes(a)) +} + +/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3 +/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block. +pub fn merged_exif(c: &Cr3<'_>) -> Option> { + use lightcraft_tiff::{IfdBuilder, Tiff, TiffWriter, tags as t}; + let ifd0 = Tiff::parse(c.cmt[0]?).ok()?; + let order = ifd0.order; + let first = |i: usize| c.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next()); + // pointers and offsets that would dangle once the IFDs move + const SKIP: [u16; 9] = [t::EXIF_IFD, t::GPS_IFD, t::INTEROP_IFD, t::SUB_IFDS, t::MAKER_NOTE, 273, 279, 513, 514]; + let build = |ifd: &lightcraft_tiff::Ifd| { + let mut b = IfdBuilder::new(); + for e in ifd.entries.iter().filter(|e| !SKIP.contains(&e.tag)) { + b.set(e.tag, e.value.clone()); + } + b + }; + let mut root = build(ifd0.ifds.first()?); + if let Some(exif) = first(1) { + root.set_child(t::EXIF_IFD, build(&exif)); + } + if let Some(gps) = first(3) { + root.set_child(t::GPS_IFD, build(&gps)); + } + TiffWriter::new(order, false).write(&[root]).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bx(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(body); + v + } + fn uuid(u: &[u8; 16], body: &[u8]) -> Vec { + let mut b = u.to_vec(); + b.extend_from_slice(body); + bx(b"uuid", &b) + } + fn full(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut b = vec![0u8; 4]; + b.extend_from_slice(body); + bx(kind, &b) + } + fn craw(w: u16, h: u16, children: &[u8]) -> Vec { + let mut b = vec![0u8; CRAW_CHILDREN_AT]; + b[24..26].copy_from_slice(&w.to_be_bytes()); + b[26..28].copy_from_slice(&h.to_be_bytes()); + b.extend_from_slice(children); + bx(b"CRAW", &b) + } + fn trak(entry: Vec, offset: u64, size: u32) -> Vec { + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend(entry); + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend(1u32.to_be_bytes()); + stsz.extend(size.to_be_bytes()); + let mut co64 = 1u32.to_be_bytes().to_vec(); + co64.extend(offset.to_be_bytes()); + let stbl = [full(b"stsd", &stsd), full(b"stsz", &stsz), full(b"co64", &co64)].concat(); + bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))) + } + fn tiff_with(tag: u16, v: lightcraft_tiff::Value) -> Vec { + let b = lightcraft_tiff::IfdBuilder::new().with(tag, v); + lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap() + } + + /// A small synthetic CR3: Canon uuid with CMT1/CMT2/CMT4, a JPEG track, a raw track, XMP. + pub(crate) fn sample() -> Vec { + use lightcraft_tiff::{Value, tags as t}; + let cmt1 = tiff_with(t::MODEL, Value::Ascii("Canon EOS Test".into())); + let cmt2 = tiff_with(t::ISO_SPEED, Value::Short(vec![400])); + let cmt4 = tiff_with(1, Value::Ascii("N".into())); + let canon = uuid(&CANON_UUID, &[bx(b"CMT1", &cmt1), bx(b"CMT2", &cmt2), bx(b"CMT4", &cmt4), bx(b"THMB", b"thumb")].concat()); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let payload_at = 4096u64; + let jpeg_t = trak(craw(64, 48, &bx(b"JPEG", &[0; 4])), payload_at, 16); + let raw_t = trak(craw(60, 40, &bx(b"CMP1", &[0xff, 0x10, 0, 0x30])), payload_at + 16, 32); + file.extend(bx(b"moov", &[canon, jpeg_t, raw_t].concat())); + file.extend(uuid(&XMP_UUID, b"")); + file.resize(payload_at as usize + 64, 0); + file + } + + #[test] + fn parses_canon_boxes_tracks_and_xmp() { + let f = sample(); + assert!(is_cr3(&f)); + let c = parse_cr3(&f).unwrap(); + assert!(c.cmt[0].is_some() && c.cmt[1].is_some() && c.cmt[2].is_none() && c.cmt[3].is_some()); + assert_eq!(c.thumbnail, Some(b"thumb".as_slice())); + assert_eq!(c.xmp, Some(b"".as_slice())); + assert_eq!(c.tracks.len(), 2); + assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) }); + let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; + assert_eq!((width, height, len), (60, 40, 4)); + assert_eq!(&f[at..at + 2], &[0xff, 0x10]); + assert_eq!(c.tracks[1].data, Some((4112, 32))); + } + + #[test] + fn merged_exif_reads_like_a_tiff_raw() { + let f = sample(); + let m = crate::read_exif(&merged_exif(&parse_cr3(&f).unwrap()).unwrap()); + assert_eq!(m.model.as_deref(), Some("Canon EOS Test")); + assert_eq!(m.iso, Some(400)); + } + + #[test] + fn hostile_input_never_panics() { + let f = sample(); + // every truncation and single-byte corruption parses to something or nothing + for n in 0..f.len().min(800) { + let _ = parse_cr3(&f[..n]).map(|c| merged_exif(&c)); + } + for i in 0..f.len().min(800) { + for v in [0u8, 1, 0x7f, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = parse_cr3(&g).map(|c| merged_exif(&c)); + } + } + // a sample offset past the end is not reported + let mut g = f.clone(); + g.truncate(4100); + assert!(parse_cr3(&g).unwrap().tracks.iter().all(|t| t.data.is_none())); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 3408aed7..594129d0 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -7,10 +7,12 @@ //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. -//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw). +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). +//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). #![forbid(unsafe_code)] mod container; +pub mod cr3; mod datetime; mod exif; mod gpx; diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 050870ce..ded35d29 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -96,10 +96,20 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec()) } +/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes. +fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { + let full = lightcraft_meta::cr3::parse_cr3(bytes) + .and_then(|c| c.tracks.iter().find(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Jpeg).and_then(|t| t.data)); + if let Some(j) = full.and_then(|(at, len)| bytes.get(at..at.checked_add(len)?)).filter(|j| is_dct_jpeg(j)) { + return Some(j); + } + cr3_preview_boxes(bytes) +} + /// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0 /// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller /// `THMB` box has the same shape. Returns the larger valid one. -fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { +fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { let mut best: Option<&[u8]> = None; for tag in [b"PRVW", b"THMB"] { let mut from = 0; @@ -180,4 +190,32 @@ mod tests { let _ = embedded_preview(&cr3[..n]); } } + + /// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box. + #[test] + fn cr3_prefers_the_full_size_jpeg_track() { + let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; + let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat()); + let (small, big) = (fake_jpeg(300), fake_jpeg(3000)); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec(); + prvw.extend_from_slice(&(small.len() as u32).to_be_bytes()); + prvw.extend_from_slice(&small); + file.extend(bx(b"PRVW", &prvw)); + // CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at` + let mut craw = vec![0u8; 82]; + craw.extend(bx(b"JPEG", &[0; 4])); + let at = 2048u64; + let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat()); + let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat()); + let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat()); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat())))); + file.extend(bx(b"moov", &trak)); + file.resize(at as usize, 0); + file.extend_from_slice(&big); + assert_eq!(embedded_preview(&file).unwrap(), big); + // a track pointing past the end falls back to PRVW + file.truncate(at as usize + 100); + assert_eq!(embedded_preview(&file).unwrap(), small); + } } diff --git a/docs/parity.md b/docs/parity.md index fc94d08a..6a5a007e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -122,7 +122,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045dead..49f8634f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,6 +83,10 @@ changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel. ### Library +- Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the + 1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw + data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk picks + up the full-size preview; their camera metadata comes with a fresh import. - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets `img_1-1.JPG`; on case-insensitive volumes the case change still goes through. From c9fe78e1784fb6971ae4b432fa4150b58896982c Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:27:15 -0700 Subject: [PATCH 6/9] docs: add star history chart to README Show repository star growth at the bottom of the README. Link the date-based chart with a top-left legend to the corresponding Star History page. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e7454f24..5e741b7c 100644 --- a/README.md +++ b/README.md @@ -446,3 +446,7 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +## Star history + +[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left) From 9adb40908a5ed598e0605b577ee598b38daa4c1c Mon Sep 17 00:00:00 2001 From: Maurice Rogers Date: Tue, 6 Oct 2026 21:53:09 -0600 Subject: [PATCH 7/9] LR-IMP-FORMATS: Reload from Disk in the Photo and context menus; fills missing camera metadata photo.reload had no menu path, so the documented way to refresh CR3s imported before their metadata was read (Photo > Reload from Disk) did not exist in the app. It is now in the Photo menu and the photo context menu, and besides size/preview-only it fills camera fields the catalog lacks (camera, lens, exposure, GPS, capture time) from a fresh probe, never overwriting a set value. --- crates/engine/src/cmd/convert.rs | 72 +++++++++++++++++++++++++++++-- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/panels/grid.rs | 3 ++ docs/whats-new.md | 5 ++- 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 1641d63f..e946d1fa 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -183,6 +183,33 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo }) } +/// Ops that fill a photo's empty camera fields (camera, lens, exposure, GPS, capture time) from a fresh probe. +fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec { + let (mut m, src) = (ph.meta.clone(), &info.meta); + let before = m.clone(); + if m.camera.is_empty() { + m.camera = src.camera.clone(); + } + if m.lens.is_empty() { + m.lens = src.lens.clone(); + } + if m.shutter.is_empty() { + m.shutter = src.shutter.clone(); + } + m.focal_mm = m.focal_mm.or(src.focal_mm); + m.aperture = m.aperture.or(src.aperture); + m.iso = m.iso.or(src.iso); + m.gps = m.gps.or(src.gps); + let mut ops = Vec::new(); + if m != before { + ops.push(Op::SetMeta { id, meta: Box::new(m) }); + } + if ph.captured.is_none() && info.captured.is_some() { + ops.push(Op::SetCaptured { id, captured: info.captured.clone() }); + } + ops +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -199,8 +226,18 @@ fn reload(s: &mut Session, p: &Value) -> Result { let mut reloaded = Vec::new(); for ((id, _), info) in paths.into_iter().zip(probed) { let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; - let Some(op) = content_op(id, ph, info.clone()) else { continue }; + // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; + // nothing already set is overwritten + let meta_ops = fill_missing_meta(id, ph, &info); + let Some(op) = content_op(id, ph, info.clone()) else { + if !meta_ops.is_empty() { + ops.extend(meta_ops); + reloaded.push(id); + } + continue; + }; ops.push(op); + ops.extend(meta_ops); // virtual copies share the file for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { ops.extend(content_op(c.id, c, info.clone())); @@ -277,9 +314,9 @@ pub fn edit_specs() -> Vec { cmd!( "photo.reload", "Reload from Disk", - [], + ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}", has_selection, reload ), @@ -294,3 +331,32 @@ pub fn edit_specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_catalog::{Photo, PhotoId, Source}; + + /// Reload fills camera fields a photo lacks (a CR3 imported before CR3 metadata was read) and keeps + /// everything already set. + #[test] + fn reload_fills_only_missing_camera_fields() { + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 1620, 1080, "2026-10-06T00:00:00"); + ph.meta.title = "Mine".into(); + ph.meta.iso = Some(800); + let mut info = crate::media::ProbeInfo { captured: Some("2026-10-04T08:16:11".into()), ..Default::default() }; + info.meta.camera = "Canon EOS R6 Mark III".into(); + info.meta.lens = "RF24-105mm".into(); + info.meta.iso = Some(400); + info.meta.aperture = Some(6.3); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!((meta.camera.as_str(), meta.lens.as_str(), meta.aperture), ("Canon EOS R6 Mark III", "RF24-105mm", Some(6.3))); + assert_eq!((meta.title.as_str(), meta.iso), ("Mine", Some(800)), "set fields are kept"); + assert!(matches!(ops.get(1), Some(Op::SetCaptured { captured: Some(_), .. }))); + // nothing missing → nothing to do + ph.meta = (**meta).clone(); + ph.captured = Some("2026-10-04T08:16:11".into()); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); + } +} diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4e5c71a5..288e0262 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1017,5 +1017,6 @@ "Quit": "終了", "Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。", "Back Up Library…": "ライブラリをバックアップ…", - "Restore Library from Backup…": "バックアップからライブラリを復元…" + "Restore Library from Backup…": "バックアップからライブラリを復元…", + "Reload from Disk": "ディスクから再読み込み" } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e..d876bffb 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -186,6 +186,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "photo.saveMetadataToFile", "photo.readMetadataFromFile", + "photo.reload", "app.showInFinder", "dialog.rename", "dialog.captureTime", diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 760abbfe..405cbf75 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -821,6 +821,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } + if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { + let _ = app.run("photo.reload", json!({})); + } if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); } diff --git a/docs/whats-new.md b/docs/whats-new.md index 49f8634f..d2698a96 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -85,8 +85,9 @@ ### Library - Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the 1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw - data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk picks - up the full-size preview; their camera metadata comes with a fresh import. + data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in + the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they + were missing, without touching anything already set. - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets `img_1-1.JPG`; on case-insensitive volumes the case change still goes through. From 489f78ba6967067ad68f6471b39a6708f1a50213 Mon Sep 17 00:00:00 2001 From: Mohammed Munzir Date: Wed, 7 Oct 2026 10:40:55 +0300 Subject: [PATCH 8/9] CLI: the connect hint names the port it actually tried (#167) run --connect ADDR could not reach the app and always advised lightcraft --control 7980, whatever port ADDR named, so following the hint left the command connecting elsewhere. The hint now takes the port from ADDR itself, with an end-to-end test on a refused connection. --- apps/lightcraft-cli/src/main.rs | 68 +++++++++++++++++--------------- apps/lightcraft-cli/tests/cli.rs | 10 +++++ 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 9bf0e301..c1312081 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -416,39 +416,39 @@ fn run(args: &[String]) -> Result<(), String> { if steps.is_empty() { return Err("run: no command given (see `lightcraft-cli commands`)".into()); } - let mut backend: Box = match connect { - Some(addr) => { - if demo || library.is_some() || !imports.is_empty() { - return Err("--demo, --library and --import apply to headless mode only".into()); - } - Box::new( - Remote::connect(&addr) - .map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?, - ) - } - None => { - let mut h = match &library { - Some(dir) => { - let mut h = Headless::default(); - h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; - h - } - None if demo => Headless::demo(), - None => Headless::default(), - }; - if !imports.is_empty() { - let paths = expand_paths(&imports); - let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; - // what follows acts on the imported photos (already-known files are reported as duplicates) - let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default(); - ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned())); - if let Some(first) = ids.first().cloned() { - h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?; + let mut backend: Box = + match connect { + Some(addr) => { + if demo || library.is_some() || !imports.is_empty() { + return Err("--demo, --library and --import apply to headless mode only".into()); } + Box::new(Remote::connect(&addr).map_err(|e| { + format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr)) + })?) } - Box::new(h) - } - }; + None => { + let mut h = match &library { + Some(dir) => { + let mut h = Headless::default(); + h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; + h + } + None if demo => Headless::demo(), + None => Headless::default(), + }; + if !imports.is_empty() { + let paths = expand_paths(&imports); + let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; + // what follows acts on the imported photos (already-known files are reported as duplicates) + let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default(); + ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned())); + if let Some(first) = ids.first().cloned() { + h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?; + } + } + Box::new(h) + } + }; let mut out = std::io::stdout().lock(); let mut failed = 0; for s in &steps { @@ -474,6 +474,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value")) } +/// The port an `--connect` address names, for the recovery hint. The whole address when it has +/// no port, so a hint is never built from a guess. +fn connect_port(addr: &str) -> &str { + addr.rsplit_once(':').map_or(addr, |(_, port)| port) +} + fn render(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9d41873a..37799f4a 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -324,3 +324,13 @@ fn a_library_open_in_another_process_is_refused() { assert_eq!(lines[0]["ok"], true); let _ = std::fs::remove_dir_all(&lib); } + +/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port +/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere. +#[test] +fn connect_failure_hint_names_the_attempted_port() { + let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}"); + assert!(!err.contains("--control 7980"), "{err}"); +} From 51f0ef25ae7a2b5958e7ad83afcfabb646226f07 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 05:41:28 -0400 Subject: [PATCH 9/9] Export: look up the library's symlinked originals once per guard The symlink fallback ran read_link on every library entry for each existing target; a large library made every Overwrite export check do thousands of syscalls. The guard now finds the links on first need and keeps them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/originals.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 130d22a2..b3a140a2 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source}; pub struct OriginalGuard { /// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …) by_name: HashMap>, + /// The entries of `by_name` that are symlinks, found on the first check that needs them. + links: std::sync::OnceLock>, } impl OriginalGuard { @@ -32,6 +34,7 @@ impl OriginalGuard { /// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`). pub fn add(&mut self, path: &Path, label: &str) { let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return }; + self.links = std::sync::OnceLock::new(); let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what)); put(name.clone(), path.to_path_buf(), format!("the original of {label}")); let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); @@ -60,9 +63,11 @@ impl OriginalGuard { } // An imported symlink can have a different name from its original. A write to // that original would change what the library reads through the link. Only do - // this fallback for existing targets that the name index did not protect. - for (p, what) in self.by_name.values().flatten() { - if std::fs::read_link(p).is_ok() && same_file(p, target) { + // this fallback for existing targets that the name index did not protect; the links are + // looked up once per guard, not once per target. + let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect()); + for (p, what) in links { + if same_file(p, target) { return Err(format!( "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", target.display()