From c8dff74948d8a9d9f939afc662b5d4771f22d732 Mon Sep 17 00:00:00 2001 From: srikrishna-00 <73975911+srikrishna-00@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:15:17 +0530 Subject: [PATCH 001/142] #618: profiles.list includes imported LUT profiles `profiles.list` enumerated only the built-in `PROFILES`, so a `.cube` LUT imported with `profile.import` never appeared in it, although the command promises every profile and `develop.profile` points clients to it for ids. `profiles.menu` already listed them. List the built-in profiles first, then the imported LUT profiles marked `"imported": true` (as in `profiles.menu`), with the same favourite flag. Test: `cube_luts_become_profiles` now checks the imported profile is listed with its name, group and favourite flag, still listed after the library is reopened, gone after `profile.deleteImported`, and that built-ins stay listed (fails before this change, passes after). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/query.rs | 14 +++++++------- crates/engine/src/tests_import.rs | 10 ++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 824039e46..85ea71d5e 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -178,13 +178,13 @@ pub fn specs() -> Vec { cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| { Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect())) }), - cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| { - Ok(Value::Array( - crate::presets::PROFILES - .iter() - .map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)})) - .collect(), - )) + cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile (built-in, then imported LUTs marked `imported`) with its group and favourite flag", always, |s, _| { + let favorite = |id: &str| s.profile_favorites.iter().any(|f| f == id); + let builtin = crate::presets::PROFILES.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(p.id)})); + // imported LUT profiles too (#618), marked as in `profiles.menu` + let imported = + s.lut_profiles.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(&p.id), "imported": true})); + Ok(Value::Array(builtin.chain(imported).collect())) }), cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())), cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| { diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 4a4cad539..5b3397c34 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -548,6 +548,14 @@ fn cube_luts_become_profiles() { assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); s.execute("profile.favorite", &json!({"id": id})).unwrap(); assert!(s.profile_favorites.contains(&id)); + // `profiles.list` lists it next to the built-in profiles (#618) + let listed = |s: &mut Session| { + let list = s.execute("profiles.list", &json!({})).unwrap(); + let list = list.as_array().unwrap(); + assert!(list.iter().any(|p| p["id"] == "lc.color"), "built-in profiles stay listed"); + list.iter().find(|p| p["id"] == id.as_str()).cloned() + }; + assert_eq!(listed(&mut s), Some(json!({"id": id, "name": "Warm Test", "group": "Film Looks", "favorite": true, "imported": true}))); // the library keeps it drop(s); lightcraft_pipeline::lut::unregister(&id); @@ -557,8 +565,10 @@ fn cube_luts_become_profiles() { assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen"); assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open"); + assert!(listed(&mut s).is_some_and(|p| p["favorite"] == true), "listed after reopen"); s.execute("profile.deleteImported", &json!({"id": id})).unwrap(); assert!(lightcraft_pipeline::lut::get(&id).is_none()); + assert_eq!(listed(&mut s), None, "a deleted profile is no longer listed"); let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } From 770737981248413248625de3cd15e3830c7b2db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wladmir=20J=C3=BAnior?= Date: Sun, 11 Oct 2026 00:44:03 -0300 Subject: [PATCH 002/142] pt-BR: translate face recognition and album sorting strings --- crates/ui-egui/locales/pt-br.json | 41 ++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index dfe472818..d2fb7df5f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1884,5 +1884,44 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "Sort Albums A–Z": "Ordenar álbuns de A a Z", + "Go back to listing them by name": "Voltar a listá-los por nome", + "Sort Contents A–Z": "Ordenar conteúdo de A a Z", + "Cancel Pending Photo Action": "Cancelar ação pendente da foto", + "Show Person": "Mostrar pessoa", + "Add Face Model…": "Adicionar modelo de rostos…", + "Cancel Face Model Download": "Cancelar download do modelo de rostos", + "Choose Face Recognition Model": "Escolher modelo de reconhecimento facial", + "Detect Faces": "Detectar rostos", + "Download Face Model": "Baixar modelo de rostos", + "Drag Crop Handle": "Arrastar alça de recorte", + "Evaluate Face Recognition": "Avaliar reconhecimento facial", + "Face Model Downloads": "Downloads de modelos de rostos", + "Face Models": "Modelos de rostos", + "Face Recognition On/Off": "Ativar/desativar reconhecimento facial", + "Faces of a Person": "Rostos de uma pessoa", + "Index Faces": "Indexar rostos", + "Index Faces in the Background": "Indexar rostos em segundo plano", + "Inspect Face Model File": "Inspecionar arquivo do modelo de rostos", + "Install Face Model": "Instalar modelo de rostos", + "Library Folders": "Pastas da biblioteca", + "Name Face": "Nomear rosto", + "Name Faces": "Nomear rostos", + "Remove Face Model": "Remover modelo de rostos", + "Reorder Album": "Reordenar álbum", + "Sort Albums by Name": "Ordenar álbuns por nome", + "Suggest Names for Faces": "Sugerir nomes para rostos", + "Test Face Model": "Testar modelo de rostos", + "Unnamed Faces": "Rostos sem nome", + "Faces": "Rostos", + "Face recognition": "Reconhecimento facial", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Esta versão não tem onde guardar modelos de rostos, então a lista de modelos não está disponível. (O aplicativo para desktop tem.)", + "Recognise faces (experimental)": "Reconhecer rostos (experimental)", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Sugere quem aparece em uma foto com base nos rostos que você nomeou. Tudo permanece no seu computador.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Esta versão não consegue executar modelos de reconhecimento: eles podem ser adicionados e escolhidos, mas não usados.", + "Choose a model below (Use) to start.": "Escolha um modelo abaixo (Usar) para começar.", + "Face detector": "Detector de rostos", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Detectar rostos (menu Foto) precisa dele: um pequeno download único.", + "Recognition models": "Modelos de reconhecimento" } From 8e61ad6b13d94c743ecd35649c2150e596c97bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E6=B4=8B=20=E9=99=88?= <205215139@qq.com> Date: Sun, 11 Oct 2026 15:56:17 +0800 Subject: [PATCH 003/142] Add Ctrl+O / Cmd+O to show every mask overlay at once Previously the masking overlay showed only the selected mask's region (toggled by O). This adds a new Overlay::AllMasks variant that paints the evaluated alpha of every visible mask that has components in one tint, wired to a new "Show All Mask Overlays" command (Ctrl+O on Windows/Linux, Cmd+O on macOS) reachable from the View menu, the masking panel header (stack icon), and the keyboard shortcut. Both the CPU and GPU render paths evaluate all visible masks and hand their alpha planes to visualize::apply, which layers each mask's region so multiple masks are visible at once. The all-masks overlay takes precedence over the single-mask overlay so the shortcut is not shadowed by the always-on per-mask view. --- crates/gpu/src/render.rs | 12 +++++- crates/pipeline/src/lib.rs | 21 ++++++++++- crates/pipeline/src/visualize.rs | 56 +++++++++++++++++++++++++++- crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 6 +++ crates/ui-egui/src/panels/detail.rs | 12 ++++++ crates/ui-egui/src/panels/masking.rs | 4 ++ crates/ui-egui/src/state.rs | 4 ++ crates/ui-egui/src/tests_masking.rs | 9 +++++ 9 files changed, 122 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 45a5a5f6d..1ace48078 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -688,6 +688,16 @@ pub fn render( _ => Err(m), } }); + // every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once) + let all_overlay_alphas: Option> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) { + let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); + masks.as_ref().map(|all| { + list.iter().enumerate().map(|(mi, _)| { + let b = cx.slice(all, mi * n, n); + cx.read_plane(&b, w, h) + }).collect() + }) + } else { None }; let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); @@ -723,7 +733,7 @@ pub fn render( lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref()) } }); - lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref()); // spots grew the window the render worked on: cut it back to the request if let Some(k) = plan.keep { image = image.crop(k.x, k.y, k.w, k.h); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index f7c33ee9e..82d05800b 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -551,7 +551,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("histogram", &mut t); let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); - visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); + let masks = overlay_alphas_all(req.overlay, &plan, &prep); + visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref()); let mut image = if plan.keep.is_some() { cut(&image) } else { image }; if req.overlay == Overlay::HdrRange && edit.hdr.enabled { // the HDR rendition of the same request says how far above SDR white each pixel goes @@ -603,6 +604,24 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref())) } +/// The alpha planes an `Overlay::AllMasks` shows: one per visible mask that has components, +/// reusing the render's own evaluation where available (a fresh evaluation otherwise). +fn overlay_alphas_all(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option> { + if !matches!(o, Overlay::AllMasks { .. }) { + return None; + } + let ev = plan.settings.light.exposure as f32; + let mut out = Vec::new(); + for m in plan.settings.masks.iter().filter(|m| m.visible && !m.components.is_empty()) { + if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { + out.push(e.alpha.clone()); + } else { + out.push(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref())); + } + } + Some(out) +} + /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" /// (default look, keeping the crop so framing matches, under the photo's own rendering process). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index fc3d8390f..dd87ce332 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -33,6 +33,9 @@ pub enum Overlay { /// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in /// `color` at `opacity` (0..100; the colour views only). Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 }, + /// The evaluated alpha of every visible mask that has components, drawn as one `view` in + /// `color` at `opacity` (0..100; the colour views only) — every mask's region at once. + AllMasks { view: MaskView, color: [u8; 3], opacity: u8 }, /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). HdrRange, @@ -120,6 +123,7 @@ impl Overlay { Overlay::PointColorRange(i) => (1, i as f64), Overlay::Spots(t) => (2, t as f64), Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), + Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64), Overlay::HdrRange => (4, 0.0), } } @@ -131,6 +135,14 @@ impl Overlay { 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), 4 => Overlay::HdrRange, + 5 if v.is_finite() && v >= 0.0 => { + let rgb = (v as u64 >> 20) & 0xff_ffff; + Overlay::AllMasks { + view: MaskView::ALL.get(((v as u64 >> 16) & 0xf) as usize).copied().unwrap_or_default(), + color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8], + opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8, + } + } _ => Overlay::None, } } @@ -142,6 +154,7 @@ impl Overlay { Overlay::PointColorRange(i) => 0x1000 + i as u64, Overlay::Spots(t) => 0x2000 + t as u64, Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), + Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity), Overlay::HdrRange => 0x4000, } } @@ -168,8 +181,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } /// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask -/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { +/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of +/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components. +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} @@ -184,6 +198,15 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) mask_view(img, a, view, color, opacity); } } + Overlay::AllMasks { view, color, opacity } => { + if let Some(planes) = masks { + for a in planes { + if (a.width, a.height) == (img.width, img.height) { + mask_view(img, a, view, color, opacity); + } + } + } + } } } @@ -380,4 +403,33 @@ mod tests { assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color); assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); } + + #[test] + fn all_masks_overlay_shows_every_visible_mask_at_once() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = Rgb32f::from_fn(80, 40, |_, _| [0.1, 0.1, 0.1]); + let info = SourceInfo::default(); + let left = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let right = MaskShape::Radial { center: Point::new(0.75, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { id: 1, visible: true, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: left }], ..Default::default() }); + s.masks.push(Mask { id: 2, visible: true, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }], ..Default::default() }); + let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; + let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }; + let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40); + assert_eq!(img.data[in_l], [255; 4], "left mask region painted"); + assert_eq!(img.data[in_r], [255; 4], "right mask region painted"); + assert_eq!(img.data[mid], [0, 0, 0, 255], "between the masks: black"); + // a hidden mask is skipped + s.masks[1].visible = false; + let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted"); + assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted"); + // parts round trip and a stable, distinct cache key + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }.key()); + } } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b919aa2e6..89f59d1ea 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -149,6 +149,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.visualizeHdr", "view.softProof", "view.maskOverlay", + "view.maskOverlayAll", "view.maskOverlayMode", "view.maskOverlayColor", "view.maskPins", @@ -312,6 +313,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.visualizeHdr" => Some(u.hdr_visualize), "view.softProof" => Some(u.soft_proof), "view.maskOverlay" => Some(u.mask_overlay), + "view.maskOverlayAll" => Some(u.mask_overlay_all), "view.maskPins" => Some(u.mask_pins), "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81a46eb98..5ae64e1a5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -91,6 +91,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + // Ctrl+O / Cmd+O: every mask's region at once, not just the selected one + ("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+O"), "View"), // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), @@ -634,6 +636,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.mask_overlay_all = !app.ui.mask_overlay_all; + Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all})) + } "view.maskOverlayMode" => { use lightcraft_engine::pipeline::MaskView; let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..24976d94e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1065,6 +1065,18 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra if app.ui.fullscreen { return Overlay::None; } + // Show every visible mask's region at once (Ctrl+O / Cmd+O), ahead of the selected-mask overlay + // so the shortcut is not shadowed by the always-on single-mask overlay. + if app.ui.right == RightPanel::Masking + && app.ui.mask_overlay_all + && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) + { + return Overlay::AllMasks { + view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), + color: app.ui.mask_overlay_color, + opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8, + }; + } if app.ui.right == RightPanel::Masking && app.ui.mask_overlay && let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..47b96ee6d 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -197,6 +197,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() { app.ui.mask_overlay = !on; } + let all = app.ui.mask_overlay_all; + if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, "Show all overlays (Ctrl+O)").clicked() { + app.ui.mask_overlay_all = !all; + } }); }); if d.masks.is_empty() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6a50e2baf..b52f7ec64 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -332,6 +332,9 @@ pub struct UiState { pub mask_overlay_color: [u8; 3], pub mask_overlay_opacity: f32, pub mask_pins: bool, + /// Masking: show every visible mask's region as one overlay at once (Ctrl+O / Cmd+O), + /// instead of just the selected mask's ([`mask_overlay`]). + pub mask_overlay_all: bool, /// The overlay (on, mode) to go back to when Show Luminance Map is turned off. #[serde(skip)] pub luminance_map_restore: Option<(bool, String)>, @@ -813,6 +816,7 @@ impl Default for UiState { mask_overlay_color: [230, 30, 40], mask_overlay_opacity: 50.0, mask_pins: true, + mask_overlay_all: false, crop_overlay: CropOverlay::Thirds, crop_overlay_orient: 0, show_filenames: true, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index b95fa1ab2..db009e816 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -168,6 +168,15 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged"); assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. })); + // Ctrl+O shows every visible mask's region at once (the new command), ahead of the selected + // mask overlay so it is not shadowed by the always-on single-mask overlay. + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(h.app.ui.mask_overlay_all, "Ctrl+O turns on the all-masks overlay"); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(!h.app.ui.mask_overlay_all, "Ctrl+O toggles the all-masks overlay off"); + assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + // Failed edits and global adjustments must not hide the mask overlay. assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err()); assert!(h.app.ui.mask_overlay); From f4761b459c2dce352eee7cdbd2c19eaa4589f2cf Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 03:26:25 +0200 Subject: [PATCH 004/142] LR-PROF-CAMERACOLOR: camera-JPEG colour fit accepts previews above 64 MP (ILCE-7RM6 full frame) --- crates/engine/src/camera_preview.rs | 25 ++++++++++++++++++++++++- docs/camera-preview-colour.md | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..40c306d51 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -265,12 +265,19 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { }) } +/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and +/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); +/// 128 MP also admits 100 MP medium-format previews. +const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; - let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; + let decoded = crate::files::decode_raw_preview( + bytes, + lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, + )?; let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { @@ -2810,4 +2817,20 @@ mod tests { reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); } + + /// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted. + #[test] + fn corpus_arw6_full_frame_gets_a_camera_look() { + const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) }; + 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/arw6-sony-ilce7rm6-ff-land-c.arw"); + 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"); + } } diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..b1ab486d7 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -4,7 +4,7 @@ ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF decoding supply sensor data and c ## Colour and tone are separate -A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. +A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 128-million-pixel input limit (61 MP bodies' JPEGs, e.g. the ILCE-7RM6 full frame at 66.5 MP, exceed 64 MP; the codec checks the cap before decoding at full size, about 200 MB transient), converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (5 values × 72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout with an sRGB-encoded value axis, applied in linear ProPhoto RGB after the baseline exposure) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The value axis matters: on a sunny waterfall scene dark yellows had to turn ~12° toward orange while brighter yellow-greens turned ~9° toward green, and one shift per hue made both worse. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels). From a90cdcd2f34664e23ab04d814184ea2b01ac599c Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 10:25:01 +0200 Subject: [PATCH 005/142] =?UTF-8?q?LR-PROF-CAMERACOLOR:=20ILCE-7RM6=20colo?= =?UTF-8?q?ur=20results=20=E2=80=94=20per-file=20fit,=20no=20pooled=20prof?= =?UTF-8?q?ile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/camera-preview-colour.md | 8 ++++++++ docs/parity-checklist.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b1ab486d7..692098a79 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -80,6 +80,14 @@ On the public CC0 EOS R100 / RF-S 18–45 mm sample, the measured framing scale 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, Nikon and Canon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. +### Sony ILCE-7RM6 (A7R VI) + +Full-frame A7R VI files never reached the fit: their 66.5 MP embedded JPEG exceeded the fit's former 64 MP preview cap, so they opened in the neutral fallback. The cap is now 128 MP. On the full-frame corpus file the holdout RMS went from 0.124 to 0.062, and the render's mean RGB from 85/87/77 to 104/117/79 against the camera JPEG's 108/122/82. + +On a local set of 54 ARW6 and lossless ARW photos (the maintainer's own shots plus the 12 CC0 RawDB ILCE-7RM6 samples), 46 were fitted and 8 were rejected by the gates; the kinds of scene behind the rejections were not analysed. On 15 withheld photos (every third usable photo in sorted path order), mean CIE76 ΔE against the camera JPEG was 5.7 (render at 1200 px, comparison at 200 px, matching orientation); the APS-C corpus file scored 5.1 and the portrait RawDB files 4.6 and 6.0. + +A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 12 of 15 photos regressed, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The set is one photographer's scenes, mostly in the Standard creative style; revisit when a varied multi-photographer set exists. + ## Camera profiles (pooled per model) One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index cb0c48a8e..2c3e3bce3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -233,7 +233,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 12 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); 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 | From 9e2f38316623731cdba8196d6f7d625a3e15f7e3 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 10:29:55 +0200 Subject: [PATCH 006/142] =?UTF-8?q?LR-PROF-CAMERACOLOR:=20ILCE-7RM6=20colo?= =?UTF-8?q?ur=20results=20=E2=80=94=20corrected=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/camera-preview-colour.md | 4 ++-- docs/parity-checklist.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 692098a79..8e62e9400 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -84,9 +84,9 @@ This is a per-file camera-look estimate, **not measured spectral calibration or Full-frame A7R VI files never reached the fit: their 66.5 MP embedded JPEG exceeded the fit's former 64 MP preview cap, so they opened in the neutral fallback. The cap is now 128 MP. On the full-frame corpus file the holdout RMS went from 0.124 to 0.062, and the render's mean RGB from 85/87/77 to 104/117/79 against the camera JPEG's 108/122/82. -On a local set of 54 ARW6 and lossless ARW photos (the maintainer's own shots plus the 12 CC0 RawDB ILCE-7RM6 samples), 46 were fitted and 8 were rejected by the gates; the kinds of scene behind the rejections were not analysed. On 15 withheld photos (every third usable photo in sorted path order), mean CIE76 ΔE against the camera JPEG was 5.7 (render at 1200 px, comparison at 200 px, matching orientation); the APS-C corpus file scored 5.1 and the portrait RawDB files 4.6 and 6.0. +On a local set of 54 ARW6 and lossless ARW photos (the maintainer's own shots plus the 12 CC0 RawDB ILCE-7RM6 samples), 46 were fitted and 8 were rejected by the gates; the kinds of scene behind the rejections were not analysed. On 15 withheld photos (every third usable photo in sorted path order), mean CIE76 ΔE against the camera JPEG was 5.7 (render at 1200 px, comparison at 200 px, matching orientation); in a separate sanity check at the same settings, the APS-C corpus file scored 5.1 and two portrait RawDB files 4.6 and 6.0 (orientation-matched). These numbers were measured before the ARW6 companding curve was replaced by its clean-room derivation (`docs/arw6-compression.md`), which decodes highlights about 0.3 % lower; the fit was not re-measured. -A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 12 of 15 photos regressed, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The set is one photographer's scenes, mostly in the Standard creative style; revisit when a varied multi-photographer set exists. +A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 11 of 15 photos regressed, 3 improved and 1 was unchanged, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The 42 private photos are one photographer's scenes, 38 of them in the Standard creative style (the 12 RawDB files were not inventoried); revisit when a varied multi-photographer set exists. ## Camera profiles (pooled per model) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2c3e3bce3..4a034121c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -233,7 +233,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 12 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); 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 | From a77b0cc472abf8a8b2c387bff8c78784d780768c Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:14:52 +0700 Subject: [PATCH 007/142] fix(meta): reject impossible Gregorian capture dates --- crates/meta/src/datetime.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs index 271aa67d5..4f5e03d7a 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -25,8 +25,19 @@ fn num(s: &str) -> Option { impl DateTime { fn valid(self) -> Option { - let ok = (1..=12).contains(&self.month) - && (1..=31).contains(&self.day) + // A timestamp must represent a real Gregorian calendar day, not just + // numbers in the range 1..=31. Invalid dates would otherwise be + // accepted from EXIF/XMP and silently normalized during time arithmetic. + let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0); + let max_day = match self.month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => 0, + }; + let ok = self.day >= 1 + && self.day <= max_day && self.hour < 24 && self.minute < 60 && self.second < 61 @@ -165,6 +176,16 @@ mod tests { assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none()); assert!(DateTime::parse_exif(" : : : : ").is_none()); assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none()); + // Invalid calendar days must never enter persisted capture metadata. + for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] { + assert!(DateTime::parse_exif(value).is_none(), "{value}"); + } + assert!(DateTime::parse_exif("2024:02:29").is_some()); + assert!(DateTime::parse_exif("2000:02:29").is_some()); + for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] { + assert!(DateTime::parse_iso(value).is_none(), "{value}"); + } + assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some()); assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0); assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500); assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12); From c41b1e311d4788c14aa52ebf157c01399e74dd9f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:16:19 +0700 Subject: [PATCH 008/142] fix(geom): reject non-finite affine and projective transforms --- crates/geom/src/homography.rs | 31 ++++++++++++++++++++++++++----- crates/geom/src/lib.rs | 5 +++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/geom/src/homography.rs b/crates/geom/src/homography.rs index b27ecef24..daa11b05f 100644 --- a/crates/geom/src/homography.rs +++ b/crates/geom/src/homography.rs @@ -51,11 +51,11 @@ impl Homography { let c01 = m[5] * m[6] - m[3] * m[8]; let c02 = m[3] * m[7] - m[4] * m[6]; let det = m[0] * c00 + m[1] * c01 + m[2] * c02; - if det.abs() < 1e-300 { + if !det.is_finite() || det.abs() < 1e-300 { return None; } let inv = 1.0 / det; - Some(Homography([ + let coefficients = [ c00 * inv, (m[2] * m[7] - m[1] * m[8]) * inv, (m[1] * m[5] - m[2] * m[4]) * inv, @@ -65,11 +65,17 @@ impl Homography { c02 * inv, (m[1] * m[6] - m[0] * m[7]) * inv, (m[0] * m[4] - m[1] * m[3]) * inv, - ])) + ]; + coefficients.iter().all(|v| v.is_finite()).then_some(Homography(coefficients)) } /// The homography mapping `src[i]` → `dst[i]` (4 correspondences, DLT with h33 = 1). pub fn from_quads(src: &[Point; 4], dst: &[Point; 4]) -> Option { + // Failed feature matches and invalid perspective parameters can contain + // non-finite coordinates. Do not return a transform that poisons rendering. + if src.iter().chain(dst.iter()).any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return None; + } let mut a = [[0.0f64; 9]; 8]; for i in 0..4 { let (x, y) = (src[i].x, src[i].y); @@ -80,7 +86,7 @@ impl Homography { // Gaussian elimination with partial pivoting on the 8×8 system (last column = rhs). for col in 0..8 { let piv = (col..8).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?; - if a[piv][col].abs() < 1e-12 { + if !a[piv][col].is_finite() || a[piv][col].abs() < 1e-12 { return None; } a.swap(col, piv); @@ -94,7 +100,7 @@ impl Homography { } } let h: Vec = (0..8).map(|i| a[i][8] / a[i][i]).collect(); - Some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0])) + h.iter().all(|v| v.is_finite()).then_some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0])) } } @@ -121,4 +127,19 @@ mod tests { let src = [Point::new(0.0, 0.0); 4]; assert!(Homography::from_quads(&src, &src).is_none()); } + + #[test] + fn nonfinite_projective_and_affine_transforms_are_rejected() { + let src = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)]; + let mut invalid = src; + invalid[2].x = f64::NAN; + assert!(Homography::from_quads(&invalid, &src).is_none()); + invalid[2].x = f64::INFINITY; + assert!(Homography::from_quads(&src, &invalid).is_none()); + assert!(Homography([f64::NAN, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none()); + assert!(Homography([f64::INFINITY, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none()); + assert!(Affine([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none()); + assert!(Affine([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none()); + assert_eq!(Affine::IDENTITY.inverse(), Some(Affine::IDENTITY)); + } } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1d81279d9..4b8f623e2 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -234,12 +234,13 @@ impl Affine { } pub fn inverse(&self) -> Option { let det = self.determinant(); - if det.abs() < 1e-300 { + if !det.is_finite() || det.abs() < 1e-300 { return None; } let [a, b, c, d, e, f] = self.0; let inv = 1.0 / det; - Some(Affine([d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv])) + let coefficients = [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv]; + coefficients.iter().all(|v| v.is_finite()).then_some(Affine(coefficients)) } /// Bounding box of a transformed rectangle. pub fn transform_rect_bbox(&self, r: &Rect) -> Rect { From e4f92df78c6fcd2ca0810f0c60bc02eefbce9888 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:17 +0700 Subject: [PATCH 009/142] fix(meta): reject incomplete and mismatched GPX documents --- crates/meta/src/gpx.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/meta/src/gpx.rs b/crates/meta/src/gpx.rs index a8e8e9071..e63499033 100644 --- a/crates/meta/src/gpx.rs +++ b/crates/meta/src/gpx.rs @@ -96,7 +96,9 @@ struct Pending { /// Parse a GPX document. pub fn parse_gpx(s: &str) -> Result { let mut reader = quick_xml::Reader::from_str(s); - reader.config_mut().check_end_names = false; + // Mismatched end tags must be rejected; otherwise the path stack can + // attribute a point's time or segment to the wrong enclosing element. + reader.config_mut().check_end_names = true; let mut path: Vec> = Vec::new(); let mut seen_gpx = false; let mut log = Tracklog::default(); @@ -169,6 +171,9 @@ pub fn parse_gpx(s: &str) -> Result { if !seen_gpx { return Err(GpxError::NotGpx); } + if !path.is_empty() { + return Err(GpxError::Xml("unclosed GPX elements".into())); + } // stable: points logged at the same second keep their file order log.points.sort_by(|a, b| a.time.total_cmp(&b.time)); Ok(log) @@ -362,6 +367,12 @@ mod tests { fn rejects_non_gpx_and_bad_input() { assert_eq!(parse_gpx(""), Err(GpxError::NotGpx)); assert!(parse_gpx("").is_err()); + // A partial logger write must not be mistaken for a complete track, + // even if it contains a valid timed point. + let point = r#""#; + assert!(matches!(parse_gpx(point), Err(GpxError::Xml(_)))); + let wrong_close = r#""#; + assert!(matches!(parse_gpx(wrong_close), Err(GpxError::Xml(_)))); assert!( parse_gpx("") .unwrap() From 8c19c9f60a0c29da23f4dc81c87e494fcb262bf0 Mon Sep 17 00:00:00 2001 From: Alfons Hoogervorst <92640+snofla@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:54:06 +0200 Subject: [PATCH 010/142] LR-IMP-CAMERA-COVERAGE: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified) All 38 files decode from sensor data and develop at full size (9520x6336 and 6336x9520) without error; mean CIE76 dE to the camera JPEG is 6.3-13.9 at 400 px. The files are not committed and the evidence entry carries no path. Leica is now 14 verified / 47 unverified, the Leica bucket on a verified body 5% -> 21%; the weighted shares (85% decode, 42% on a verified body) do not move. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 +++- docs/gaps.md | 5 +++-- docs/raw-parity.md | 21 ++++++++++++--------- docs/raw/coverage.tsv | 2 +- docs/raw/evidence.tsv | 1 + 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..9b73beb6c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -114,6 +114,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full + size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models diff --git a/docs/gaps.md b/docs/gaps.md index 152a2dc67..1f98cba02 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (gap 10: Leica Q3 verified, 236 models; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..9a0d72617 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,11 +14,11 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| Decoded from sensor data and **verified** on a real file | **236** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **941** (65%) | measured from the format rules (631 of them DNG, mostly phones) | | **Preview-only** (embedded JPEG) | **233** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 83 / 639 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | | …of which on a body we have verified | **~42%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | @@ -46,7 +46,9 @@ committed table is stale. the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body - there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the + there, 178 files from 118 bodies), and a recorded check of 38 private Leica Q3 DNGs (2026-10-11: every file + decoded and developed at full size, 9520 × 6336 and 6336 × 9520; mean CIE76 ΔE to the camera JPEG 6.3–13.9 + at 400 px; the files are not committed). Bulk-check body lists were matched to Adobe names through the raw.pixls.us folder names. Checks without a body list (≈ 60 NEFs, 35 CR2 bodies for the CFA layout) are not counted as verified. - **Usage weights** ([raw/usage-weights.tsv](raw/usage-weights.tsv)): maker buckets from 2025 market @@ -72,7 +74,7 @@ committed table is stale. | Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | | Google | 88 | 1 | 87 | 0 | 0 | — | | Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | -| Leica | 61 | 13 | 48 | 0 | 0 | — | +| Leica | 61 | 14 | 47 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | | Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF | @@ -81,7 +83,7 @@ committed table is stale. | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **236 (16%)** | **941 (65%)** | **233 (16%)** | **36 (2%)** | | ## By use (estimated) @@ -95,7 +97,7 @@ committed table is stale. | OM / Olympus | 3% | 3% | 1% | compressed ORF | | Phones | 3% | 93% | 1% | DNG | | Pentax / Ricoh | 1.5% | 94% | 14% | | -| Leica | 1% | 95% | 5% | | +| Leica | 1% | 96% | 21% | the Q3 verified | | DJI | 1% | 95% | 0% | | | Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | | **Weighted** | 100% | **85%** | **42%** | | @@ -137,7 +139,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | PEF / DNG | Pentax pixel shift | ⬜ | frames not combined | | SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 | | SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused | -| DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs | +| DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | | DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | | DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL only; white-level pile-up fix #548 | @@ -266,6 +268,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..efc4b98fb 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -622,7 +622,7 @@ Leica M11-P DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, Leica Q (Typ 116) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q2 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q2 Monochrom DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica Q3 DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q3 DNG 2022 verified recorded check 2026-10-11: 38 private Q3 DNGs (landscape and portrait) decoded from sensor data and developed at full size without error, mean CIE76 dE to the camera JPEG 6.3-13.9 at 400 px (not committed; docs/raw-parity.md) Leica Q3 43 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q3 Monochrom DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S2 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..23d7758a9 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -40,6 +40,7 @@ Leica D-Lux 6 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leic Leica D-Lux 7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica Digilux 2 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body Leica Digilux 3 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica Q3 verified recorded check 2026-10-11: 38 private Q3 DNGs (landscape and portrait) decoded from sensor data and developed at full size without error, mean CIE76 dE to the camera JPEG 6.3-13.9 at 400 px (not committed; docs/raw-parity.md) Leica V-LUX (Typ 114) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format From 2801199bea24df5955475424d0595533701b0b36 Mon Sep 17 00:00:00 2001 From: Alfons Hoogervorst <92640+snofla@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:56:59 +0200 Subject: [PATCH 011/142] Issue #989: fill_box reads its buffer with as_chunks::<3>() (clippy 1.99 chunks_exact_to_as_chunks) Item 4 of #989. Same elements and the same ignored remainder as chunks_exact(3), checked on lengths 0-9 in a scratch file; the 13 spots tests pass and clippy over the workspace without the desktop app package is clean under -D warnings. Items 1-3 of #989 (the macOS app build, the /bin/true lock test, eleven engine tests) are not touched here. Co-Authored-By: Claude Sonnet 5.5 --- crates/pipeline/src/spots.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..3bb775df4 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,9 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + let (pixels, _) = out.as_chunks::<3>(); + for (d, v) in src.iter_mut().zip(pixels) { + *d = *v; } } From 8760dc4a414154f5adeef15e8e2bfde5881b79d6 Mon Sep 17 00:00:00 2001 From: Alfons Hoogervorst <92640+snofla@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:21:31 +0200 Subject: [PATCH 012/142] Issue #989: odd_folders_never_fail_the_check skips its non-UTF-8 names where the file system refuses them (item 3, one of eleven) APFS refuses names that are not valid UTF-8 (EILSEQ, os error 92), so the test's create_dir_all(...).unwrap() panicked on macOS. The test now probes once with a \xff folder; where that fails, the non-UTF-8 folder case and the non-UTF-8 entry are skipped and the catalog-folder and symlink cases still run on a plain-named folder. Where the probe succeeds (Linux, FreeBSD) the test is unchanged. Run on macOS (case-insensitive APFS, rustc 1.99.0): the five tests in tests_lightroom_folder pass, cargo fmt is clean, and clippy over lightcraft-engine with --all-targets is clean. The ten sidecar tests of item 3 are not touched. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests_lightroom_folder.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests_lightroom_folder.rs b/crates/engine/src/tests_lightroom_folder.rs index cdf9905c4..eccff23ac 100644 --- a/crates/engine/src/tests_lightroom_folder.rs +++ b/crates/engine/src/tests_lightroom_folder.rs @@ -155,13 +155,22 @@ fn odd_folders_never_fail_the_check() { #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; - let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata")); - std::fs::create_dir_all(&bad).unwrap(); - std::fs::write(root.join("Cat.lrcat"), b"x").unwrap(); - assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name"); - let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe")); + // APFS (macOS) refuses names that are not valid UTF-8 (EILSEQ): there the non-UTF-8 cases are skipped and + // the symlink cases below still run on a plain-named catalog folder + let probe = root.join(std::ffi::OsStr::from_bytes(b"\xff")); + let utf8_only = std::fs::create_dir(&probe).is_err(); + let _ = std::fs::remove_dir(&probe); + if !utf8_only { + let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata")); + std::fs::create_dir_all(&bad).unwrap(); + std::fs::write(root.join("Cat.lrcat"), b"x").unwrap(); + assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name"); + } + let lr = root.join(if utf8_only { std::ffi::OsStr::new("lr") } else { std::ffi::OsStr::from_bytes(b"\xff\xfe") }); std::fs::create_dir_all(&lr).unwrap(); - std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap(); + if !utf8_only { + std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap(); + } std::fs::write(lr.join("b.lrcat"), b"x").unwrap(); assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped"); // a symlink to a catalog folder is one too; a dangling one is not From 15e308c3eb99a293e76a184ec3db79a5424ce3a1 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:58:38 +0200 Subject: [PATCH 013/142] Sort ties by file name, then id Photos that tie on the sort key (equal ratings, one import batch, a burst within a second) now sort by file name before id, in Catalog::query, Recently Added and auto-stack. Split out of #641. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/query.rs | 15 +++++++++-- crates/catalog/src/stacks.rs | 29 +++++++++++++++++---- crates/catalog/src/tests.rs | 43 +++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 10 +++++-- crates/engine/src/tests_import.rs | 21 +++++++++++++++ docs/whats-new.md | 5 ++++ 6 files changed, 114 insertions(+), 9 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 8b694a021..be6ab6cdc 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -1,5 +1,7 @@ //! Filtering, search and sorting. +use std::cmp::Ordering::Equal; + use serde::{Deserialize, Serialize}; use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId}; @@ -359,8 +361,13 @@ impl Filter { } } +/// Case-insensitive file name order without allocating (compares per-character lowercase via `char::to_lowercase`). +pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering { + a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase)) +} + impl Catalog { - /// Photos matching `filter`, in `sort` order (ties broken by id for stability). + /// Photos matching `filter`, in `sort` order (ties broken by file name, then id). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { let root = filter.library_root(); let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); @@ -369,11 +376,15 @@ impl Catalog { SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), SortKey::ImportDate => a.imported.cmp(&b.imported), SortKey::EditDate => a.edited.cmp(&b.edited), - SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), + SortKey::FileName => cmp_name(&a.file_name, &b.file_name), SortKey::Rating => a.rating.cmp(&b.rating), SortKey::FileSize => a.file_size.cmp(&b.file_size), SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), } + // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the + // order cameras number frames, so the order doesn't depend on how ids were allocated; + // the id only keeps it stable. + .then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) .then(a.id.cmp(&b.id)); if sort.ascending { o } else { o.reverse() } }); diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 304106aef..04a9f0beb 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -9,6 +9,7 @@ use std::collections::{HashMap, HashSet}; +use crate::query::cmp_name; use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId}; impl Catalog { @@ -203,17 +204,19 @@ impl Catalog { /// Only runs of two or more are returned. pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec> { let index = self.stack_index(); - let mut timed: Vec<(i64, PhotoId)> = ids + let mut timed: Vec<(i64, &str, PhotoId)> = ids .iter() .filter_map(|id| self.photos.get(id)) .filter(|p| !p.deleted && !index.contains_key(&p.id)) - .filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id))) + .filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id))) .collect(); - timed.sort(); - timed.dedup_by_key(|(_, id)| *id); + // a burst within one second stays in file name order (the first file is the stack's cover) + timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2))); + // equal ids are the same photo, so same time and name: adjacent after the sort + timed.dedup_by_key(|(_, _, id)| *id); let mut groups: Vec> = Vec::new(); let mut last: Option = None; - for (t, id) in timed { + for (t, _, id) in timed { match (last, groups.last_mut()) { (Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id), _ => groups.push(vec![id]), @@ -264,6 +267,22 @@ mod tests { (c, ids) } + #[test] + fn auto_stack_keeps_a_burst_in_file_order() { + let mut c = Catalog::new(); + let mut by_name = Vec::new(); + for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() { + let id = PhotoId(1000 - n as u64); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some("2026-04-01T10:00:00".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + by_name.push(id); + } + let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0); + assert_eq!(groups, vec![by_name.clone()]); + assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover"); + } + #[test] fn iso_seconds_parses() { assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0)); diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index bedc94e75..acfbb1bb1 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -694,3 +694,46 @@ fn undated_photos_group_under_unknown_date_and_sort_together() { let filter_year = Filter { date: Some("2026".into()), ..Default::default() }; assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]); } + +/// Photos with one import time and capture time, as one import batch or a burst gives them, added +/// with ids that run opposite to their names. +fn batch_with_reversed_ids(c: &mut Catalog) -> Vec { + let names = ["DSC_0001.ARW", "DSC_0002.ARW", "DSC_0003.ARW", "DSC_0004.ARW"]; + let mut ids = Vec::new(); + for (n, name) in names.iter().enumerate() { + let id = PhotoId(1000 - n as u64); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 10, 10, "2026-10-10T09:00:00"); + p.captured = Some("2026-10-01T12:00:00".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + ids // in name order +} + +#[test] +fn one_import_batch_sorts_by_file_name_not_id() { + let mut c = Catalog::new(); + let by_name = batch_with_reversed_ids(&mut c); + let sort = Sort { key: SortKey::ImportDate, ascending: true, ..Default::default() }; + assert_eq!(c.query(&Filter::default(), &sort), by_name); +} + +#[test] +fn burst_in_one_second_sorts_by_file_name_not_id() { + let mut c = Catalog::new(); + let by_name = batch_with_reversed_ids(&mut c); + let sort = Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() }; + assert_eq!(c.query(&Filter::default(), &sort), by_name); +} + +#[test] +fn cmp_name_ignores_case_without_allocating() { + use crate::query::cmp_name; + use std::cmp::Ordering::*; + assert_eq!(cmp_name("IMG_1.JPG", "img_1.jpg"), Equal); + assert_eq!(cmp_name("Ärger.jpg", "ärger.jpg"), Equal); + assert_eq!(cmp_name("a", "B"), Less); + assert_eq!(cmp_name("B", "a"), Greater); + assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less); + assert_eq!(cmp_name("a", "ab"), Less); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..a024eb02d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -881,11 +881,17 @@ impl Session { } } if self.source == LibrarySource::RecentlyAdded { - // newest import first, whatever the sort (the grid groups by import day) + // newest import first, whatever the sort direction (the grid groups by import day); + // a batch stays in file order (name, then id) let cat = &self.catalog; visible.sort_by(|a, b| { let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default(); - key(b).cmp(&key(a)).then(a.cmp(b)) + let name = |id: &PhotoId| cat.photo(*id).map(|p| p.file_name.as_str()); + let by_name = match (name(a), name(b)) { + (Some(x), Some(y)) => lightcraft_catalog::query::cmp_name(x, y), + _ => std::cmp::Ordering::Equal, + }; + key(b).cmp(&key(a)).then(by_name).then(a.cmp(b)) }); } if self.source == LibrarySource::Missing { diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 185993494..bdebb3cb1 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -309,6 +309,27 @@ fn recently_added_covers_recent_imports_newest_first() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn recently_added_keeps_an_import_batch_in_file_order() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut s = Session::new(); + // one import batch: one `imported` string, ids running opposite to the names + let mut by_name = Vec::new(); + for (n, name) in ["IMG_0001.png", "IMG_0002.png", "IMG_0003.png", "IMG_0004.png"].iter().enumerate() { + let id = PhotoId(5000 - n as u64); + let p = Photo::new(id, Source::File { path: name.to_string() }, name, "PNG", 16, 12, "2026-09-30T09:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + by_name.push(name.to_string()); + } + s.clock = Box::new(|| "2026-10-01T09:00:00".to_string()); + for ascending in [true, false] { + s.execute("library.sort", &serde_json::json!({"key": "captureDate", "ascending": ascending})).unwrap(); + s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap(); + let names: Vec = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect(); + assert_eq!(names, by_name, "ascending: {ascending}"); + } +} + /// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in /// import order), and a metadata preset on every photo. #[test] diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..cfb2d9ec2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -46,6 +46,11 @@ in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade installs to Program Files. +### Library sorting +- Photos that tie on the sort key (equal ratings, one import batch, a burst shot within the same second) now sort by + file name, then id, instead of import order. Recently Added keeps a batch in file name order, and Auto-Stack orders a + burst by file name, so the first file is the cover. + ## 2026-10-10 ### Dialogs fit the window From dd1a857d49b24724a4443e6adae7d19574b51b79 Mon Sep 17 00:00:00 2001 From: Alfons Hoogervorst <92640+snofla@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:26:54 +0200 Subject: [PATCH 014/142] Issue #989: the ten sidecar repair tests also pass on a case-insensitive volume (item 3, ten of eleven) The tests were written for a case-sensitive disk. rename.rs sidecars() offers both .XMP and .xmp per naming and keeps those that exist; on case-insensitive APFS a.XMP "exists" for a file stored as a.xmp, so the code renames a.XMP while the fault injectors (Faults twice, PartialRename) compared exact paths with the lowercase one: the injected failure never fired and the rename the tests expect to be refused succeeded. Fault injectors now match by same_path_or_file (equal path, or the same file by identity); two assertions that compared an exact spelling use same_path_for_volume (equal, or equal ignoring ASCII case on a case-insensitive volume), and one that required the lowercase packet to be absent is skipped there, where both spellings are one file. Helpers live in tests_xmp.rs. Production code is not touched. Run with rustc 1.99.0 on macOS: case-insensitive APFS, the ten failed before and pass now; case-sensitive APFS (a disk image), the original tests pass and so do these. All 13 sidecar::tests_repair tests pass on both. cargo fmt is clean and clippy over lightcraft-engine with --all-targets is clean. Not touched: the .xmp -> .XMP spelling change on rename, and the Lightroom folder test (its own PR). Co-Authored-By: Claude Sonnet 5.5 --- .../src/sidecar/tests_repair_companions.rs | 7 ++++--- .../src/sidecar/tests_repair_provenance.rs | 3 ++- .../engine/src/sidecar/tests_repair_saves.rs | 8 +++++--- crates/engine/src/tests_xmp.rs | 20 +++++++++++++++++++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/sidecar/tests_repair_companions.rs b/crates/engine/src/sidecar/tests_repair_companions.rs index cefec46e8..94c7996a7 100644 --- a/crates/engine/src/sidecar/tests_repair_companions.rs +++ b/crates/engine/src/sidecar/tests_repair_companions.rs @@ -1,6 +1,7 @@ //! Real Session partial moves, sidecar serialization/merging, AutoWrite and history. use super::*; use crate::rename::{MoveFs, RealFs}; +use crate::tests_xmp::{same_path_for_volume, same_path_or_file}; use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -16,7 +17,7 @@ impl MoveFs for Faults { RealFs.same_file(a, b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.0.iter().any(|p| p == a) { + if self.0.iter().any(|p| same_path_or_file(p, a)) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move")) } else { RealFs.rename_no_replace(a, b) @@ -261,8 +262,8 @@ fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() { assert!(!second_packet.exists()); let pending = queues(&s); assert_eq!(pending.len(), 1); - assert_eq!(pending[0].from, shared); - assert_eq!(pending[0].to, second_packet); + assert!(same_path_for_volume(&pending[0].from, &shared), "{:?}", pending[0].from); + assert!(same_path_for_volume(&pending[0].to, &second_packet), "{:?}", pending[0].to); let catalog_before = s.catalog.to_snapshot(); let history_before = (s.undo.len(), s.redo.len(), s.commits()); diff --git a/crates/engine/src/sidecar/tests_repair_provenance.rs b/crates/engine/src/sidecar/tests_repair_provenance.rs index 63ff42002..86f7d1dd6 100644 --- a/crates/engine/src/sidecar/tests_repair_provenance.rs +++ b/crates/engine/src/sidecar/tests_repair_provenance.rs @@ -1,6 +1,7 @@ //! Native files and genuine Session entry points: packet ownership survives partial renames. use super::*; use crate::rename::{MoveFs, RealFs}; +use crate::tests_xmp::same_path_or_file; use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -16,7 +17,7 @@ impl MoveFs for Faults { RealFs.same_file(a, b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.0.iter().any(|p| p == a) { + if self.0.iter().any(|p| same_path_or_file(p, a)) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move")) } else { RealFs.rename_no_replace(a, b) diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs index 6f9fa4aa4..6fd3c2e9f 100644 --- a/crates/engine/src/sidecar/tests_repair_saves.rs +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -1,6 +1,7 @@ //! Real Session/serializer regressions for saves that overlap retained physical repairs. use super::*; use crate::rename::{MoveFs, RealFs}; +use crate::tests_xmp::{case_insensitive, same_path_or_file}; use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -19,14 +20,14 @@ impl MoveFs for PartialRename { RealFs.same_file(a, b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if a == self.sidecar || a == self.photo { + if same_path_or_file(a, &self.sidecar) || same_path_or_file(a, &self.photo) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault")) } else { RealFs.rename_no_replace(a, b) } } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if a == self.sidecar { + if same_path_or_file(a, &self.sidecar) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault")) } else { RealFs.copy_no_replace(a, b) @@ -150,7 +151,8 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw assert_eq!(parsed.rating, Some(4)); assert_eq!(parsed.title.as_deref(), Some("Final title")); assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); - if upper { + // (on a case-insensitive volume the two spellings are one file) + if upper && !case_insensitive(&root) { assert!(!preferred.exists(), "merge uses the repaired uppercase packet"); } for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..4e7a0b326 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -7,6 +7,26 @@ use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; use serde_json::json; use crate::Session; +use crate::rename::{MoveFs, RealFs}; + +/// Whether `dir` is on a case-insensitive volume (the default on macOS and Windows): its path in upper case still resolves. +pub(crate) fn case_insensitive(dir: &Path) -> bool { + Path::new(&dir.to_string_lossy().to_uppercase()).exists() +} + +/// The same path, or the same file under another spelling (`a.XMP` and `a.xmp` on a case-insensitive volume). Fault +/// injectors match this way, so an injected failure fires whichever spelling the code under test uses for a file; on a +/// case-sensitive volume only identical paths match. +pub(crate) fn same_path_or_file(a: &Path, b: &Path) -> bool { + a == b || RealFs.same_file(a, b) +} + +/// The same path, or on a case-insensitive volume a path that differs only in ASCII case: the code under test may spell +/// a sidecar `.XMP` for a file stored as `.xmp` there, while on a case-sensitive volume it offers only the spelling that +/// exists. +pub(crate) fn same_path_for_volume(a: &Path, b: &Path) -> bool { + a == b || (a.parent().is_some_and(case_insensitive) && a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) +} pub(crate) fn temp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id())); From 0e53cfc2408de9ae64ae84d01a6dd61eb5241e3e Mon Sep 17 00:00:00 2001 From: Alfons Hoogervorst <92640+snofla@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:42:25 +0200 Subject: [PATCH 015/142] Issue #989: the Export dialog height test expects the macOS row (Add to Apple Photos, 876 pt) export_dialog_is_unchanged_in_a_tall_window expected 846 pt on every platform. a6431c15 (add exported files to Apple Photos, #236) put a macOS-only checkbox at the end of the dialog, so on macOS it is 876 pt tall at 1600x1000 (measured: [[492 62] - [1107.2 938]]). The test failed on macOS on upstream main (76b53778) with no other change; it is unchanged on other platforms. Run on macOS (rustc 1.99.0): the five tests in tests_dialog_fit pass, cargo fmt is clean, clippy over lightcraft-ui-egui with --all-targets is clean. Not run on Linux or FreeBSD, where the expectation stays 846. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/tests_dialog_fit.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 895a1d864..0014c6bdb 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -94,8 +94,10 @@ fn export_dialog_is_unchanged_in_a_tall_window() { assert_fits(&h, "Export at 1600×1000"); let dialog = rect(&h, "dialog:window"); // (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the - // format row gained JPEG XL, #780) - assert!((dialog.height() - 846.0).abs() <= 1.0, "{dialog:?}"); + // format row gained JPEG XL, #780. On macOS the dialog has one more row, "Add to Apple Photos" + // (#236), so it is 876 tall there) + let height = if cfg!(target_os = "macos") { 876.0 } else { 846.0 }; + assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } From 48c61e80c163de578cadb526c20f37ac780ae9fb Mon Sep 17 00:00:00 2001 From: Robert Woldberg <314159+rwoldberg@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:17:23 -0600 Subject: [PATCH 016/142] Update progress bar, so large lightroom catalog impots do not appear hung --- crates/engine/src/files.rs | 13 ++- crates/engine/src/import.rs | 81 ++++++++++++-- crates/engine/src/lightroom_job.rs | 141 +++++++++++++++++++++---- crates/ui-egui/src/lightroom_import.rs | 71 ++++++++----- 4 files changed, 247 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index db762f009..933de616a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -482,9 +482,16 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp /// Oriented size of the embedded preview of a raw file. fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { let jpeg = stand_in_image(bytes)?; - let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; - let (mut w, mut h) = (d.source_width, d.source_height); - if preview_orientation(bytes, d.orientation).swaps_axes() { + // headers only, as for other images (issue #367): decoding a full-size preview just for its + // size took about a second per file in an import; a decode remains the fallback + let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) { + Ok(header) => (header.width, header.height, header.orientation), + Err(_) => { + let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; + (d.source_width, d.source_height, d.orientation) + } + }; + if preview_orientation(bytes, orientation).swaps_axes() { std::mem::swap(&mut w, &mut h); } Some((w, h)) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 833d92e8e..8a49ae8ba 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -412,10 +412,18 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp /// Probe files (headers + content hash) as an import would. pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { - probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default()) + let progress = ScanProgress::default(); + probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) } -fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec> { +/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once +/// `cancel` is set: the files not reached come back as errors. +fn probe_all( + probe: Option<&crate::media::FileProbe>, + paths: &[String], + done: &std::sync::atomic::AtomicUsize, + cancel: &std::sync::atomic::AtomicBool, +) -> Vec> { use std::sync::atomic::Ordering::Relaxed; let Some(probe) = probe.cloned() else { return paths @@ -443,11 +451,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress if i >= paths.len() { break; } - if progress.cancel.load(Relaxed) { + if cancel.load(Relaxed) { break; } let r = probe(&paths[i]); - progress.done.fetch_add(1, Relaxed); + done.fetch_add(1, Relaxed); out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); } }); @@ -459,11 +467,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress paths .iter() .map(|p| { - if progress.cancel.load(Relaxed) { + if cancel.load(Relaxed) { return Err("cancelled".into()); } let r = probe(p); - progress.done.fetch_add(1, Relaxed); + done.fetch_add(1, Relaxed); r }) .collect() @@ -565,7 +573,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress .collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); progress.done.store(todo.len() - missing.len(), Relaxed); - let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter(); + let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); @@ -650,6 +658,10 @@ pub struct ImportJob { /// Files found so far (folders expanded) and handled so far, for progress. pub total: std::sync::Arc, pub done: std::sync::Arc, + /// Of those, the files that have to be read first (not probed by a preceding scan) and the + /// ones read so far: the step before they are handled. + pub read_total: std::sync::Arc, + pub read_done: std::sync::Arc, } /// The outcome of [`ImportJob::prepare`] for one batch, in file order. @@ -802,6 +814,8 @@ impl ImportJob { now: (s.clock)(), total: Default::default(), done: Default::default(), + read_total: Default::default(), + read_done: Default::default(), }) } @@ -853,8 +867,10 @@ impl ImportJob { // files a preceding scan already probed are not read again (a network share is slow to read) let cached: Vec> = todo.iter().map(|f| self.cache.remove(f)).collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); - let progress = ScanProgress::default(); - let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter(); + // Reading the files is the slow part of adding them where they are: it is counted on its + // own, and `cancel` stops it too (a large library takes long enough to want both). + self.read_total.fetch_add(missing.len(), Relaxed); + let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter(); let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); @@ -1412,4 +1428,51 @@ mod prepared_tests { assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); } + + fn unread_paths(n: usize) -> Vec { + (0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect() + } + + /// Reading the files of a large import (a Lightroom catalog's originals) reported nothing: + /// the progress stood still at "14 of 24554" for as long as it took. + #[test] + fn prepare_counts_the_files_it_reads() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (done, last) = (job.read_done.clone(), done_in_probe.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + last.fetch_max(done.load(Relaxed), Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false)); + assert_eq!(prepared.items.len(), 40); + assert_eq!(seen.load(Relaxed), 40); + assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read"); + assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted"); + assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own"); + } + + /// Cancel had no effect until every file had been read. + #[test] + fn cancel_stops_reading_files() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + cancel_in_probe.store(true, Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(400), &cancel); + // at most the files the (up to 8) readers had already started + assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); + assert!(prepared.items.is_empty()); + } } diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index fd939341d..9a0658b48 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -129,6 +129,65 @@ pub struct LightroomCompletion { /// Alias used by hosts that call the result a commit. pub type LightroomCommit = LightroomCompletion; +/// The step of preparing a Lightroom import that is under way ([`LightroomProgress::step`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LightroomStep { + /// Reading the catalog's tables. + Catalog, + /// Reading the photos the catalog names (headers, content hash): nearly all of the time. + ReadPhotos, + /// Readying the photos that were read for the catalog. + AddPhotos, +} + +/// Progress of [`LightroomJob::prepare`] step by step, shared with the thread that runs it. +#[derive(Clone, Default)] +pub struct LightroomProgress { + /// Catalog tables to read and read so far ([`lightroom_catalog::read_with_progress`]). + pub catalog_total: Arc, + pub catalog_done: Arc, + read_total: Arc, + read_done: Arc, + add_total: Arc, + add_done: Arc, + /// The catalog is read: the photos are next. + photos: Arc, +} + +impl LightroomProgress { + /// Reading the photos is nearly all of an import's time; the catalog and readying the photos + /// take the rest of [`LightroomProgress::fraction`]. + const CATALOG_SHARE: f32 = 0.05; + const READ_SHARE: f32 = 0.85; + + /// The step under way, with how much of it is done and its total (0 while not yet known). + pub fn step(&self) -> (LightroomStep, usize, usize) { + let load = |n: &AtomicUsize| n.load(Ordering::Relaxed); + if !self.photos.load(Ordering::Relaxed) { + return (LightroomStep::Catalog, load(&self.catalog_done), load(&self.catalog_total)); + } + let (read_done, read_total) = (load(&self.read_done), load(&self.read_total)); + let (add_done, add_total) = (load(&self.add_done), load(&self.add_total)); + // nothing to read (every photo is already in the library) goes straight to adding + if read_done < read_total || (read_total == 0 && add_done == 0) { + (LightroomStep::ReadPhotos, read_done, read_total) + } else { + (LightroomStep::AddPhotos, add_done, add_total) + } + } + + /// The whole preparation, 0..=1. + pub fn fraction(&self) -> f32 { + let (step, done, total) = self.step(); + let part = if total == 0 { 0.0 } else { (done as f32 / total as f32).clamp(0.0, 1.0) }; + match step { + LightroomStep::Catalog => Self::CATALOG_SHARE * part, + LightroomStep::ReadPhotos => Self::CATALOG_SHARE + Self::READ_SHARE * part, + LightroomStep::AddPhotos => Self::CATALOG_SHARE + Self::READ_SHARE + (1.0 - Self::CATALOG_SHARE - Self::READ_SHARE) * part, + } + } +} + /// A Lightroom import detached from the owner thread. pub struct LightroomJob { path: PathBuf, @@ -136,6 +195,7 @@ pub struct LightroomJob { import: ImportJob, token: LightroomLibraryToken, archive_dir: Option, + progress: LightroomProgress, } impl LightroomJob { @@ -144,33 +204,25 @@ impl LightroomJob { let opts = ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }; let import = ImportJob::new(s, opts)?; let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop")); - Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir }) + let progress = LightroomProgress { + read_total: Arc::clone(&import.read_total), + read_done: Arc::clone(&import.read_done), + add_total: Arc::clone(&import.total), + add_done: Arc::clone(&import.done), + ..LightroomProgress::default() + }; + Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir, progress }) } - pub fn total(&self) -> usize { - self.import.total.load(Ordering::Relaxed) - } - - pub fn done(&self) -> usize { - self.import.done.load(Ordering::Relaxed) - } - - pub fn progress(&self) -> (usize, usize) { - (self.done(), self.total()) + /// The job's progress, to watch from another thread while [`LightroomJob::prepare`] runs. + pub fn progress(&self) -> LightroomProgress { + self.progress.clone() } pub fn token(&self) -> &LightroomLibraryToken { &self.token } - pub fn total_atomic(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.import.total) - } - - pub fn done_atomic(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.import.done) - } - /// Finish an owner commit's deferred archive-index write on a worker. pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> { finalization.finish() @@ -181,14 +233,12 @@ impl LightroomJob { if cancel.load(Ordering::Relaxed) { return Err(error("Lightroom import cancelled")); } - self.import.total.store(1, Ordering::Relaxed); - self.import.done.store(0, Ordering::Relaxed); - let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?; + let mut data = + lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?; lightroom_catalog::validate_for_job(&data).map_err(error)?; if cancel.load(Ordering::Relaxed) { return Err(error("Lightroom import cancelled")); } - self.import.done.fetch_add(1, Ordering::Relaxed); let paths: Vec = data .photos .iter() @@ -202,6 +252,9 @@ impl LightroomJob { .filter(|photo| !Path::new(&photo.path).is_file()) .map(|photo| normalize(&photo.path)) .collect(); + // the catalog step's last unit (validation, and looking for each original), then the photos + self.progress.catalog_done.fetch_add(1, Ordering::Relaxed); + self.progress.photos.store(true, Ordering::Relaxed); let normal = self.import.prepare(&paths, cancel); if cancel.load(Ordering::Relaxed) { normal.rollback(); @@ -334,6 +387,48 @@ mod tests { } } + /// One count for the whole import read "14 of 24554" while the photos were read, then twice + /// the number of photos: each step has its own count, and the whole only moves forward. + #[test] + fn progress_names_each_step_with_its_own_count() { + let p = LightroomProgress::default(); + let set = |n: &AtomicUsize, v: usize| n.store(v, Ordering::Relaxed); + assert_eq!(p.step(), (LightroomStep::Catalog, 0, 0)); + assert_eq!(p.fraction(), 0.0); + set(&p.catalog_total, 14); + set(&p.catalog_done, 7); + assert_eq!(p.step(), (LightroomStep::Catalog, 7, 14)); + let mut last = p.fraction(); + assert!(last > 0.0); + let mut forward = |p: &LightroomProgress| { + let f = p.fraction(); + assert!(f >= last && f <= 1.0, "{f} after {last}"); + last = f; + }; + set(&p.catalog_done, 14); + p.photos.store(true, Ordering::Relaxed); + assert_eq!(p.step(), (LightroomStep::ReadPhotos, 0, 0), "the photos are not counted yet"); + forward(&p); + set(&p.add_total, 100); + set(&p.read_total, 100); + set(&p.read_done, 40); + assert_eq!(p.step(), (LightroomStep::ReadPhotos, 40, 100)); + forward(&p); + set(&p.read_done, 100); + set(&p.add_done, 25); + assert_eq!(p.step(), (LightroomStep::AddPhotos, 25, 100)); + forward(&p); + set(&p.add_done, 100); + forward(&p); + assert!(p.fraction() > 0.999); + // every photo already in the library: nothing to read + let known = LightroomProgress::default(); + known.photos.store(true, Ordering::Relaxed); + set(&known.add_total, 5); + set(&known.add_done, 5); + assert_eq!(known.step(), (LightroomStep::AddPhotos, 5, 5)); + } + #[test] fn cancellation_before_commit_leaves_owner_catalog_unchanged() { let mut session = crate::Session::new(); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 8cee63e39..774e92d8b 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -5,10 +5,11 @@ //! data only on the owner thread, then finishes the archive index on a worker. use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, mpsc}; use egui::Align2; +use lightcraft_engine::lightroom_job::{LightroomProgress, LightroomStep}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -39,8 +40,7 @@ pub struct LightroomTask { kind: Kind, path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, rx: mpsc::Receiver, phase: Phase, cancelled: bool, @@ -58,13 +58,21 @@ impl LightroomTask { Phase::Committing => "committing", Phase::Finalizing => "finalizing", }; + let (step, done, total) = self.progress.step(); + let step = match step { + LightroomStep::Catalog => "catalog", + LightroomStep::ReadPhotos => "readPhotos", + LightroomStep::AddPhotos => "addPhotos", + }; json!({ "running": true, "kind": kind, "path": self.path, "phase": phase, - "done": self.done.load(Ordering::Relaxed), - "total": self.total.load(Ordering::Relaxed), + "step": step, + "done": done, + "total": total, + "fraction": self.progress.fraction(), "cancelled": self.cancelled || self.cancel.load(Ordering::Relaxed), }) } @@ -106,8 +114,7 @@ fn busy(app: &LightcraftApp) -> Result<(), String> { fn spawn_inspect( path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, tx: mpsc::Sender, ctx: egui::Context, ) -> Result<(), String> { @@ -115,7 +122,7 @@ fn spawn_inspect( .name("lc-lightroom-inspect".into()) .spawn(move || { let result = lightcraft_engine::guard::catch("Lightroom inspect", || { - lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &total, &done) + lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &progress.catalog_total, &progress.catalog_done) }) .and_then(|r| r.map(inspect_report)); let _ = tx.send(Message::Inspected(result)); @@ -231,12 +238,11 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, kind: Kind, value: Val fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result { busy(app)?; let cancel = Arc::new(AtomicBool::new(false)); - let total = Arc::new(AtomicUsize::new(0)); - let done = Arc::new(AtomicUsize::new(0)); + let progress = LightroomProgress::default(); let (tx, rx) = mpsc::channel(); - spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?; + spawn_inspect(path.clone(), cancel.clone(), progress.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, progress, rx, phase: Phase::Reading, cancelled: false }); Ok(json!({"running": true, "kind": "inspect", "path": path})) } @@ -245,12 +251,11 @@ fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, c busy(app)?; let job = lightcraft_engine::lightroom_job::LightroomJob::new(&mut app.session, path.clone(), update_existing).map_err(|e| e.to_string())?; let cancel = Arc::new(AtomicBool::new(false)); - let total = job.total_atomic(); - let done = job.done_atomic(); + let progress = job.progress(); let (tx, rx) = mpsc::channel(); spawn_prepare(job, cancel.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, progress, rx, phase: Phase::Reading, cancelled: false }); Ok(json!({"running": true, "kind": "import", "path": path})) } @@ -412,25 +417,43 @@ pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::t /// The progress window for inspect/import, with cancellation. pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = app.lightroom.as_ref() else { return }; - let total = task.total.load(Ordering::Relaxed); - let done = task.done.load(Ordering::Relaxed); + let (step, done, total) = task.progress.step(); let title = match task.kind { Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"), Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"), }; - let text = match task.phase { - Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(), - Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total), - Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(), - Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(), + // The step under way, with its own count: reading the photos takes nearly all of an import. + let text = match (task.phase, step) { + (Phase::Reading, LightroomStep::Catalog) if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(), + (Phase::Reading, LightroomStep::Catalog) => { + crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total) + } + (Phase::Reading, LightroomStep::ReadPhotos) => crate::i18n::tr_format!("Reading photos… {} of {}", done, total), + (Phase::Reading, LightroomStep::AddPhotos) => crate::i18n::tr_format!("Adding photos… {} of {}", done, total), + (Phase::Committing, _) => crate::i18n::tr("Adding Lightroom photos…").into(), + (Phase::Finalizing, _) => crate::i18n::tr("Saving Lightroom import index…").into(), + }; + // Two bars: the whole task above, the step named by `text` below. Inspecting is one step. + let reading = task.phase == Phase::Reading; + let step_fraction = if reading { done as f32 / total.max(1) as f32 } else { 1.0 }; + let whole = match task.kind { + Kind::Import if reading => Some(task.progress.fraction()), + Kind::Import => Some(1.0), + Kind::Inspect => None, }; let mut cancel = false; let t = Tokens::get(ctx); - egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.0]).show( + let height = if whole.is_some() { 140.0 } else { 92.0 }; + egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, height]).show( ctx, |ui| { + if let Some(whole) = whole { + ui.label(egui::RichText::new(title).color(t.text).strong()); + ui.add(egui::ProgressBar::new(whole).desired_width(340.0).show_percentage()); + ui.add_space(4.0); + } ui.label(egui::RichText::new(text).color(t.text)); - ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0)); + ui.add(egui::ProgressBar::new(step_fraction).desired_width(340.0)); let r = ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel"))); register(ui.ctx(), "button:lightroomCancel", r.rect); From 0a6f8d3db86f2ccc3e4165fe0cbb76efa993de2d Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 01:01:39 +1100 Subject: [PATCH 017/142] LR-HDR-EXPORT: HLG HDR AVIF export (hdrTransfer) and a tested SDR/HDR display decision AVIF HDR export takes hdrTransfer = pq | hlg (export dialog: HDR curve; MCP export schema lists it). HLG writes CICP transfer 18 and no clli box. The window logs whether it shows HDR; today always SDR (eframe configures an 8-bit surface), with the platform / surface format / headroom / opt-out rules unit-tested. Also satisfies clippy 1.99's chunks_exact_to_as_chunks in spots.rs. Part of #868. Sources: ITU-R BT.2100 (HLG OETF, inverse OOTF with system gamma 1.2), ITU-R BT.2408 (203 cd/m2 reference white at 75 % HLG); own design for the display decision. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/lightcraft/src/hdr_display.rs | 174 +++++++++++++++++++++++++++ apps/lightcraft/src/main.rs | 3 + crates/codecs/README.md | 2 +- crates/codecs/src/encode.rs | 94 +++++++++++++-- crates/codecs/src/lib.rs | 4 +- crates/engine/src/export.rs | 34 +++++- crates/engine/src/tests_export.rs | 15 +++ crates/mcp/src/tools.rs | 2 + crates/pipeline/src/spots.rs | 4 +- crates/ui-egui/src/panels/dialogs.rs | 7 +- docs/gaps.md | 5 +- docs/parity-checklist.md | 9 +- 12 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 apps/lightcraft/src/hdr_display.rs diff --git a/apps/lightcraft/src/hdr_display.rs b/apps/lightcraft/src/hdr_display.rs new file mode 100644 index 000000000..2983e9a21 --- /dev/null +++ b/apps/lightcraft/src/hdr_display.rs @@ -0,0 +1,174 @@ +//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR +//! white, and with which surface format. Pure decisions, unit-tested here; the window always +//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong. +//! +//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer, +//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol), +//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal +//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the +//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the +//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same +//! decision turns HDR on. + +use eframe::wgpu::TextureFormat; + +/// Windowing platform, as far as HDR output is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + MacOs, + Windows, + Wayland, + X11, + Web, + Other, +} + +impl Platform { + /// The platform this build runs on (`wayland` from the session environment on Linux / BSD). + pub fn current() -> Self { + if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_arch = "wasm32") { + Self::Web + } else if std::env::var_os("WAYLAND_DISPLAY").is_some() { + Self::Wayland + } else if std::env::var_os("DISPLAY").is_some() { + Self::X11 + } else { + Self::Other + } + } + + /// Whether the platform has an HDR compositing path wgpu can reach at all. + fn has_hdr_path(self) -> bool { + matches!(self, Self::MacOs | Self::Windows | Self::Wayland) + } +} + +/// How HDR values reach the display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HdrEncoding { + /// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB, + /// Wayland extended linear). + ExtendedLinear, + /// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ). + Pq10, +} + +/// The display output decision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisplayOutput { + Sdr { reason: &'static str }, + Hdr { format: TextureFormat, encoding: HdrEncoding }, +} + +impl DisplayOutput { + #[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR + pub fn is_hdr(self) -> bool { + matches!(self, Self::Hdr { .. }) + } +} + +/// The encoding a surface format carries on `platform`, if it is an HDR one. +fn encoding_of(format: TextureFormat, platform: Platform) -> Option { + match format { + TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear), + // macOS takes HDR through EDR floats only + TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10), + _ => None, + } +} + +/// The best HDR surface format among those a surface offers (`available`), for `platform`: +/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only. +/// (For configuring the surface once eframe lets the app pick its format.) +#[cfg_attr(not(test), allow(dead_code))] +pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option { + if !platform.has_hdr_path() { + return None; + } + [TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some()) +} + +/// The decision for the window: `target` is the format the renderer configured (`None`: the glow +/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the +/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS), +/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`). +pub fn choose(target: Option, platform: Platform, display_headroom: Option, disabled: bool) -> DisplayOutput { + if disabled { + return DisplayOutput::Sdr { reason: "HDR display is switched off" }; + } + if !platform.has_hdr_path() { + return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" }; + } + let Some(format) = target else { + return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" }; + }; + let Some(encoding) = encoding_of(format, platform) else { + return DisplayOutput::Sdr { reason: "the window surface is 8-bit" }; + }; + match display_headroom { + Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding }, + Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" }, + None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" }, + } +} + +/// The decision for this start, logged once (the window renderer's `target` format). +pub fn report(target: Option) -> DisplayOutput { + let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")); + // no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready + let out = choose(target, Platform::current(), None, disabled); + match out { + DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"), + DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"), + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use TextureFormat as F; + + #[test] + fn eight_bit_surfaces_are_sdr() { + for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] { + for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] { + assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}"); + } + } + } + + #[test] + fn hdr_needs_platform_format_and_headroom() { + let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear }; + assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr); + assert_eq!( + choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false), + DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 } + ); + // any missing condition falls back to SDR + assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr()); + assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr()); + // macOS has no PQ swap chain path + assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr()); + } + + #[test] + fn preferred_format_picks_float_then_pq() { + let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float]; + assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float)); + assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm)); + assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None); + assert_eq!(preferred_hdr_format(&all, Platform::X11), None); + assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index cc71cc06d..9067f7e28 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -27,6 +27,7 @@ mod appearance; mod control_server; mod dialog_filter; mod editors; +mod hdr_display; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -833,6 +834,8 @@ fn main() -> eframe::Result { // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) session = session.with_default_sam3_model(); + // HDR display: SDR unless the surface, platform and display all allow HDR + hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format)); let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref())); if let Some(ui) = prefs { app.ui = ui; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 4f9566b51..069a1886a 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -53,7 +53,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d | GIF / BMP | yes (first GIF frame) | — | via `image` | | PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported | | JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes | -| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) | +| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_hdr` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) or HLG (`hlg_signal`, SDR white at 0.75) | | HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported | | Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails | diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 6333a9c8d..725980e53 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -394,11 +394,41 @@ pub fn pq_decode(e: f32) -> f32 { ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0 } +/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HdrTransfer { + #[default] + Pq, + Hlg, +} + +/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408). +pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0; + +/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100: +/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma +/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75, +/// as BT.2408 asks. Non-finite or negative inputs count as 0. +pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] { + const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; + const GAMMA: f32 = 1.2; + let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS)); + let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0); + let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 }; + fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32) +} + +/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]). +pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { + encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq) +} + /// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0 /// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the -/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box. +/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level +/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display). /// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only. -pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { +pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> Result> { let (w, h) = (width as usize, height as usize); let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?; if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) { @@ -420,7 +450,10 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]; max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2])); sum_nits += y_nits as f64; - let [r, g, b] = nits.map(pq_encode); + let [r, g, b] = match transfer { + HdrTransfer::Pq => nits.map(pq_encode), + HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]), + }; let y = K[0] * r + K[1] * g + K[2] * b; let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5; let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5; @@ -446,11 +479,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: ec.pixel_range = PixelRange::Full; ec.color_description = Some(ColorDescription { color_primaries: ColorPrimaries::BT2020, - transfer_characteristics: TransferCharacteristics::SMPTE2084, + transfer_characteristics: match transfer { + HdrTransfer::Pq => TransferCharacteristics::SMPTE2084, + HdrTransfer::Hlg => TransferCharacteristics::HLG, + }, matrix_coefficients: MatrixCoefficients::BT2020NCL, }); let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16); - ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 }); + ec.content_light = + (transfer == HdrTransfer::Pq).then_some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 }); ec.still_picture = true; ec.quantizer = quantizer; ec.min_quantizer = quantizer.min(255) as u8; @@ -488,10 +525,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: use avif_serialize::constants as c; let mut a = avif_serialize::Aviffy::new(); a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl) - .set_transfer_characteristics(c::TransferCharacteristics::Smpte2084) + .set_transfer_characteristics(match transfer { + HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084, + HdrTransfer::Hlg => c::TransferCharacteristics::Hlg, + }) .set_color_primaries(c::ColorPrimaries::Bt2020) - .set_full_color_range(true) - .set_content_light_level(cll.0, cll.1); + .set_full_color_range(true); + if transfer == HdrTransfer::Pq { + a.set_content_light_level(cll.0, cll.1); + } if let Some(exif) = meta.exif { a.set_exif(exif.to_vec()); } @@ -499,7 +541,7 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: } #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] { - let _ = (quality, speed, meta); + let _ = (quality, speed, meta, transfer); Err(Error::Encode("AVIF encoding is not available in this build".into())) } } @@ -586,3 +628,37 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result= prev); + prev = v; + } + } + + #[test] + fn hdr_avif_rejects_short_buffers() { + let m = EncodeMeta::default(); + assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err()); + assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err()); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334a..4e5e4070c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -39,8 +39,8 @@ mod tiff_codec; mod webp; pub use encode::{ - ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, - encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, + ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression, + encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal, }; pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl}; pub use sniff::{Format, sniff}; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..67ed60347 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -623,6 +623,32 @@ pub enum MetadataPolicy { None, } +/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or +/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HdrCurve { + #[default] + Pq, + Hlg, +} + +impl HdrCurve { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "pq" => Some(Self::Pq), + "hlg" => Some(Self::Hlg), + _ => None, + } + } + fn codec(self) -> lightcraft_codecs::HdrTransfer { + match self { + Self::Pq => lightcraft_codecs::HdrTransfer::Pq, + Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ExportOptions { @@ -681,6 +707,8 @@ pub struct ExportOptions { pub add_to_photos: bool, /// The Photos album they go into, made when missing (empty = none). pub photos_album: String, + /// The curve of an HDR AVIF: PQ (default) or HLG. + pub hdr_transfer: HdrCurve, } impl Default for ExportOptions { @@ -710,6 +738,7 @@ impl Default for ExportOptions { jxl_progressive: false, add_to_photos: false, photos_album: String::new(), + hdr_transfer: HdrCurve::Pq, } } } @@ -751,6 +780,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "jxlProgressive", "addToPhotos", "photosAlbum", + "hdrTransfer", ]; /// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the @@ -923,6 +953,7 @@ impl ExportOptions { } "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, + "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, @@ -1061,6 +1092,7 @@ impl ExportOptions { jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive), add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos), photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()), + hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer), } } @@ -1450,7 +1482,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { - lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) + lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec()) } (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), (ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => { diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index b57bd471b..592f7349d 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -455,6 +455,21 @@ fn hdr_avif_export_is_pq_rec2020() { assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16)); } +#[test] +fn hdr_avif_export_hlg() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "hdrTransfer": "hlg", "quality": 70})); + assert_eq!(o.hdr_transfer, crate::export::HdrCurve::Hlg); + assert_eq!(o.to_json()["hdrTransfer"], "hlg"); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let hdr = export_photo(&mut s, id, &o, 1).unwrap(); + // nclx: BT.2020 primaries (9), HLG (18), BT.2020 NCL (9), full range; no content light level box + let i = hdr.bytes.windows(4).position(|w| w == b"nclx").expect("colr nclx box"); + assert_eq!(hdr.bytes[i + 4..i + 11].to_vec(), vec![0, 9, 0, 18, 0, 9, 0x80]); + assert!(!hdr.bytes.windows(4).any(|w| w == b"clli")); +} + /// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's /// packet carry no location, and an original that embeds GPS is refused rather than copied. #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..449e5c5e4 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -262,6 +262,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), PSD 8|16 (default 16), AVIF 8|10, JPEG XL 8|16; JPEG/WebP are 8-bit"}, "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, + "hdrTransfer": {"type": "string", "enum": ["pq", "hlg"], "description": "Transfer curve of HDR AVIF output (default pq): pq = SMPTE ST 2084, hlg = ITU-R BT.2100 hybrid log-gamma"}, "addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"}, "photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"}, "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} @@ -743,6 +744,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "colorSpace", "bitDepth", "hdr", + "hdrTransfer", "preset", "background", "resize", diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..ef794316f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -710,10 +710,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if opts.hdr && !opts.hdr_output() { ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); } + if opts.hdr && opts.format == F::Avif { + use lightcraft_engine::export::HdrCurve as H; + choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); + } } if !rendered { } else if opts.format == F::Avif && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim)); + let curve = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { "HLG" } else { "PQ" }; + ui.label(egui::RichText::new(format!("Color space: Rec. 2020 {curve} (HDR AVIF)")).color(t.text_dim)); } else if opts.format == F::Png && opts.hdr { ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..8321c39f3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 14: HLG AVIF export done, HDR display decision tested); previously minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -28,7 +28,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | -| 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | +| 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..2bbbd9474 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-HDR-EXPORT / LR-EXP-HDR: HLG AVIF; LR-VIEW-HDR-DISPLAY decision logic); previously minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -139,7 +139,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram | -| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | +| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet | ## D. Search & filter (FILT) @@ -366,7 +366,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | | LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | | LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) | -| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) | +| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 AVIF with PQ or HLG (`hdrTransfer`, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) | ## R. Video (VID) @@ -390,7 +390,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | -| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | +| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | From a61a52ed8b37880244d1e62a3b3d2fd4c3b71144 Mon Sep 17 00:00:00 2001 From: THEtheChad <880190+THEtheChad@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:08:14 +0000 Subject: [PATCH 018/142] perf(gpu): LR-BEHAV-GPU: horizontal box blur reads adjacent pixels The horizontal box pass gave each thread 16+ consecutive pixels to slide a running sum over, so a workgroup's threads read pixels 16 apart. On a Radeon 780M (DX12) that pass took 9-15x as long as the vertical one, and it sits under every GPU Gaussian and guided filter (NR, texture, dehaze, clarity/base). Each thread now sums one pixel's 2r+1 window: adjacent threads read adjacent pixels. Same 64x4 workgroups, parameters and bindings; box_v unchanged. Measured on the Radeon 780M (6000x4000): guided filter at sigma 1.75 828 -> 146 ms, colour-NR Gaussian 495 -> 104 ms. Full-size heavy-edit export of the CC0 Sony ARW: GPU 3.6-4.0 s -> 1.9-2.0 s (CPU 1.9-2.1 s); GPU-vs-CPU output distance unchanged. Adds gaussian_matches_cpu (GPU blur vs lightcraft_raster::blur::gaussian, 1-3 channels, partial workgroups, windows wider than the image) and per-pass rows to bench_kernels. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/render.rs | 58 ++++++++++++++++++++++++++++++----- crates/gpu/src/wgsl/blur.wgsl | 22 ++++++------- docs/gpu-pipeline.md | 13 ++++++-- docs/whats-new.md | 5 +++ 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350f..acafbdf5d 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -229,10 +229,19 @@ pub(crate) fn rgb_words(img: &Rgb32f) -> &[f32] { // --------------------------------------------------------------------------------------------- // Filters (twins of `lightcraft_raster::blur` / `resample` and `lightcraft_pipeline::local`) -/// Pixels each blur thread slides its running sum over (at least; longer for large radii, so the +/// Rows each `box_v` thread slides its running sum over (at least; longer for large radii, so the /// priming sum stays a small share of the work). const CHUNK: usize = 16; +/// One box pass of radius `r` (clamped edges) over a `w × h` image of `nc` interleaved channels, +/// `src → dst`: horizontal or vertical. +fn box_pass(cx: &mut Cx<'_>, horizontal: bool, src: &Buf, dst: &Buf, (w, h): (usize, usize), nc: usize, r: usize) { + let chunk = (2 * r + 1).clamp(CHUNK, 128); + let p = [w as u32, h as u32, nc as u32, r as u32, chunk as u32]; + let (k, groups) = if horizontal { ("box_h", groups2(w, h, [64, 4])) } else { ("box_v", groups2(w, h.div_ceil(chunk), [64, 4])) }; + cx.run(k, &p, &[Some(src), Some(dst)], groups); +} + /// Gaussian blur of a `w × h` image of `nc` interleaved channels: three box passes each way /// (`lightcraft_raster::blur::gaussian`). pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize, sigma: f32) -> Buf { @@ -240,18 +249,15 @@ pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize return cx.copy(src); } let radii = lightcraft_raster::blur::box_radii(sigma); - let passes: Vec<(&str, usize)> = radii.iter().map(|r| ("box_h", *r)).chain(radii.iter().map(|r| ("box_v", *r))).filter(|p| p.1 > 0).collect(); + let passes: Vec<(bool, usize)> = radii.iter().map(|r| (true, *r)).chain(radii.iter().map(|r| (false, *r))).filter(|p| p.1 > 0).collect(); if passes.is_empty() { return cx.copy(src); } // ping-pong between two buffers let mut bufs = [cx.gpu.buffer(w * h * nc), cx.gpu.buffer(w * h * nc)]; - for (i, (k, r)) in passes.iter().enumerate() { - let chunk = (2 * r + 1).clamp(CHUNK, 128); - let groups = if *k == "box_h" { groups2(w.div_ceil(chunk), h, [64, 4]) } else { groups2(w, h.div_ceil(chunk), [64, 4]) }; - let p = [w as u32, h as u32, nc as u32, *r as u32, chunk as u32]; + for (i, (horizontal, r)) in passes.iter().enumerate() { let (out, prev) = (&bufs[i % 2], &bufs[(i + 1) % 2]); - cx.run(k, &p, &[Some(if i == 0 { src } else { prev }), Some(out)], groups); + box_pass(cx, *horizontal, if i == 0 { src } else { prev }, out, (w, h), nc, *r); } let last = (passes.len() - 1) % 2; let [a, b] = std::mem::replace(&mut bufs, [cx.gpu.buffer(0), cx.gpu.buffer(0)]); @@ -1132,6 +1138,33 @@ mod tests { assert!(covered > 0 && covered < w * h, "{covered} px covered"); } + /// The blur against `lightcraft_raster::blur::gaussian`: widths that do and don't fill the last + /// workgroup, windows wider than the image, and 1, 2 (as two planes) and 3 channels. + #[test] + fn gaussian_matches_cpu() { + use lightcraft_raster::blur; + let Some(gpu) = crate::device() else { return }; + let _scope = crate::ctx::RenderScope::new(gpu); + let v = |x: usize, y: usize, c: usize| ((x * 7 + y * 13 + c * 5) % 17) as f32 / 16.0 + if x % 61 < 3 { 2.0 } else { 0.0 }; + for (w, h) in [(1000, 5), (257, 3), (5, 4), (1, 9)] { + for sigma in [0.8f32, 2.0, 4.5, 30.0, 250.0, 1000.0] { + let planes: Vec = (0..3).map(|c| Plane::from_fn(w, h, |x, y| v(x, y, c))).collect(); + let want: Vec = planes.iter().map(|p| blur::gaussian(p, sigma)).collect(); + for nc in [1, 2, 3] { + let words: Vec = (0..w * h).flat_map(|i| (0..nc).map(move |c| (i, c))).map(|(i, c)| planes[c].data[i]).collect(); + let mut cx = Cx::new(gpu); + let src = gpu.upload(&words); + let out = gaussian(&mut cx, &src, w, h, nc, sigma); + let got: Vec = cx.read(&out, w * h * nc); + for (i, g) in got.iter().enumerate() { + let want = want[i % nc].data[i / nc]; + assert!((g - want).abs() < 1e-5 * (1.0 + sigma / 10.0), "{w}×{h} nc={nc} sigma={sigma}: word {i} {g} vs {want}"); + } + } + } + } + } + /// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`. #[test] #[ignore] @@ -1139,6 +1172,7 @@ mod tests { let Some(gpu) = crate::device() else { return }; let _scope = crate::ctx::RenderScope::new(gpu); let (w, h) = (6000, 4000); + eprintln!("{} ({:?})", gpu.info.name, gpu.info.backend); let src = gpu.upload(&vec![0.5f32; w * h * 3]); let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| { let mut best = f64::MAX; @@ -1152,9 +1186,19 @@ mod tests { } eprintln!("{name:<40} {best:.1} ms"); }; + for nc in [1, 2, 3] { + let out = gpu.buffer(w * h * nc); + // noise reduction's radii, Texture's and Dehaze's at 6000 px + for r in [2, 11, 120] { + for (k, horizontal) in [("box_h", true), ("box_v", false)] { + time(&format!("{k} nc={nc} r={r}"), &mut |cx| box_pass(cx, horizontal, &src, &out, (w, h), nc, r)); + } + } + } for nc in [1, 2, 3] { time(&format!("gaussian nc={nc} sigma=2"), &mut |cx| drop(gaussian(cx, &src, w, h, nc, 2.0))); } + time("gaussian nc=3 sigma=4.5 (colour NR)", &mut |cx| drop(gaussian(cx, &src, w, h, 3, 4.5))); time("map log_lum", &mut |cx| { let o = cx.gpu.buffer(w * h); map(cx, "log_lum_k", w * h, &[], [Some(&src), None, None], &o); diff --git a/crates/gpu/src/wgsl/blur.wgsl b/crates/gpu/src/wgsl/blur.wgsl index b0e28370d..375fb6d55 100644 --- a/crates/gpu/src/wgsl/blur.wgsl +++ b/crates/gpu/src/wgsl/blur.wgsl @@ -1,6 +1,6 @@ // Box passes (clamped edges) over an image of NC interleaved channels; three of each approximate a -// Gaussian (`lightcraft_raster::blur::gaussian`). Each thread slides a running sum over CH pixels. -// P: w, h, nc, r, ch. Bindings: src, dst. +// Gaussian (`lightcraft_raster::blur::gaussian`). +// P: w, h, nc, r, ch (box_v only). Bindings: src, dst. // Pixel `i` (all nc ≤ 3 channels at once: one contiguous read per pixel). fn ld(i: u32, nc: u32) -> vec3 { @@ -25,31 +25,27 @@ fn st(i: u32, nc: u32, v: vec3) { } } -// Horizontal: one thread per CH consecutive pixels of a row. +// Horizontal: one thread per pixel, summing its 2r + 1 window; adjacent threads read adjacent +// pixels, which the cache serves. (Threads that each slid a running sum over 16 pixels read pixels +// 16 apart across a workgroup: on a Radeon 780M that pass took 9–15× as long as `box_v`.) @compute @workgroup_size(64, 4) fn box_h(@builtin(global_invocation_id) g: vec3) { let w = pu(0u); let h = pu(1u); let nc = pu(2u); let r = i32(pu(3u)); - let ch = pu(4u); + let x = g.x; let y = g.y; - let x0 = g.x * ch; - if (y >= h || x0 >= w) { + if (x >= w || y >= h) { return; } let last = i32(w) - 1; - let inv = 1.0 / f32(2 * r + 1); let row = y * w; - let x1 = min(x0 + ch, w); var acc = vec3(0.0); for (var k = -r; k <= r; k++) { - acc += ld(row + u32(clamp(i32(x0) + k, 0, last)), nc); - } - for (var x = x0; x < x1; x++) { - st(row + x, nc, acc * inv); - acc = acc + ld(row + u32(min(i32(x) + r + 1, last)), nc) - ld(row + u32(max(i32(x) - r, 0)), nc); + acc += ld(row + u32(clamp(i32(x) + k, 0, last)), nc); } + st(row + x, nc, acc * (1.0 / f32(2 * r + 1))); } // Vertical: one thread per CH consecutive rows of a column (the CPU re-primes every BAND rows the diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 883c3a0bf..edfdf55d6 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -139,7 +139,8 @@ walks the drive. - Kernels (`crates/gpu/src/wgsl/`): `geom` (orientation pixel map, bilinear through the crop/straighten/flip affine or the full lens + perspective warp), `resize` (separable resample with the CPU's taps; Mitchell prefilter, box/bilinear for the fast guided filter), `blur` (box - passes with running sums over pixel chunks; three each way = the CPU's Gaussian), `map` + passes, three each way = the CPU's Gaussian: horizontal ones sum each pixel's window, so adjacent + threads read adjacent pixels; vertical ones slide running sums down chunks of a column), `map` (log luminance, dark channel, guided-filter steps, white balance, luminance / colour NR, airlight sampling), `mask` (linear / radial / luminance / colour range / brush shapes, combine, finalize), `finish` (the whole per-pixel stage) — each mirrors a named CPU function. @@ -233,7 +234,7 @@ noise-reduction size come from the whole frame, and a window that spots or Auto cut back after the readback. The tests skip (pass with a note) when no adapter exists. Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on -Metal and running-sum order in the box filters — all far below one 8-bit step. +Metal and summation order in the box filters — all far below one 8-bit step. ## Measurements `render_bench` on `corpus/raw/arw-sony-a7m3-compressed.arw` (24 MP; loupe = 2560 px preview → @@ -251,6 +252,14 @@ so CPU numbers are pessimistic; the GPU numbers are less affected): | per-pixel stage 1920×1280, + vibrance/saturation | 53–65 ms | 5 ms | | export render 6000×4000 | 1037–1414 ms | 291–330 ms | +On Windows (Radeon 780M iGPU, DX12), a full-size JPEG export of the same raw with heavy edits (NR 30 +/ 25, clarity, texture, dehaze, sharpening, highlights / shadows) took 3.6–4.0 s on the GPU against +1.9–2.0 s on the CPU: the horizontal box pass had each thread slide a running sum over 16 pixels, so +the threads of a workgroup read pixels 16 apart, and it took 9–15× as long as the vertical pass. +Summing each pixel's window instead (adjacent threads, adjacent pixels): 1.9–2.0 s, with the GPU's +NR stage 1.5 → 0.4 s and spatial planes 0.89 → 0.29 s (`LIGHTCRAFT_PROFILE`). The ignored test +`bench_kernels` (`crates/gpu/src/render.rs`) times the passes on a 24 MP plane. + GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel compilation: ~0.4 s once per process — the desktop app starts it on a background thread at launch (`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..6f93109dd 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -41,6 +41,11 @@ the other editor (Edit in Other Editor, now ⌥⌘E), and where PhotoCraft is if you use a portable copy. - Export can write PSD too (flat, 8- or 16-bit). +### Faster GPU exports +- Exports with noise reduction, Texture, Clarity, Dehaze or Highlights and Shadows can be much faster on the GPU, + depending on the graphics card. On a Windows laptop with an AMD Radeon 780M, a full-size export of a 24 MP raw + with those edits went from about 3.7 s to 1.9 s. Photos look the same as before. + ### Reliability - Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade From 530320a6bdb2876568483127137ff3ba2738c1cb Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 03:18:48 +1100 Subject: [PATCH 019/142] LR-BEHAV-ACCESS: AccessKit tree audit test; name fields, give sliders range tests/accessibility.rs walks the AccessKit tree headless in every view, Develop tool and 13 dialogs and fails on unnamed controls, sliders without value/range and untitled dialogs. Fixes what it found: text fields are named by caption or hint, develop sliders expose min/max/step/value text and take Increment/Decrement, Tab-focused sliders take the arrow keys, Auto Stack's slider is named. Also as_chunks in spots.rs for the newer clippy lint. Docs: ui-parity audit section, checklist, gaps, ROADMAP. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 5 +- crates/pipeline/src/spots.rs | 4 +- crates/ui-egui/src/panels/dialogs.rs | 31 +++-- crates/ui-egui/src/panels/right.rs | 5 +- crates/ui-egui/src/panels/topbar.rs | 1 + crates/ui-egui/src/text_field.rs | 15 ++ crates/ui-egui/src/widgets.rs | 49 ++++++- crates/ui-egui/tests/accessibility.rs | 189 ++++++++++++++++++++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 34 ++++- 12 files changed, 318 insertions(+), 30 deletions(-) create mode 100644 crates/ui-egui/tests/accessibility.rs diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..51e45d11b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | | ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | -| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | +| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | | Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..1331575a2 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -298,13 +298,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); - ui.add( + let r = ui.add( egui::Slider::new(gap, 0.0..=86400.0) .logarithmic(true) .smallest_positive(1.0) .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) .custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()), ); + crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; ui.label( @@ -359,7 +360,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::AllMetadata { title, rows, search } => { ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); - let r = ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)); + let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), crate::i18n::tr("Filter fields")); crate::widgets::register(ui.ctx(), "field:metadataSearch", r.rect); let q = search.trim().to_lowercase(); let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); @@ -402,7 +403,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } Dialog::SmartRules { id, name, rules, .. } => { - let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); + let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Name")); crate::widgets::register(ui.ctx(), "field:smartName", r.rect); ui.add_space(6.0); // the folder an album made from a folder view carries is not in the editor, but it counts @@ -508,12 +509,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, &colour, |ui| { let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); - let te = ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)); + let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), crate::i18n::tr(&colour)); crate::widgets::register(ui.ctx(), format!("field:labelName-{}", colour.to_lowercase()), te.rect); }); } field(ui, "Save as set", |ui| { - let te = ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)); + let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), crate::i18n::tr("Save as set")); crate::widgets::register(ui.ctx(), "field:labelSetName", te.rect); }); ui.label( @@ -528,7 +529,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let tags_open = field(ui, "Template", |ui| { ui.spacing_mut().item_spacing.x = 4.0; let w = crate::import::tag_field_width(ui); - let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)); + let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), crate::i18n::tr("Template")); crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect); crate::import::tag_toggle(ui, "renameTemplate") }); @@ -614,8 +615,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::CreatePreset { name, group, groups } => { - ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)); - ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)); + crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), crate::i18n::tr("Preset name")); + crate::widgets::named(ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), crate::i18n::tr("Group")); ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); group_checklist(ui, "presetInclude", groups); } @@ -823,7 +824,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { { wm.image = f; } - ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())); + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), crate::i18n::tr("Image")); }); }); let mut width = wm.image_width as f64 * 100.0; @@ -833,7 +834,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } else { choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical); field(ui, "Text", |ui| { - ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)) + crate::widgets::named(ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Text")) }); } choices( @@ -885,7 +886,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let tags_open = field(ui, "File name", |ui| { ui.spacing_mut().item_spacing.x = 4.0; let w = crate::import::tag_field_width(ui); - ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)); + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)), crate::i18n::tr("File name")); crate::import::tag_toggle(ui, "exportNaming") }); if tags_open { @@ -907,11 +908,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { { *dir = d; } - ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())); + crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); }); }); field(ui, "Subfolder", |ui| { - ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)) + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder")) }); use lightcraft_engine::export::Conflict as K; choices( @@ -928,7 +929,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::widgets::register(ui.ctx(), "exportAddToPhotos", r.rect); if opts.add_to_photos { field(ui, "Album", |ui| { - ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)) + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Album")) }); ui.label( egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) @@ -956,7 +957,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Err(e) => app.toast(ui.ctx(), e), } } - ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())); + crate::widgets::named(ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), crate::i18n::tr("Preset name")); }); }); } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 501a78c5e..84e3ae8ff 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -114,9 +114,11 @@ fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Custom")); let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width")); register(ui.ctx(), "field:cropCustomW", a.rect); ui.label("×"); let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height")); register(ui.ctx(), "field:cropCustomH", b.rect); let go = text_button(ui, "cropCustomApply", "Apply", false); apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter))); @@ -644,6 +646,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone()); let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY)); + crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); register(ui.ctx(), "field:gps", r.rect); if r.has_focus() { ui.data_mut(|d| d.insert_temp(gid, text.clone())); @@ -796,7 +799,7 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str } else { crate::text_field::TextField::singleline(&widget, &mut text) }; - let r = field.width(f32::INFINITY).show(ui); + let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) if r.editing { ui.data_mut(|d| d.insert_temp(id, text.clone())); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index a95805f83..be9704ed4 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -103,6 +103,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); } let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) + .label(crate::i18n::tr("Search Photos")) .frame(false) .width(sr.width() - 20.0) .font(t.font(13.5)) diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index d22b3b939..268242ef1 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -51,6 +51,7 @@ pub struct TextField<'a> { text: &'a mut String, multiline: bool, hint: Option, + label: Option, width: Option, select_on_focus: bool, begin_edit: bool, @@ -81,6 +82,7 @@ impl<'a> TextField<'a> { text, multiline: false, hint: None, + label: None, width: None, select_on_focus: false, begin_edit: false, @@ -104,6 +106,13 @@ impl<'a> TextField<'a> { self } + /// The name a screen reader announces for the field (its visible caption); the hint + /// stands in when there is none. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + pub fn width(mut self, width: f32) -> Self { self.width = Some(width); self @@ -182,6 +191,7 @@ impl<'a> TextField<'a> { Action::Refocus => {} } } + let name = self.label.clone().or_else(|| self.hint.clone()); let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; edit = edit.id(id); if let Some(hint) = self.hint { @@ -214,6 +224,11 @@ impl<'a> TextField<'a> { let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary)); let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range()); let mut response = ui.add(edit); + // screen readers: the field by its caption, even once it holds text (egui names it by + // nothing but its contents) + if let Some(name) = name.filter(|n| !n.trim().is_empty()) { + ui.ctx().accesskit_node_builder(response.id, |node| node.set_label(name)); + } // The menu may take focus on a primary-button release. egui collapses a // TextEdit selection when focus leaves; retain the range our own menu acts on. if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0872b2ec4..e17e812fc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -185,6 +185,10 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); // A click selects the slider for arrow/wheel adjustments. Keep scroll-to-adjust // opt-in so hovering an unfocused slider still scrolls the containing panel. + // Tab reaching the slider selects it as a click does: the arrow keys then adjust it + if enabled && resp.gained_focus() { + ui.data_mut(|d| d.insert_temp(focused_slider_key(), id)); + } if enabled && (resp.clicked() || resp.drag_started()) { resp.request_focus(); ui.data_mut(|d| d.insert_temp(focused_slider_key(), id)); @@ -192,6 +196,18 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); + // and its range, step and the value as shown ("+0.50", "5600") + let shown = shown_value(spec, value); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(spec.min); + node.set_max_numeric_value(spec.max); + node.set_numeric_value_step(spec.step); + node.set_value(shown); + if enabled { + node.add_action(egui::accesskit::Action::Increment); + node.add_action(egui::accesskit::Action::Decrement); + } + }); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one (over the label, so it takes the click) @@ -292,13 +308,23 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ if resp.drag_stopped() { out.drag_stopped = true; } + // a screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) + let (inc, dec) = ui.input(|i| { + i.events.iter().fold((false, false), |(inc, dec), e| match e { + egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { + (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) + } + _ => (inc, dec), + }) + }); // Arrow keys work with either a hovered or a focused slider. - if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus()) { + if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus() || inc || dec) { let (up, down, shift) = ui.input_mut(|i| { let shift = i.modifiers.shift; let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) }); + let (up, down) = (up || inc, down || dec); if up || down { let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); if (nv - value).abs() > 1e-12 { @@ -665,6 +691,7 @@ mod access_tests { ctx.enable_accesskit(); let spec = lightcraft_develop::controls::find("light.exposure").unwrap(); let mut found = Vec::new(); + let mut ranges = Vec::new(); for _ in 0..3 { let out = ctx.run_ui(egui::RawInput::default(), |ui| { let _ = slider(ui, spec, 0.5, true, None); @@ -674,6 +701,12 @@ mod access_tests { let mut out = out; out.textures_delta.clear(); if let Some(update) = out.platform_output.accesskit_update.take() { + ranges = update + .nodes + .iter() + .filter(|(_, n)| n.role() == egui::accesskit::Role::Slider) + .map(|(_, n)| (n.min_numeric_value(), n.max_numeric_value())) + .collect(); found = update .nodes .iter() @@ -684,11 +717,25 @@ mod access_tests { let has = |role: &str, label: &str| found.iter().any(|(r, l, _)| r == role && l == label); assert!(has("Slider", "Exposure"), "{found:?}"); assert!(found.iter().any(|(r, l, v)| r == "Slider" && l == "Exposure" && *v == Some(0.5)), "the slider's value: {found:?}"); + assert!(ranges.contains(&(Some(spec.min), Some(spec.max))), "the slider's range: {ranges:?}"); assert!(has("Button", "Auto"), "{found:?}"); assert!(has("Button", "Delete mask"), "{found:?}"); } } +/// Names a widget for screen readers (`name` replaces what egui gave it): for a plain egui field +/// whose caption is a separate label beside it. +pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str) { + ctx.accesskit_node_builder(id, |node| node.set_label(name.to_string())); +} + +/// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn +/// beside it, or whose hint is all it shows. +pub(crate) fn named(r: egui::Response, name: &str) -> egui::Response { + screen_reader_name(&r.ctx, r.id, name); + r +} + /// `text` laid out on one line, cut short with … when it is wider than `max` (the galley's /// `elided` says whether it was): for text painted into a fixed box (a tile, a row before its /// buttons), which a plain `Painter::text` would draw past. diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs new file mode 100644 index 000000000..e28d060bb --- /dev/null +++ b/crates/ui-egui/tests/accessibility.rs @@ -0,0 +1,189 @@ +//! Accessibility audit: walks the AccessKit tree egui exposes to screen readers (VoiceOver, +//! Narrator, AT-SPI) in each main view and Develop tool, headless, and fails on an interactive +//! node with no name, a slider without its value and range, or a canvas tool no key can reach. +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::{Harness, kittest::NodeT}; +use lightcraft_ui_egui::{ + LightcraftApp, Services, + state::{RightPanel, ViewMode}, +}; + +fn harness(view: ViewMode, right: RightPanel) -> Harness<'static, LightcraftApp> { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.left_panel = true; + app.ui.view = view; + app.ui.right = right; + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + h +} + +fn interactive(role: Role) -> bool { + matches!( + role, + Role::Button + | Role::CheckBox + | Role::Switch + | Role::RadioButton + | Role::Slider + | Role::SpinButton + | Role::TextInput + | Role::MultilineTextInput + | Role::SearchInput + | Role::ComboBox + | Role::EditableComboBox + | Role::Tab + | Role::Link + | Role::MenuItem + | Role::MenuItemCheckBox + | Role::MenuItemRadio + | Role::ListBoxOption + ) +} + +/// Every problem found in the harness's current tree, one line each. +fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec { + let mut problems = Vec::new(); + let mut count = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + let role = n.role(); + if !interactive(role) { + continue; + } + count += 1; + let name = n.label().unwrap_or_default(); + let name = if name.trim().is_empty() { n.value().unwrap_or_default() } else { name }; + if name.trim().is_empty() { + problems.push(format!("{place}: unnamed {role:?} at {:?}", n.bounding_box())); + } + if role == Role::Slider && (n.numeric_value().is_none() || n.min_numeric_value().is_none() || n.max_numeric_value().is_none()) { + problems.push(format!("{place}: slider {name:?} lacks value/range")); + } + } + assert!(count > 5, "{place}: the tree has interactive nodes ({count})"); + problems +} + +#[test] +fn every_interactive_node_has_a_name_in_every_view_and_tool() { + let mut problems = Vec::new(); + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] { + let h = harness(view, RightPanel::None); + problems.extend(audit(&h, &format!("{view:?}"))); + } + for right in [ + RightPanel::Edit, + RightPanel::Profiles, + RightPanel::Crop, + RightPanel::Remove, + RightPanel::Masking, + RightPanel::RedEye, + RightPanel::Versions, + RightPanel::Activity, + RightPanel::Keywords, + RightPanel::Info, + ] { + let h = harness(ViewMode::Detail, right); + problems.extend(audit(&h, &format!("Detail/{right:?}"))); + } + // the dialogs: each a window with a title, every control in it named + for dialog in [ + "dialog.export", + "dialog.copySettings", + "dialog.pasteSettings", + "dialog.createPreset", + "dialog.rename", + "dialog.newAlbum", + "dialog.newSmartAlbum", + "dialog.captureTime", + "dialog.labelNames", + "dialog.allMetadata", + "dialog.saveMetadataPreset", + "dialog.autoStack", + "dialog.contactSheet", + ] { + let mut h = harness(ViewMode::PhotoGrid, RightPanel::None); + if let Err(e) = h.state_mut().run(dialog, serde_json::json!({})) { + problems.push(format!("{dialog}: does not open ({e})")); + continue; + } + h.run_steps(6); + problems.extend(audit(&h, dialog)); + let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count(); + let untitled = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty()) + .count(); + if untitled > 0 { + problems.push(format!("{dialog}: {untitled} of {windows} windows have no title")); + } + } + problems.sort(); + problems.dedup(); + assert!(problems.is_empty(), "{} accessibility problems:\n{}", problems.len(), problems.join("\n")); +} + +fn slider_value(h: &Harness<'_, LightcraftApp>, name: &str) -> Option { + h.root() + .children_recursive() + .map(|n| n.accesskit_node()) + .find(|n| n.role() == Role::Slider && n.label().as_deref() == Some(name)) + .and_then(|n| n.numeric_value()) +} + +/// A screen reader's Increment on a develop slider moves it one step, as an arrow key does. +#[test] +fn screen_reader_increment_moves_a_slider() { + let mut h = harness(ViewMode::Detail, RightPanel::Edit); + let before = slider_value(&h, "Exposure").expect("Exposure is in the tree"); + let target = h + .root() + .children_recursive() + .find(|n| n.accesskit_node().role() == Role::Slider && n.accesskit_node().label().as_deref() == Some("Exposure")) + .unwrap() + .accesskit_node() + .locate(); + h.event(egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Increment, + target_node: target.0, + target_tree: target.1, + data: None, + })); + h.run_steps(4); + let after = slider_value(&h, "Exposure").unwrap(); + assert!(after > before, "{before} -> {after}"); +} + +/// The canvas tools' settings (crop angle and offsets, spot size, mask amount) are reachable with +/// Tab alone, and the arrow keys then adjust them. +#[test] +fn canvas_tool_sliders_are_reachable_with_tab() { + for right in [RightPanel::Crop, RightPanel::Remove] { + let mut h = harness(ViewMode::Detail, right); + let mut reached = None; + for _ in 0..300 { + h.key_press(egui::Key::Tab); + h.run_steps(1); + let focused = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused() && n.role() == Role::Slider); + if let Some(n) = focused { + reached = Some((n.label().unwrap_or_default(), n.numeric_value())); + break; + } + } + let (name, before) = reached.unwrap_or_else(|| panic!("{right:?}: no slider takes the focus with Tab")); + h.key_press(egui::Key::ArrowUp); + h.run_steps(4); + let after = slider_value(&h, &name); + assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..4f5f1d188 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 23: AccessKit audit test landed, estimate 10–20 h → 4–8 h) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -37,7 +37,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | | 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | -| 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | +| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 23 (accessibility): the AccessKit tree audit and its fixes landed; what is left is canvas placement by keyboard and a real screen-reader session; 10–20 h → 4–8 h; gap not closed | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..1ab1572ba 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-BEHAV-ACCESS: AccessKit audit test) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -472,7 +472,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | -| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | +| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | | LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..ba62abb40 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -48,7 +48,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | | ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | -| UI/UX fidelity | ~85% | ~70% | 40–70 h | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | | Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44aa1c703..2236f02a7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -32,11 +32,11 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | | Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item | | Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases | -| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit: sliders, buttons, dropdowns, section headers announce | 30% | canvas tools pointer-only; not audited with a screen reader | +| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab | 55% | crop handles, spot and mask placement on the canvas still need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | | Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test | | **UI/UX overall** | | | **~70%** | estimated, weighted by use (develop and library 60%, tools 25%, rest 15%) | -Remaining: **40–70 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility audit 10–20 +Remaining: **34–58 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 4–8 h, Classic keymap layer 4–8 h, context-menu audit 4–8 h, text-field and IME polish 4–8 h). Output modules and AI tools are counted with their features in [target-app-parity.md](target-app-parity.md). @@ -74,9 +74,37 @@ under the UI command that wraps it (a no-op error); the UI command now wins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). +## Accessibility audit + +`crates/ui-egui/tests/accessibility.rs` walks the AccessKit tree egui hands to VoiceOver, Narrator and +AT-SPI, headless, in Photo Grid, Square Grid, Detail, Compare, Survey and Reference, in each right +panel (Edit, Profiles, Crop, Remove, Masking, Red Eye, Versions, Activity, Keywords, Info) and in 13 +dialogs (Export, Copy / Paste Settings, Create Preset, Rename, New Album, New Smart Album, Capture +Time, Label Names, All Metadata, Save Metadata Preset, Auto Stack, Contact Sheet). It fails on a +button, check box, slider, text field, dropdown, tab, link or menu item without a name, on a slider +without its value and range, and on a dialog window without a title. + +Found and fixed (2026-10-12): 47 problems in the views and tools, 11 more in the dialogs. +- Every text field announced only its contents: the top search box, the Info panel's metadata fields + and GPS, the keyword fields, Crop's custom aspect, and plain egui fields in the dialogs (export file + name, folder, subfolder, watermark, preset names, label names, rename template, metadata filter). + `TextField` now names itself by its caption (`.label`) or hint; plain egui fields are named with + `widgets::named`. +- Develop sliders announced a value but no range: they now carry min, max, step, the value as shown + ("+0.50"), and answer a screen reader's Increment / Decrement (VoiceOver Ctrl-Option-Up / Down) by + one step, as the arrow keys do. A slider reached with Tab takes the arrow keys as a clicked one does. +- Auto Stack's time slider had no name. + +Keyboard reach: Crop (angle, offsets, scale, aspect) and Remove (size, feather, opacity) settings +are reached with Tab and adjusted with the arrow keys (tested). Still pointer-only: dragging crop +handles, placing a spot or a brush stroke, and moving a mask pin on the canvas. Not done: a session +with a real screen reader on each platform, and focus order (egui follows drawing order: top bar, +left panel, canvas, right panel, which is sensible but not tuned). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | From 2da4b36b455dba64d6cca8c24ed91230515c6e79 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Mon, 12 Oct 2026 01:26:41 +1000 Subject: [PATCH 020/142] Windows: keep sidecar spellings on case-insensitive volumes; fix rename/sidecar tests 20 engine tests failed on Windows on clean main. Three real bugs on case-insensitive volumes (Windows, default macOS APFS) caused most of them: - Renaming a photo probed `a.XMP` before `a.xmp`. On a case-insensitive volume the upper-case spelling "exists" when only `a.xmp` is there, so the sidecar was moved as `Trip.XMP` (and its retained-packet record was taken under the wrong spelling). Upper-case candidates are now used only when the folder lists them exactly (new `MoveFs::listed`). - `RealFs::same_file` on Windows (no file id in std) depended on argument order: `same_file("Trip.XMP", "Trip.xmp")` was false when `Trip.xmp` is the real entry, so saving after a partial rename reported a phantom collision. It is now symmetric, and also requires the names to differ only by case (two different files with equal size and mtime were "the same"). - Saving metadata picked the sidecar spelling by `is_file()`, lower case first, so the atomic write renamed an existing `Trip.XMP` to `Trip.xmp`. The spelling the folder lists now wins. The rest were fixtures: FakeFs fault paths, catalog paths and expected paths spelled with `/` while the code joins with the host separator. Regression tests: case_insensitive_renames_keep_each_sidecars_spelling (FakeFs, every platform; fails without the fix) and same_file_spellings_are_symmetric_on_windows. Linux behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/rename.rs | 105 +++++++++++++++--- crates/engine/src/sidecar.rs | 7 +- .../engine/src/sidecar/tests_repair_saves.rs | 4 +- crates/engine/src/tests_filesystem_history.rs | 33 ++++-- crates/engine/src/tests_folders.rs | 3 +- 5 files changed, 121 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index c5ff9a0c4..e49355c08 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -319,6 +319,17 @@ pub(crate) trait MoveFs { /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. fn stem_sibling(&self, sidecar: &Path) -> Option; + /// The folder lists an entry spelled exactly like `p` (letter case included). On a + /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two + /// apart. When the folder can't be listed: `true`, so nothing is skipped. + fn listed(&self, p: &Path) -> bool { + let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; + let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; + match std::fs::read_dir(dir) { + Ok(entries) => entries.flatten().any(|e| e.file_name() == name), + Err(_) => true, + } + } /// Content observed in a packet, independent of its pathname or inode. Model volumes /// override this; disk implementations stream a bounded buffer without retaining the XML. fn packet_version(&self, path: &Path) -> std::io::Result { @@ -366,15 +377,23 @@ impl MoveFs for RealFs { } #[cfg(not(unix))] { - // no stable file id in std here: `b` is `a` under another spelling when they look - // alike and the folder has no entry spelled exactly like `b` (on a case-sensitive - // folder holding both `IMG.JPG` and `img.jpg`, both entries are listed) - let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false }; - if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { + // no stable file id in std here: `a` and `b` are one file under two spellings when + // they look alike and the folder doesn't list both (on a case-sensitive folder + // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the + // listed one: the answer must not depend on the argument order. + let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; + let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); + if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { return false; } let Ok(entries) = std::fs::read_dir(dir) else { return false }; - !entries.flatten().any(|e| e.file_name() == name) + let (mut listed_a, mut listed_b) = (false, false); + for e in entries.flatten() { + let name = e.file_name(); + listed_a |= name == name_a; + listed_b |= name == name_b; + } + !(listed_a && listed_b) } } @@ -699,15 +718,14 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu if fs.exists(t) && !is_respelling(fs, f, t) { return Err(fail(format!("{to} already exists"))); } - let mut packets: Vec = sidecars(from) - .into_iter() - .map(|(p, _, _)| p) - .filter(|p| fs.is_file(p)) - .map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path }) - .collect(); + // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the + // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. + let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); + let mut packets: Vec = + sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; let mut done: Vec = Vec::new(); - let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| { + let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { let mut dst = crate::sidecar::sidecar_path(to, naming); if upper { dst = dst.with_extension("XMP"); @@ -1124,6 +1142,24 @@ pub(crate) mod tests { use super::*; use std::cell::RefCell; + /// Windows has no file id in std: two spellings of one file are the same file whichever is + /// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the + /// known `Trip.xmp`), and only the spelling on disk is listed. + #[cfg(windows)] + #[test] + fn same_file_spellings_are_symmetric_on_windows() { + let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp")); + std::fs::write(&real, b"packet").unwrap(); + std::fs::write(&other, b"packet").unwrap(); + assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real)); + assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias)); + assert!(RealFs.listed(&real) && !RealFs.listed(&alias)); + std::fs::remove_dir_all(&dir).unwrap(); + } + /// The cross-volume move's check compares files chunk by chunk: equal files across several /// chunks, a difference in the last chunk, different lengths, a missing file. #[test] @@ -1171,7 +1207,10 @@ pub(crate) mod tests { } fn idx(&self, p: &Path) -> Option { let p = Self::norm(p); - self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p }) + self.files.borrow().iter().position(|(f, _)| { + let f = f.replace('\\', "/"); + if self.ci { folded_path(&f) == folded_path(&p) } else { f == p } + }) } /// The listing: (exact path, contents), sorted. pub fn listing(&self) -> Vec<(String, String)> { @@ -1196,7 +1235,7 @@ pub(crate) mod tests { self.idx(a).is_some() && self.idx(a) == self.idx(b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { return Err(std::io::Error::other("injected failure")); } if self.exists(b) { @@ -1207,7 +1246,7 @@ pub(crate) mod tests { Ok(()) } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { return Err(std::io::Error::other("injected failure")); } if self.exists(b) { @@ -1223,6 +1262,10 @@ pub(crate) mod tests { self.files.borrow_mut().remove(i); Ok(()) } + fn listed(&self, p: &Path) -> bool { + let p = Self::norm(p); + self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p) + } fn stem_sibling(&self, sidecar: &Path) -> Option { let stem = Self::norm(&sidecar.with_extension("")); self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| { @@ -1293,7 +1336,7 @@ pub(crate) mod tests { } else { serde_json::json!({"path": from, "into": root.join("Archive")}) }; - let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") }; + let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") }; let before = s.catalog.to_snapshot(); let history = (s.undo.len(), s.redo.len()); assert!(s.execute(command, ¶ms).is_err()); @@ -1421,6 +1464,34 @@ pub(crate) mod tests { assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err()); } + /// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed + /// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling + /// is the one reported and queued. + #[test] + fn case_insensitive_renames_keep_each_sidecars_spelling() { + let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]); + move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap(); + move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/c.jpg".into(), "A".into()), + ("/p/c.jpg.XMP".into(), "full".into()), + ("/p/c.xmp".into(), "stem".into()), + ("/p/d.jpg".into(), "B".into()), + ("/p/d.xmp".into(), "mixed".into()) + ] + ); + // the move of the sidecar's real spelling is the one attempted (and here fails): the photo + // goes back and nothing changed + let before = fs.listing(); + *fs.fail.borrow_mut() = vec!["/p/c.xmp".into()]; + let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err(); + assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}"); + assert!(!e.moved && e.pending_sidecars.is_empty()); + assert_eq!(fs.listing(), before); + } + /// Issue #105: a rollback that fails is reported, and the library follows the files that kept /// their new names (an undoable partial rename), so nothing goes missing. #[test] diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 4f44780ec..9aaf7e937 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -566,7 +566,12 @@ impl Session { .map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?; // Keep an existing packet's spelling/style so foreign properties survive this save and // subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above. - let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred); + // On a case-insensitive volume `a.xmp` also answers for `a.XMP`: the spelling the folder + // lists comes first, so the atomic write doesn't rename the packet. + use crate::rename::MoveFs; + let existing: Vec = candidates.into_iter().filter(|path| path.is_file()).collect(); + let listed = existing.iter().find(|path| crate::rename::RealFs.listed(path)).cloned(); + let path = listed.or_else(|| existing.into_iter().next()).unwrap_or(preferred); self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?; let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display())); diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs index 6f9fa4aa4..db56977cf 100644 --- a/crates/engine/src/sidecar/tests_repair_saves.rs +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -151,7 +151,9 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw assert_eq!(parsed.title.as_deref(), Some("Final title")); assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); if upper { - assert!(!preferred.exists(), "merge uses the repaired uppercase packet"); + // by listing: on a case-insensitive volume `preferred` answers for the upper-case packet + let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); + assert!(!listed, "merge uses the repaired uppercase packet"); } for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { s.execute(step, &json!({})).unwrap(); diff --git a/crates/engine/src/tests_filesystem_history.rs b/crates/engine/src/tests_filesystem_history.rs index c071d995b..e4e150c25 100644 --- a/crates/engine/src/tests_filesystem_history.rs +++ b/crates/engine/src/tests_filesystem_history.rs @@ -31,11 +31,21 @@ fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool }) } +/// The model volume spells paths with `/`; the code under test joins them with the host's separator. +fn slash(path: &str) -> String { + path.replace('\\', "/") +} + +fn file_source(s: &Session, id: u64) -> String { + let Source::File { path } = &s.catalog.photo(PhotoId(id)).unwrap().source else { panic!("synthetic file fixture") }; + slash(path) +} + fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) { let files = fs.listing(); for photo in s.catalog.photos() { let Source::File { path } = &photo.source else { panic!("synthetic file fixture") }; - assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0); + assert!(files.iter().any(|(file, _)| *file == slash(path)), "photo {} points to missing {path}: {files:?}", photo.id.0); assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str()); } let replayed = memory_library(store); @@ -102,7 +112,8 @@ fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal() let renamed = fs.listing(); s.merge_undo(2, "Rename Twice"); assert_eq!(s.undo.len(), 1); - assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]); + let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect(); + assert_eq!(moves, vec![("/p/final.jpg".to_string(), "/p/a.jpg".to_string())]); // First exercise the ordinary merged step, before any corrective paths exist. for redo in [false, true] { file_history_step(&mut s, &fs, redo).unwrap(); @@ -274,7 +285,7 @@ fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollb file_history_step(&mut s, &fs, redo).unwrap(); let mut after = before .iter() - .map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone())) + .map(|(path, bytes)| (path.replace(slash(&from).trim_end_matches(".jpg"), slash(&to).trim_end_matches(".jpg")), bytes.clone())) .collect::>(); after.sort(); assert_eq!(fs.listing(), after); @@ -343,7 +354,7 @@ fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() { let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); *fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()]; assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() }); + assert_eq!(file_source(&s, 1), "/p/Trip.jpg"); assert_eq!(s.undo.len(), 1); assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2); assert_file_catalog_and_journal(&s, &fs, &store); @@ -513,10 +524,11 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() { ); s.merge_undo(3, "Rename Three Times"); assert_eq!((s.undo.len(), s.redo.len()), (1, 0)); - assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]); + let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect(); + assert_eq!(moves, vec![("/p/d.jpg".to_string(), "/p/b.jpg".to_string()), ("/p/c.jpg".to_string(), "/p/a.jpg".to_string())]); // The second photo move sticks; the preceding d -> b move must roll back to d. *fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()]; - let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string(); + let error = slash(&file_history_step(&mut s, &fs, false).unwrap_err().to_string()); assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}"); assert!(error.contains("/p/c.jpg could not be moved back"), "{error}"); let files = fs.listing(); @@ -529,9 +541,8 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() { ] ); assert_eq!((s.undo.len(), s.redo.len()), (1, 0)); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() }); - let photo2 = s.catalog.photo(PhotoId(2)).unwrap(); - assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg"); - let Source::File { path } = &photo2.source else { panic!("file fixture") }; - assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2")); + assert_eq!(file_source(&s, 1), "/p/a.jpg"); + let path = file_source(&s, 2); + assert_eq!(path, "/p/d.jpg", "photo 2 rolled back physically and must still identify d.jpg"); + assert_eq!(files.iter().find(|(file, _)| *file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2")); } diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index b55e88c8a..697c98087 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -40,7 +40,8 @@ impl Scratch { Scratch(dir) } fn path(&self, rel: &str) -> String { - self.0.join(rel).to_string_lossy().to_string() + // the host's separator throughout, as the code under test spells joined paths + self.0.join(rel).components().collect::().to_string_lossy().to_string() } } From 6c79be913033cc6d1cb941f513dd79ae25bbd970 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Mon, 12 Oct 2026 02:32:08 +1000 Subject: [PATCH 021/142] Windows: desktop entry paths, toolchain lints desktop_exec treats a freedesktop Exec path as absolute when it starts with '/' on every platform (Path::is_absolute wants a drive letter on Windows); fill_box uses as_chunks (clippy::chunks_exact_to_as_chunks); LibraryLock::descriptor_copy is cfg(all(test, unix)) like its only test. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/editors.rs | 6 ++++-- crates/catalog/src/lock.rs | 5 +++-- crates/pipeline/src/spots.rs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs index 9a8cc22d2..8b99e284d 100644 --- a/apps/lightcraft/src/editors.rs +++ b/apps/lightcraft/src/editors.rs @@ -172,8 +172,10 @@ fn desktop_exec(entry: &str) -> Option { Some(rest) => rest.split('"').next()?, None => exec.split_whitespace().next()?, }; - // a bare name (`photocraft %F`) was already looked for on the PATH - Some(PathBuf::from(program)).filter(|p| p.is_absolute()) + // a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a + // freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on + // (`Path::is_absolute` wants a drive letter on Windows, where the tests run too) + program.starts_with('/').then(|| PathBuf::from(program)) } #[cfg(test)] diff --git a/crates/catalog/src/lock.rs b/crates/catalog/src/lock.rs index 8a2b496d6..d699da049 100644 --- a/crates/catalog/src/lock.rs +++ b/crates/catalog/src/lock.rs @@ -135,8 +135,9 @@ impl LibraryLock { } /// Another descriptor on the locked file: what a process spawned by another thread holds - /// between fork and exec (a `dup` refers to the same open file description). Tests only. - #[cfg(test)] + /// between fork and exec (a `dup` refers to the same open file description). Tests only (the + /// test that uses it is Unix-only: `flock` semantics). + #[cfg(all(test, unix))] pub(crate) fn descriptor_copy(&self) -> Option { self.file.as_ref().and_then(|f| f.try_clone().ok()) } diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..32f55a3c7 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,7 +247,7 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { *d = [v[0], v[1], v[2]]; } } From b7222213c9df1bafce1df58ac34847201da95002 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 11 Oct 2026 13:08:55 +1000 Subject: [PATCH 022/142] PERF-PRESETS: thousands of presets no longer stall every command and frame A Lightroom collection imported whole (11 113 presets, a 42 MB presets.json) made every interaction take seconds: - Session::persist ran after every command and serialized the whole preset list to compare it with the last write: 609 ms per slider tick (release, the real collection). Session::presets is now a PresetList whose generation changes on mutable access; persist serializes only when it (or the profile lists) changed, and skips the write when the bytes hash the same. develop.set: 609 ms -> 2.3 ms; preset.apply: 598 ms -> 3.1 ms. - The Presets panel deep-cloned every preset into a BTreeMap, built a row, widget id and (with thumbnails) a variant render job for each of them every frame. Grouping is cached per generation and only rows in view are laid out: 5041 -> ~20 rows a frame, 2.3 ms with 5000 presets. - The preset pickers (Import dialog, Settings > Import) build only the rows in view. - add_presets and opening a library compared every preset with every other (quadratic); both use hash indexes now. Opening no longer serializes the list once just to remember it. Tests: develop commands don't re-serialize unchanged presets (and a change is still saved), generation semantics, a 20 000-preset import, and the panel building only visible rows (fails on the old code: 5041 rows). An ignored bench (presets_scale_bench, LIGHTCRAFT_BENCH_PRESETS) times a real presets.json. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 7 +- crates/engine/src/library.rs | 79 ++++++++++++-- crates/engine/src/presets.rs | 84 ++++++++++++++- crates/engine/src/tests_presets_scale.rs | 126 ++++++++++++++++++++++ crates/ui-egui/src/headless.rs | 45 ++++++++ crates/ui-egui/src/import.rs | 13 +-- crates/ui-egui/src/panels/presets.rs | 132 ++++++++++++++++++++--- crates/ui-egui/src/panels/settings.rs | 17 +-- docs/whats-new.md | 8 ++ 9 files changed, 460 insertions(+), 51 deletions(-) create mode 100644 crates/engine/src/tests_presets_scale.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..6644f0f99 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -221,7 +221,8 @@ pub struct Session { pub previous_active: Option, /// Groups last used for Copy (Lightroom remembers them). pub copy_groups: Vec, - pub presets: Vec, + /// Built-in then user presets; its generation tells savers and caches when it changed. + pub presets: presets::PresetList, /// Favourite profile ids (persisted with the library, like preset favourites). pub profile_favorites: Vec, /// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]). @@ -354,7 +355,7 @@ impl Session { library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), - presets: presets::builtin(), + presets: presets::PresetList::new(presets::builtin()), profile_favorites: Vec::new(), profile_recent: Vec::new(), journal: Vec::new(), @@ -1019,6 +1020,8 @@ mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_presets_scale; +#[cfg(test)] mod tests_process; #[cfg(test)] mod tests_segment; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c2..0305623f2 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -88,7 +88,8 @@ pub struct Library { retry_at: Option, /// What forgetting untouched Local records did when the library opened. pub forgot_local: Option, - presets_written: String, + /// What presets.json holds (see [`PresetsSaved`]). + presets_saved: PresetsSaved, view_written: Vec, /// Settings files that were unreadable or damaged when the library opened (`library.info` → /// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]). @@ -123,6 +124,53 @@ struct PresetsFile { profile_recent: Vec, } +/// The state presets.json was last written (or read) from. Saving checks the preset list's +/// generation and the small profile lists before serializing anything: with a 10 000-preset +/// collection presets.json is ~40 MB, and serializing it after every command made each slider +/// tick take seconds. +#[derive(Default)] +struct PresetsSaved { + generation: u64, + profile_favorites: Vec, + profile_recent: Vec, + /// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten. + hash: u64, +} + +impl PresetsSaved { + fn state_of(s: &Session) -> PresetsSaved { + PresetsSaved { + generation: s.presets.generation(), + profile_favorites: s.profile_favorites.clone(), + profile_recent: s.profile_recent.clone(), + hash: 0, + } + } + + /// `s` changed its presets or profile lists since this state. + fn differs(&self, s: &Session) -> bool { + self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent + } +} + +fn bytes_hash(b: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + b.hash(&mut h); + h.finish() +} + +#[cfg(test)] +thread_local! { + static PRESETS_SERIALIZED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread serialized the preset list (tests). +#[cfg(test)] +pub(crate) fn presets_serialized() -> usize { + PRESETS_SERIALIZED.with(|c| c.get()) +} + #[derive(Default, Serialize, Deserialize)] #[serde(default)] struct ViewFile { @@ -187,6 +235,8 @@ struct PrefsFile { } fn presets_json(s: &Session) -> String { + #[cfg(test)] + PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1)); let presets = &s.presets; let f = PresetsFile { user: presets.iter().filter(|p| !p.builtin).cloned().collect(), @@ -414,8 +464,9 @@ impl Session { for p in &mut self.presets { p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); } + let mut ids: std::collections::HashSet = self.presets.iter().map(|p| p.id.clone()).collect(); for u in f.user { - if !self.presets.iter().any(|p| p.id == u.id) { + if ids.insert(u.id.clone()) { self.presets.push(u); } } @@ -447,7 +498,8 @@ impl Session { { self.selection = Selection::single(*first); } - let presets_written = presets_json(self); + // presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes) + let presets_saved = PresetsSaved::state_of(self); // photo ids are per library: drop decoded sources of the previous one self.media.clear_sources(); if on_disk { @@ -464,7 +516,7 @@ impl Session { unsaved_error: None, retry_at: None, forgot_local: None, - presets_written, + presets_saved, view_written, settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(), warnings_reported: 0, @@ -526,14 +578,21 @@ impl Session { log::error!("library: compaction: {e}"); lib.last_error = Some(format!("compaction: {e}")); } + let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json")); + if !due { + return Ok(()); + } let presets = presets_json(self); + let mut state = PresetsSaved::state_of(self); + state.hash = bytes_hash(presets.as_bytes()); let Some(lib) = self.library.as_mut() else { return Ok(()) }; - if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") { - if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - log::error!("library: presets: {e}"); - } else { - lib.presets_written = presets; - } + if state.hash == lib.presets_saved.hash { + lib.presets_saved = state; + } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { + // the state stays stale: the next save tries again + log::error!("library: presets: {e}"); + } else { + lib.presets_saved = state; } Ok(()) } diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 8c36aa742..c3675e227 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -23,6 +23,73 @@ pub struct PresetFile { pub presets: Vec, } +/// The session's presets (built-in, then the user's) with a generation that changes on every +/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is +/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON. +/// +/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`, +/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed. +/// Generations are unique across all lists in the process. +#[derive(Clone, Debug)] +pub struct PresetList { + items: Vec, + generation: u64, +} + +static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_generation() -> u64 { + PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +impl PresetList { + pub fn new(items: Vec) -> PresetList { + PresetList { items, generation: next_generation() } + } + + /// Changes whenever the list may have changed. + pub fn generation(&self) -> u64 { + self.generation + } +} + +impl Default for PresetList { + fn default() -> PresetList { + PresetList::new(Vec::new()) + } +} + +impl std::ops::Deref for PresetList { + type Target = Vec; + fn deref(&self) -> &Vec { + &self.items + } +} + +impl std::ops::DerefMut for PresetList { + fn deref_mut(&mut self) -> &mut Vec { + self.generation = next_generation(); + &mut self.items + } +} + +impl<'a> IntoIterator for &'a PresetList { + type Item = &'a Preset; + type IntoIter = std::slice::Iter<'a, Preset>; + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +impl<'a> IntoIterator for &'a mut PresetList { + type Item = &'a mut Preset; + type IntoIter = std::slice::IterMut<'a, Preset>; + fn into_iter(self) -> Self::IntoIter { + self.generation = next_generation(); + self.items.iter_mut() + } +} + /// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`). pub fn slug(s: &str) -> String { let mut out = String::new(); @@ -107,22 +174,33 @@ impl Session { /// Add imported presets. Identical presets (same name, group and settings) already present are /// skipped; id clashes get a fresh id. Returns the ids added. pub fn add_presets(&mut self, presets: Vec) -> Vec { + use std::collections::{HashMap, HashSet}; + // indexed: a collection of 10 000+ presets was quadratic (minutes) to import + let mut ids: HashSet = self.presets.iter().map(|q| q.id.clone()).collect(); + let mut by_name: HashMap<(String, String), Vec> = HashMap::new(); + for (i, q) in self.presets.iter().enumerate() { + by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i); + } let mut added = Vec::new(); for mut p in presets { - if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) { + let key = (p.name.clone(), p.group.clone()); + let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings))); + if same { continue; } - if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) { + if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) { let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name))); let mut id = base.clone(); let mut n = 2; - while self.presets.iter().any(|q| q.id == id) { + while ids.contains(&id) { id = format!("{base}-{n}"); n += 1; } p.id = id; } p.builtin = false; + ids.insert(p.id.clone()); + by_name.entry(key).or_default().push(self.presets.len()); added.push(p.id.clone()); self.presets.push(p); } diff --git a/crates/engine/src/tests_presets_scale.rs b/crates/engine/src/tests_presets_scale.rs new file mode 100644 index 000000000..866b7deb3 --- /dev/null +++ b/crates/engine/src/tests_presets_scale.rs @@ -0,0 +1,126 @@ +//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays +//! responsive: commands that don't touch presets never re-serialize them, and opening or importing +//! doesn't compare every preset with every other. + +use std::path::PathBuf; +use std::time::Instant; + +use lightcraft_develop::Preset; +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn many(n: usize) -> Vec { + (0..n) + .map(|i| Preset { + id: format!("user.coll-{}.look-{i}", i / 100), + name: format!("Look {i}"), + group: format!("Collection {}", i / 100), + settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}), + favorite: false, + builtin: false, + }) + .collect() +} + +#[test] +fn develop_commands_do_not_reserialize_unchanged_presets() { + let lib = temp_dir("no-reserialize"); + let mut s = Session::new(); + s.open_library(&lib, true).unwrap(); + let added = s.add_presets(many(2000)); + assert_eq!(added.len(), 2000); + s.persist().unwrap(); + assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved"); + let before = crate::library::presets_serialized(); + for i in 0..20 { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap(); + } + assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone"); + // a preset change is still saved + let pid = added[7].clone(); + s.execute("preset.favorite", &json!({"id": pid})).unwrap(); + assert!(crate::library::presets_serialized() > before); + s.close_library().unwrap(); + drop(s); + let mut t = Session::new(); + t.open_library(&lib, false).unwrap(); + assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted"); + assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn preset_list_generation_follows_mutable_access_only() { + let mut s = Session::new(); + let g0 = s.presets.generation(); + let _ = s.presets.iter().filter(|p| p.builtin).count(); + let _ = s.presets.len(); + assert_eq!(s.presets.generation(), g0, "reads keep the generation"); + s.presets.push(many(1).remove(0)); + let g1 = s.presets.generation(); + assert_ne!(g1, g0); + for p in &mut s.presets { + p.favorite = false; + } + assert_ne!(s.presets.generation(), g1); + // generations are unique across sessions (UI caches key on them alone) + let other = Session::new(); + assert_ne!(other.presets.generation(), s.presets.generation()); +} + +#[test] +fn importing_thousands_of_presets_is_not_quadratic() { + let mut s = Session::new(); + let t = Instant::now(); + let added = s.add_presets(many(20_000)); + assert_eq!(added.len(), 20_000); + // the same presets again are all skipped as duplicates + assert!(s.add_presets(many(20_000)).is_empty()); + // a clashing id with different settings gets a fresh, unique one + let mut clash = many(1).remove(0); + clash.settings = json!({"light": {"exposure": 3.0}}); + let id = s.add_presets(vec![clash]).remove(0); + assert_ne!(id, "user.coll-0.look-0"); + assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1); + // quadratic was ~200 M comparisons here (tens of seconds in debug builds) + assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed()); +} + +/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test +/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied +/// into a temporary library; the original is only read. +#[test] +#[ignore] +fn presets_scale_bench() { + let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else { + eprintln!("set LIGHTCRAFT_BENCH_PRESETS"); + return; + }; + let lib = temp_dir("bench"); + std::fs::copy(&src, lib.join("presets.json")).unwrap(); + let mut s = Session::new(); + let t = Instant::now(); + s.open_library(&lib, true).unwrap(); + eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len()); + let t = Instant::now(); + for i in 0..20 { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap(); + } + eprintln!("develop.set: {:?} per command", t.elapsed() / 20); + let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap(); + let t = Instant::now(); + s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap(); + eprintln!("preset.apply: {:?}", t.elapsed()); + let t = Instant::now(); + s.execute("preset.favorite", &json!({"id": pid})).unwrap(); + eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..26b333073 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1510,6 +1510,51 @@ mod tests { h.settle(SETTLE); } + /// A collection of thousands of presets (a Lightroom library imported whole) lists only the + /// rows in view: a few dozen widgets and thumbnails a frame, not one per preset (every frame + /// built all of them and froze the app for seconds); scrolling brings later rows in. + #[test] + fn presets_panel_with_thousands_of_presets_builds_only_visible_rows() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let many = (0..5000) + .map(|i| lightcraft_develop::Preset { + id: format!("user.big.{i}"), + name: format!("Look {i}"), + group: format!("Big {:02}", i / 500), + settings: json!({"light": {"exposure": (i % 20) as f64 / 10.0}}), + favorite: false, + builtin: false, + }) + .collect(); + assert_eq!(h.app.session.add_presets(many).len(), 5000); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.app.ui.preset_thumbs = true; + h.settle(SETTLE); + h.step(); + let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("preset:")).map(|(w, _)| w.clone()).collect::>(); + let first = rows(&h); + assert!((5..60).contains(&first.len()), "{} rows built", first.len()); + assert!(h.app.renderer.variant_textures() < 60, "{} thumbnails", h.app.renderer.variant_textures()); + assert!(h.app.widgets.iter().any(|(w, _)| w == "presetGroup:Big 09"), "every group header is listed"); + let started = std::time::Instant::now(); + h.step(); + eprintln!("presets panel frame with 5000 presets: {:?}", started.elapsed()); + // scroll far down over the list: later presets come into view + let r = h.request("ui.hoverWidget", json!({"id": first[first.len() / 2]}), t); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..20 { + h.request("ui.scroll", json!({"dy": -2000}), t); + h.step(); + } + h.step(); + let later = rows(&h); + assert!((5..60).contains(&later.len()), "{} rows built", later.len()); + let deep = later.iter().filter_map(|w| w.strip_prefix("preset:user.big.")).filter_map(|n| n.parse::().ok()).max(); + assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..620c441ad 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1212,16 +1212,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { d.preset.clear(); } - for p in &app.session.presets { - if ui - .selectable_label( - d.preset == p.id, - format!("{} — {}", crate::i18n::builtin_label(&p.group, p.builtin), crate::i18n::builtin_label(&p.name, p.builtin)), - ) - .clicked() - { - d.preset = p.id.clone(); - } + let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); + if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { + d.preset = pid; } }); }); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 0968ab67d..fb253aa15 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -4,9 +4,11 @@ //! files. use std::collections::BTreeMap; +use std::sync::Arc; use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use lightcraft_develop::{ControlSpec, Preset, Section, Track}; +use lightcraft_engine::presets::PresetList; use serde_json::json; use crate::icons::{Icon, paint}; @@ -73,16 +75,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); let thumbs = app.ui.preset_thumbs && active.is_some(); let row_h = if thumbs { 54.0 } else { 26.0 }; + let groups = grouped(ui.ctx(), &app.session.presets); let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - let mut groups: BTreeMap> = BTreeMap::new(); - for p in &app.session.presets { - groups.entry(p.group.clone()).or_default().push(p.clone()); - } - for (g, items) in groups { - // A group holding stock presets is a stock group (shown in the UI language), even - // when user presets share its name; a group of user presets keeps its name. - let builtin = items.iter().any(|p| p.builtin); + // Only the rows in view are laid out, drawn and given thumbnails: a Lightroom collection + // imported whole is 10 000+ presets, and building every row each frame took seconds. + let visible = ui.clip_rect().expand(row_h); + for grp in groups.iter() { + let (g, builtin) = (grp.name.clone(), grp.builtin); let open_id = egui::Id::new(("preset-group", &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); @@ -111,7 +111,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !open { continue; } - for pr in items { + let (first, end) = rows_in_view(ui.cursor().top(), row_h, grp.items.len(), visible); + if first > 0 { + ui.allocate_space(vec2(ui.available_width(), first as f32 * row_h)); + } + for &i in grp.items.get(first..end).unwrap_or_default() { + let Some(pr) = app.session.presets.get(i).cloned() else { continue }; let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); @@ -175,10 +180,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name"); } ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { - let mut groups: Vec = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect(); - groups.sort(); - groups.dedup(); - for g in groups.iter().filter(|g| **g != group) { + for g in groups.iter().filter(|x| x.user && x.name != group).map(|x| &x.name) { if ui.button(g).clicked() { let _ = app.run("preset.move", json!({"id": pid, "group": g})); } @@ -201,6 +203,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); } + if end < grp.items.len() { + ui.allocate_space(vec2(ui.available_width(), (grp.items.len() - end) as f32 * row_h)); + } } ui.add_space(30.0); }); @@ -221,6 +226,107 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// A preset group as the panel lists it: indices into the session's presets. +struct Group { + name: String, + /// Holds a stock preset: a stock group (shown in the UI language), even when user presets + /// share its name; a group of user presets keeps its name. + builtin: bool, + /// Holds a user preset (a Move to Group target). + user: bool, + items: Vec, +} + +/// The presets by group (groups sorted by name, presets in list order), rebuilt only when the +/// list changed (its generation). +fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc> { + let id = egui::Id::new("presets-grouped"); + if let Some((generation, groups)) = ctx.data(|d| d.get_temp::<(u64, Arc>)>(id)) + && generation == presets.generation() + { + return groups; + } + let mut by_name: BTreeMap<&str, Group> = BTreeMap::new(); + for (i, p) in presets.iter().enumerate() { + let g = by_name.entry(&p.group).or_insert_with(|| Group { name: p.group.clone(), builtin: false, user: false, items: Vec::new() }); + g.builtin |= p.builtin; + g.user |= !p.builtin; + g.items.push(i); + } + let groups = Arc::new(by_name.into_values().collect::>()); + ctx.data_mut(|d| d.insert_temp(id, (presets.generation(), groups.clone()))); + groups +} + +/// The presets as the rows of a picker (a combo box's list), in list order with a header row +/// wherever the group changes. Only the rows in view are built (10 000+ presets made an open +/// picker take seconds a frame). Returns the id of the preset clicked. +pub fn picker_rows(ui: &mut egui::Ui, salt: &str, presets: &PresetList, selected: Option<&str>) -> Option { + let id = egui::Id::new("preset-picker-rows"); + let rows = match ui.ctx().data(|d| d.get_temp::<(u64, Arc>)>(id)) { + Some((generation, rows)) if generation == presets.generation() => rows, + _ => { + let mut rows = Vec::with_capacity(presets.len() + 16); + let mut group: Option<&str> = None; + for (i, p) in presets.iter().enumerate() { + if group != Some(p.group.as_str()) { + group = Some(&p.group); + rows.push(PickRow::Header(crate::i18n::builtin_label(&p.group, p.builtin).to_string())); + } + rows.push(PickRow::Preset(i)); + } + let rows = Arc::new(rows); + ui.ctx().data_mut(|d| d.insert_temp(id, (presets.generation(), rows.clone()))); + rows + } + }; + let row_h = ui.spacing().interact_size.y; + let mut out = None; + egui::ScrollArea::vertical().id_salt(("preset-picker", salt)).max_height(300.0).auto_shrink([false, true]).show_rows( + ui, + row_h, + rows.len(), + |ui, range| { + for row in rows.get(range).unwrap_or_default() { + let size = vec2(ui.available_width(), row_h); + match row { + PickRow::Header(g) => { + ui.add_sized(size, egui::Label::new(egui::RichText::new(g).size(10.5).weak())); + } + PickRow::Preset(i) => { + let Some(p) = presets.get(*i) else { continue }; + let label = crate::i18n::builtin_label(&p.name, p.builtin); + if ui.add_sized(size, egui::Button::selectable(selected == Some(p.id.as_str()), label)).clicked() { + out = Some(p.id.clone()); + } + } + } + } + }, + ); + out +} + +#[derive(Clone)] +enum PickRow { + /// A group's name (in the UI language for stock groups). + Header(String), + /// An index into the session's presets. + Preset(usize), +} + +/// The rows `first..end` of a block of `n` rows `row_h` high starting at `top` that meet +/// `visible`. +fn rows_in_view(top: f32, row_h: f32, n: usize, visible: Rect) -> (usize, usize) { + if row_h <= 0.0 || !top.is_finite() { + return (0, n); + } + // `as usize` saturates (negative and NaN give 0) + let first = (((visible.top() - top) / row_h).floor() as usize).min(n); + let end = (((visible.bottom() - top) / row_h).ceil() as usize).clamp(first, n); + (first, end) +} + /// The part of a `size` image that covers `r` (centre crop), as texture coordinates. pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { let a = size[0] as f32 / size[1].max(1) as f32; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..7970afaa8 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -203,15 +203,8 @@ fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Optio if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { out = Some(None); } - let mut group = ""; - for p in &app.session.presets { - if p.group != group { - group = &p.group; - ui.label(RichText::new(crate::i18n::builtin_label(group, p.builtin)).size(10.5).weak()); - } - if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() { - out = Some(Some(p.id.clone())); - } + if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) { + out = Some(Some(pid)); } }); register(ui.ctx(), format!("combo:{id}"), r.response.rect); @@ -283,10 +276,8 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { pick = Some(json!({"camera": cam, "preset": null})); } - for p in &app.session.presets { - if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() { - pick = Some(json!({"camera": cam, "preset": p.id})); - } + if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) { + pick = Some(json!({"camera": cam, "preset": pid})); } }); register(ui.ctx(), format!("combo:{id}"), r.response.rect); diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..df4896242 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -46,6 +46,14 @@ in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade installs to Program Files. +### Large preset collections +- A library holding thousands of presets (a Lightroom collection imported whole: 11 000 presets, a 42 MB + presets.json) stays responsive. Every command used to re-serialize the whole list to see whether it had changed + (~0.6 s per slider tick); now the list is saved only after it changes, and a slider tick takes ~2 ms. +- The Presets panel builds only the rows in view (and their thumbnails) instead of every preset on every frame; the + preset pickers in Import and Settings ▸ Import scroll the same way. +- Importing a large collection and opening its library no longer compare every preset with every other. + ## 2026-10-10 ### Dialogs fit the window From b28ce8ef153c76e5834da47d08687b0c3e961f1b Mon Sep 17 00:00:00 2001 From: kelanna Date: Sun, 11 Oct 2026 09:39:40 -0700 Subject: [PATCH 023/142] LR-PREF-DISPLAY: zoomed views stay sharp with a monitor profile With a display profile set, the 1:1 (and closer) Detail and Compare views showed only the whole-frame render magnified, not the sharp window cut from the photo's own pixels. The renderer gives a display-profile render its own key (RenderJob::with_display), but the window tiles were recorded under the key the job had before, so draw_window never found the texture. The tiles are now keyed with the job as the renderer runs it (Renderer::job_for), in Detail and in Compare. Tests: a_display_profile_keeps_the_sharp_window_at_one_to_one and a_display_profile_keeps_the_compare_windows_at_one_to_one check that the window is painted over the whole-frame render; both failed before the fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WEuxxPyG8t6KcADbZSEAUj --- crates/ui-egui/src/panels/compare.rs | 2 + crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/tests_preview_limit.rs | 47 +++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..933b87415 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -350,6 +350,8 @@ fn request_window(app: &mut LightcraftApp, v: &TileView) -> Option Opt let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; let job = job.with_overlay(overlay); + // keyed as the renderer keys the texture (a display profile changes the key) + let job = app.renderer.job_for(v.slot, job); // The overlay is part of the tile's identity too, so changing mask view/colour never leaves a // stale region tile on screen while its replacement renders. let look = job.settings.hash64() ^ overlay.key().rotate_left(17); diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..fd1f0e447 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -763,6 +763,53 @@ mod in_the_loupe { assert!(held(&h) > before / 3, "and they are still there"); } + /// Load a Display P3 monitor profile (`app.displayProfile`), as Settings ▸ Display does. + fn use_display_profile(h: &mut Headless) { + let dir = std::env::temp_dir().join(format!("lightcraft-zoom-display-{}-{:?}", std::process::id(), std::thread::current().id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("p3.icc"); + std::fs::write(&path, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).expect("profile written"); + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": path.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.renderer.display_id().is_some()); + } + + // Given a monitor profile, when I zoom to 1:1, then the sharp window is painted over the + // whole-frame render, as without one (a display profile changes the texture's render key, and + // the window used to be looked up by the key it had before: never found, so only the + // whole-frame render, magnified and blurred, was shown) + #[test] + fn a_display_profile_keeps_the_sharp_window_at_one_to_one() { + let (mut h, native) = detail(); + use_display_profile(&mut h); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(rendered_long_edge(&h) < native, "the whole frame is smaller than the photo"); + let view = h.app.region_view.expect("a window render"); + assert_eq!(view.full.0.max(view.full.1), native); + let (whole, window) = (drawn(&h, Slot::Main), drawn(&h, Slot::Region)); + assert!(!whole.is_empty(), "the whole-frame render is painted"); + assert!(!window.is_empty(), "the window is painted"); + assert!(window[0].0 > whole[0].0, "over the whole-frame render"); + } + + // …and in Compare, each pane's window too + #[test] + fn a_display_profile_keeps_the_compare_windows_at_one_to_one() { + let (mut h, _) = detail(); + use_display_profile(&mut h); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + h.settle(SETTLE); + for i in 0..2u8 { + assert!(h.app.compare_windows[i as usize].is_some(), "pane {i} asked for a window"); + let (whole, window) = (drawn(&h, Slot::Compare(i)), drawn(&h, Slot::CompareRegion(i))); + assert!(!whole.is_empty() && !window.is_empty(), "pane {i}: both are painted"); + assert!(window[0].0 > whole[0].0, "pane {i}: the window is painted over the whole-frame render"); + } + } + /// Compare view of the demo library at `zoom`, settled. fn compare_at(zoom: crate::state::Zoom) -> Headless { let (mut h, _) = detail(); From 6fc33e1ee19849171dff1030f1cc561b79ad0c68 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sun, 11 Oct 2026 17:34:05 +0200 Subject: [PATCH 024/142] =?UTF-8?q?LR-VIEW-DETAIL:=20the=20loupe=20says=20?= =?UTF-8?q?"Rendering=E2=80=A6"=20while=20what=20it=20shows=20is=20not=20t?= =?UTF-8?q?he=20render=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A raw's embedded JPEG, a thumbnail, the previous render after an edit or the magnified frame under a 1:1 window all look like the finished photo. Once a render has been on its way for 0.5 s with nothing new on screen, a pill at the bottom of the photo (widget `loupe:rendering`) says so; above the Navigator when that is in the way. The wait starts when the render starts coming (an idle app draws no frames), so a quick render never shows it, nor does a drag whose drafts keep up; once up it stays until nothing is coming, so slow drafts don't make it blink. It watches what is on screen: the After or the Before, and the windows in use. Not an activity row: it says the pixels aren't final (docs/background-tasks.md). Co-Authored-By: Claude --- crates/ui-egui/src/headless.rs | 51 +++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 89 +++++++++++++++++++++++++++-- docs/background-tasks.md | 5 +- docs/control-protocol.md | 2 +- docs/parity-checklist.md | 2 +- docs/whats-new.md | 5 ++ 6 files changed, 147 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..a7fded1c7 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3965,6 +3965,57 @@ mod tests { assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); } + /// While an edit renders, the loupe shows the last render: it says "Rendering…" once that has + /// lasted a moment (not at once after a spell away from the loupe, when no frames were drawn), + /// and stops when the render lands. + #[test] + fn the_loupe_says_rendering_while_an_edit_renders() { + use crate::render::{RenderOffload, Slot}; + use lightcraft_engine::media::{RenderJob, RenderResult}; + use std::{cell::Cell, rc::Rc}; + /// Renders wait until the gate opens. + struct Gate(Rc>, Vec<(Slot, RenderJob)>); + impl RenderOffload for Gate { + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { + if !self.0.get() { + return Some(job); + } + self.1.push((slot, job)); + None + } + fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { + self.1.drain(..).map(|(slot, job)| (slot, job.run(), 0.0)).collect() + } + } + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!pill(&h), "a finished render"); + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(Gate(open.clone(), Vec::new()))); + // half a second in the grid: the loupe draws no frames + h.request("ui.set", json!({"view": "photoGrid"}), t); + for _ in 0..30 { + h.step(); + } + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + for _ in 0..12 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); + assert!(!pill(&h), "not at once"); + for _ in 0..30 { + h.step(); + } + assert!(pill(&h), "the last render is on screen and the edit's is on its way"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); + assert!(!h.app.renderer.is_pending(Slot::Main)); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..6ce9d0ae1 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -749,7 +749,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } // drawn and hit-tested above the loupe and its tools: clicks on it pan - navigator(app, ui, canvas, img_rect, id); + let nav = navigator(app, ui, canvas, img_rect, id); + // what is on screen isn't the render on its way (a stand-in, the last render after an edit, + // a magnified frame under its window): say so once that has lasted a moment + let whole: &[Slot] = if show_before && !split { + &[Slot::Before] + } else if split || split_view { + &[Slot::Main, Slot::Before] + } else { + &[Slot::Main] + }; + let coming = !matches!(shown, "none" | "hover") && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s)); + let keys = + [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); + if ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now)) { + // over the photo (the After in two views); in the crop tool's wide margin, under it + let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; + rendering_pill(ui, canvas, if over.is_positive() { over } else { canvas }, nav); + } if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { preview_only_pill(ui, canvas, why); } @@ -1039,11 +1056,11 @@ fn pretty_date(iso: &str) -> String { const NAV_W: f32 = 180.0; /// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the -/// visible region outlined; click or drag on it to pan. -fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) { +/// visible region outlined; click or drag on it to pan. Returns the area it covers. +fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option { let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; if !app.ui.navigator || !zoomed || app.ui.fullscreen { - return; + return None; } let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); let aspect = img.width() / img.height().max(1.0); @@ -1073,11 +1090,54 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); app.ui.pan = (u, v); } + Some(frame.expand(5.0)) } /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. const PREFETCH_PRIORITY: u32 = 4; +/// How long a render may be on its way, with nothing new on screen, before the loupe says +/// "Rendering…" (s): as long as the activity stack waits before it shows a task. +const RENDERING_AFTER_SECS: f64 = 0.5; + +/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on +/// screen, since when, and whether the pill is up. +type RenderingWait = Option<([Option; 4], f64, bool)>; + +/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with +/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws +/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming: +/// drafts slower than the wait don't make it blink. +fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { + if !coming { + *wait = None; + return false; + } + let (seen, since, up) = wait.get_or_insert((keys, now, false)); + if !*up && *seen != keys { + (*seen, *since) = (keys, now); + } + *up |= now - *since > RENDERING_AFTER_SECS; + *up +} + +/// The pill saying "Rendering…" (registered as `loupe:rendering`), 12 pt above the bottom of `over`, +/// or just above the Navigator when that is in the way. It says the pixels on screen aren't final, +/// so it isn't an activity row (see docs/background-tasks.md). +fn rendering_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let g = p.layout_no_wrap(crate::i18n::tr("Rendering…").to_string(), t.font(12.0), Color32::from_gray(225)); + let size = vec2(g.size().x + 20.0, 20.0); + let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size); + if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) { + r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size); + } + p.rect_filled(r, 10.0, Color32::from_black_alpha(185)); + p.galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + register(ui.ctx(), "loupe:rendering", r); +} + fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { use lightcraft_engine::media::QuickSource; match q { @@ -2448,6 +2508,27 @@ mod tests { assert!(outside_crop_mesh(Rect::NAN, &box_at(pos2(50.0, 50.0), 20.0, 20.0, 10.0), Color32::BLACK).indices.is_empty()); } + /// A raw's embedded JPEG shown while the raw decodes, or the last render while an edit renders, + /// looked like the finished photo: the loupe says "Rendering…" once a render has been coming for + /// a moment with nothing new on screen, and doesn't blink while slow drafts arrive. + #[test] + fn the_loupe_says_rendering_when_the_screen_falls_behind() { + use super::{RENDERING_AFTER_SECS as WAIT, rendering_overdue}; + let key = |k| [Some(k), None, None, None]; + let mut wait = None; + assert!(!rendering_overdue(&mut wait, key(1), false, 0.0), "nothing coming"); + // an edit after an idle spell: the wait starts now, not at the last frame drawn + assert!(!rendering_overdue(&mut wait, key(1), true, 30.0), "a render just asked for"); + assert!(!rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 0.8), "a quick render never shows it"); + assert!(rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 1.2), "nothing new for the wait"); + assert!(rendering_overdue(&mut wait, key(2), true, 31.0 + WAIT), "once up, a slow draft doesn't blink it"); + assert!(!rendering_overdue(&mut wait, key(3), false, 32.0), "done"); + // a drag whose drafts keep up: always something coming, a new draft every 0.1 s + for i in 1..30u32 { + assert!(!rendering_overdue(&mut wait, key(u64::from(i)), true, 40.0 + f64::from(i) * 0.1), "draft {i}"); + } + } + /// Issue #652: a native host reports the GPU's texture limit in the first frame's input only. /// The loupe must still know it in every later frame (it read the raw input, found nothing and /// cut every window render to 2048 px). diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 576b3868f..02336c97c 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -9,7 +9,10 @@ a task that can stop is running. Agents see the same list through `activity.list The registry is `lightcraft_engine::activity` (`crates/engine/src/activity.rs`, `Session::activity`). The stack is `crates/ui-egui/src/panels/activity.rs`. Every job that can last more than a moment uses it; **don't add a progress window, panel or progress toast of your own**. A dialog may still show progress inline (the Synchronize Folder -dialog counts files while it scans), and a toast at the end saying what happened is fine. +dialog counts files while it scans), and a toast at the end saying what happened is fine. A view saying its pixels +aren't final isn't progress either: the loupe's "Rendering…" pill (`loupe:rendering`) marks a stand-in or an +out-of-date render on the photo itself, and the render behind it holds no row (a slider drag replaces it many times a +second). ## Adding a task diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf648..b9190d2cb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -72,7 +72,7 @@ filmstrip is visible. | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | -| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | +| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..e179277c1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -125,7 +125,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own | | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | -| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | +| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice) | | 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 photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..7fd189e63 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-11 +### "Rendering…" on the photo +- The loupe says "Rendering…" at the bottom of the photo when what it shows isn't the finished photo yet: the + camera's embedded JPEG while a raw decodes, the previous render while an edit renders, or the magnified frame + while the 1:1 detail is on its way. It appears once that has lasted half a second, so quick renders never show it. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a From 3c7f450137e0b18f6d76dcf90dc1e381540316b6 Mon Sep 17 00:00:00 2001 From: goujicheng Date: Mon, 12 Oct 2026 00:07:12 +0800 Subject: [PATCH 025/142] LR-IMP-PREVIEW-READ: a CR3 or a TIFF raw's preview is read from the plan's ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header-only path added in #755 answers for a Fuji RAF, whose preview pointer sits in the first bytes. A Canon CR3 keeps its preview in a JPEG track, and a TIFF raw (CR2, ARW, DNG, RW2, 3FR …) can keep its preview tag in an IFD the prefix cuts short: both fell back to `std::fs::read` of the whole file — tens of megabytes for a JPEG of a few, on the format of the largest bucket in gaps.md (Canon, 33 % of photographers). `preview_window` now asks `lightcraft_raw::sparse::preview_plan` — the module #961 added for the import path, which had no production caller — which ranges `embedded_preview` reads, and reads exactly those: `planned_preview` fills a buffer of the file's length, reads the ranges the plan names and asks again until it says `Ready`. The buffer is the file's length, which the memory gate counted already, and its untouched pages stay zero, so what this path costs is the bytes read — on macOS a freed buffer of that size may sit in the allocator's cache rather than go back to the OS (measured on a 38k-photo import, #832). The loop gives up past 60 % of the file, the bound step 2's partial read uses, and a plan that does not fit, a file past 1 GiB or a window that does not decode all end in the whole-file fallback. The quick path hands the JPEG to the decoder alone, so two container facts the whole-file path applies have to be visible in the bytes it is handed, or the file declines and the whole file keeps the preview: - a preview colour space of Adobe RGB (`decode_raw_preview` applies it; a JPEG-only decode has the JPEG's own space, else sRGB). A container that says sRGB does not decline — that is what the decode assumes anyway, and declining it took the fast path away from a real Sony ARW; - the file's own EXIF orientation, when the preview JPEG carries none of its own: the plan's header is a bounded 64 KiB (the header route grows its prefix to the preview's declared extent instead), and an orientation tag it cannot see would show the preview unrotated. Measured over the whole CC0 corpus (111 raws): 52 files take this route, reading a median 7.8 % of their bytes (0.9-27.7 %) — RW2 3.3 %, Leica RWL 4.1 %, ARW 7.5 %, CR3 8.4 %, DNG 9.7 %, CR2 14.3 % — while the 21 RAFs stay on the header route (median 4.5 %). The other 38 read whole: NEF, NRW, ORF, PEF and SRW because the planner does not describe them (an SRW plans to the whole file, which the 60 % budget then declines), plus the ARWs, CR2s, CR3s and DNGs that declare Adobe RGB or whose plan wants more than that budget — the bytes they read are the ones the plan would have read anyway. Across all 111 files the plan handed on the same preview as the whole file, with the skipped bytes zeroed and again 0xFF. `LIGHTCRAFT_PROFILE=1` prints [profile] preview …/arw-sony-a7rm4a-compressed.arw: read 688128 of 61832192 bytes (plan, window 483441) Tests: a synthetic TIFF/DNG whose preview only its tags locate (identical pixels to the whole-file path, the CFA strip unread), the same structure without the tag that makes it a raw, a file whose orientation only the whole file shows, and a file that states Adobe RGB against one that states sRGB; the opt-in real-file test reports which path answered. Co-Authored-By: DeepSeek V3.2 --- README.md | 2 +- crates/engine/src/files.rs | 272 +++++++++++++++++++++++++++- crates/engine/tests/preview_read.rs | 10 +- docs/parity-checklist.md | 5 +- docs/raw-preview-reads.md | 67 ++++++- 5 files changed, 340 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8884f2bd3..8133e9e6f 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - - **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); + - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); - **on-screen HDR display, video and the Classic Print / Book / Map modules.** - **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md). diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..4d6a3c2c8 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -681,9 +681,10 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions { /// Filesystem-backed embedded-preview hook (native). /// /// The quick path reads the file's header and its embedded preview and nothing else (see -/// [`preview_window`]); a format whose preview the header cannot point at falls back to the -/// whole-file path, which is what every format used to cost. The memory gate still counts the whole -/// file, which is what the decode of one can hold. +/// [`preview_window`]); a container the header cannot point into is planned from its tags +/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a +/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts +/// the whole file, which is what the decode of one can hold. pub fn fs_preview_loader() -> PreviewLoader { Arc::new(|path: &str, max_edge: usize| { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); @@ -706,6 +707,10 @@ pub struct PreviewRead { /// Whether the preview came from a bounded read (`false`: the whole file was read, which is what /// every preview cost before the header path existed). pub header_path: bool, + /// Whether the sparse planner named the ranges, rather than a format's own header fields: the + /// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on + /// the header path, and on the whole-file fallback). + pub planned: bool, } impl PreviewRead { @@ -745,7 +750,13 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea { prefix = read_prefix_at(&mut file, path, extent.min(total))?; } - let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) }; + let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { + // The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose + // preview tag sits past the prefix, the containers whose preview only a scan finds: ask the + // sparse planner which ranges `embedded_preview` reads before handing the caller the + // whole-file fallback. + return planned_preview(&mut file, path, total); + }; // A preview at the file's end may have been padded past it: clamp rather than fail. let end = range.offset.saturating_add(range.window_len).min(total); let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?; @@ -767,7 +778,7 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() { return Ok(None); } - let read = PreviewRead { read: bytes_read, total, header_path: true }; + let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false }; // On a network share the bytes are the wait, so say what this preview actually cost. A window // taken from the prefix cost nothing beyond the prefix itself. if lightcraft_pipeline::profiling() { @@ -776,6 +787,107 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea Ok(Some((prefix, window, read))) } +/// The most times the sparse planner may name ranges before the preview gives up: the plan is +/// monotone (it never asks for a range twice), so a real file needs a handful. +const MAX_PREVIEW_PLAN_ROUNDS: usize = 8; +/// Tenths of a file the planner may have read before the preview gives up and the whole-file path +/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the +/// file has stopped being one. +const MAX_PREVIEW_PLAN_SHARE: u64 = 6; +/// The largest file the planner path builds its buffer for. The raws it serves are far below this; +/// a file past it is read whole as before rather than allocating its length up front. +const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30; +/// The header the quick path is handed on the planner route. The header route grows its prefix to +/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out +/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough +/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes. +const PLAN_PREFIX: u64 = 64 * 1024; + +/// The preview of a container the header-only path cannot answer for, from the byte ranges +/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads. +/// +/// The planner is asked about a buffer of the file's whole length in which only the ranges named so +/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The +/// buffer is the file's length, which the loader's memory gate already counts, and its untouched +/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in +/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so +/// this path's cost is the bytes read, not a promise about RSS. +/// +/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the +/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it +/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when +/// the quick path could not reproduce what the whole-file path shows (the two container facts +/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded +/// gives what the whole file gives), so a window that still fails to decode ends in the same +/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture. +fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result, Vec, PreviewRead)>, String> { + use std::io::{Read, Seek, SeekFrom}; + + if total > MAX_PREVIEW_PLAN_BYTES { + return Ok(None); + } + let Ok(len) = usize::try_from(total) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?; + let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) }; + head.copy_from_slice(&header); + let mut loaded = lightcraft_raw::sparse::Ranges::new(); + loaded.insert(0, header.len() as u64); + for _ in 0..MAX_PREVIEW_PLAN_ROUNDS { + match lightcraft_raw::sparse::preview_plan(&buf, &loaded) { + lightcraft_raw::sparse::HeaderPlan::Need(ranges) => { + let wanted = loaded.total().saturating_add(ranges.total()); + if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) { + return Ok(None); + } + for &(start, end) in ranges.spans() { + let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(a..b) else { return Ok(None) }; + file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?; + loaded.insert(start, end); + } + } + lightcraft_raw::sparse::HeaderPlan::Ready => { + let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) }; + // The quick path hands the JPEG to the decoder alone, so what the *container* says + // about the preview has to be something the bytes it is handed can show. Two container + // facts change the pixels and are not in the JPEG: the preview's colour space (which + // the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries + // no orientation of its own, the file's EXIF orientation. Where either is present and + // the header cannot answer for it, the whole-file path keeps the preview — exactly as + // it already does for a container-declared colour space. A container that says sRGB + // agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other + // space declines. + if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) { + return Ok(None); + } + // `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side + // first: a JPEG that carries its own orientation needs nothing from the container. + if lightcraft_meta::extract(&window).orientation.is_none() + && lightcraft_meta::extract(&header).orientation.is_none() + && lightcraft_meta::extract(&buf).orientation.is_some() + { + return Ok(None); + } + let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true }; + // A plan-driven preview is the one that used to cost the whole file, so it is worth + // saying what it cost now too. + if lightcraft_pipeline::profiling() { + eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len()); + } + // The planner's buffer is the file's length, so give its pages back the way a decode + // does (see `fs_pair_loader`). + drop(buf); + crate::memory::release(); + return Ok(Some((header, window, read))); + } + lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None), + } + } + Ok(None) +} + /// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter), /// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in. fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result, String> { @@ -967,6 +1079,7 @@ mod tests { let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview"); assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); assert!(read.header_path, "the header path is what answered"); + assert!(!read.planned, "a RAF pointer in the header needs no planner"); assert_eq!(read.total, file.len() as u64); assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); @@ -985,6 +1098,155 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the + /// file's own orientation, and the EXIF colour space the container declares for its preview. + #[derive(Clone, Copy, Default)] + struct Fixture { + dng: bool, + sony: bool, + orientation: Option, + /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. + color_space: Option, + } + + /// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the + /// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of + /// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than + /// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. + fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut preview = IfdBuilder::new(); + if what.dng { + preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + if what.sony { + preview.set(t::MAKE, Value::Ascii("SONY".into())); + } + if let Some(o) = what.orientation { + preview.set(t::ORIENTATION, Value::Short(vec![o])); + } + if let Some(space) = what.color_space { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![space])); + if space == 0xFFFF { + let mut interop = IfdBuilder::new(); + interop.set(0x0001, Value::Undefined(b"R03".to_vec())); + exif.set_child(t::INTEROP_IFD, interop); + } + preview.set_child(t::EXIF_IFD, exif); + } + preview.set(t::COMPRESSION, Value::Short(vec![7])); + preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); + let mut raw = IfdBuilder::new(); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); + raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); + TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") + } + + /// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview + /// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges + /// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels + /// must equal the whole-file path's, exactly; a file the planner cannot describe must decline + /// rather than guess, so the caller keeps reading it whole. + #[test] + fn preview_loading_plans_the_ranges_it_needs() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan"); + let path = dir.join("preview.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview"); + assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); + assert!(read.planned, "the planner is what answered"); + assert!(read.header_path, "and it is still a bounded read"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); + assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); + + // The plan path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // The same structure without the tag that makes it a raw: the planner declines, and the + // caller's whole-file read is what answers (no guessing at what a file might hold). + let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); + std::fs::write(&path, ¬_a_raw).unwrap(); + assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A file whose preview the quick path could not show the way the whole-file path does is left to + /// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is + /// bigger than that header) and the preview JPEG carries none of its own, so a window read would + /// come out unrotated where the whole-file path rotates it — the plan must decline instead of + /// handing on a wrong picture. + #[test] + fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts + // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image + let mut strip = jpeg.clone(); + strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); + let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); + let path = dir.join("rotated.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); + assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); + assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); + assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A container can declare what its preview JPEG is in, and the whole-file path applies that + /// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so + /// a container that declares Adobe RGB must keep the whole-file path — while one that declares + /// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression + /// test, since a guard that declined every declared space did exactly that to a real Sony ARW. + #[test] + fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() { + use lightcraft_raw::PreviewColorSpace; + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let dir = crate::tests_xmp::temp_dir("preview-plan-space"); + let path = dir.join("space.arw"); + let path = path.to_string_lossy().into_owned(); + + let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); + std::fs::write(&path, &adobe).unwrap(); + assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space"); + + let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); + std::fs::write(&path, &srgb).unwrap(); + let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); + assert!(read.planned, "the plan still answers"); + assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated /// file or one that isn't an image. diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs index 11ffec44e..548e4b753 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/preview_read.rs @@ -32,10 +32,16 @@ fn a_preview_reads_only_the_header_and_the_preview() { read.total, frac * 100.0, elapsed.as_secs_f64(), - if read.header_path { " [header path]" } else { " [whole file]" } + if read.planned { + " [plan]" + } else if read.header_path { + " [header path]" + } else { + " [whole file]" + } ); - assert!(read.header_path, "the header path must answer for a raw whose pointer is in its header"); + assert!(read.header_path, "the header or the plan must answer for a raw whose preview is not the whole file"); assert_eq!(read.total, file_len, "the report covers the whole file"); assert!(frac < 0.5, "a preview must not cost most of the file: read {} of {} bytes", read.read, read.total); assert!(preview.starts_with(&[0xff, 0xd8]), "the preview bytes are a JPEG: {:?}", preview.get(..4)); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..cbb8820d8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; LR-IMP-PREVIEW-READ: CR3 and TIFF previews read the sparse planner's ranges) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -82,7 +82,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | -| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | +| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-IMP-PREVIEW-READ: a preview reads the raw's header and its embedded JPEG instead of the whole file (a 56 MB RAF: 56.5 MB → 5.9 MB, ~10 s → 0.96 s over SMB, byte-identical pixels), and an unedited raw's grid thumbnail is that camera JPEG itself, so no sensor decode is queued behind it. A CR3's JPEG track and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads; the formats it cannot describe (CRW/MRW/X3F, NEF/ORF/PEF) keep the whole-file path | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-preview-reads.md b/docs/raw-preview-reads.md index bac09ea12..fe82f4662 100644 --- a/docs/raw-preview-reads.md +++ b/docs/raw-preview-reads.md @@ -28,11 +28,12 @@ libraries. Measured on one real file (`DSCF9492.RAF`, 56.5 MB) over a GVFS/SMB s 4. When the prefix already covers the range — the usual case, since it was grown to it — the window is a slice of the prefix. Reading it again would double a 5.6 MB preview. -Formats whose preview pointer is not in the header (CR3 scans boxes; a TIFF pointer tag can sit in -an IFD the prefix cuts short; CRW/MRW/X3F are found by scanning) keep the whole-file path, so -nothing regresses: `preview_window` returns `None` and the loader reads the file as before. A pointer -that lies, a short read or a file that changes underneath us ends in the same fallback — the user's -preview is never lost to a damaged header. +Formats whose preview pointer is not in the header — a Canon CR3's boxes, a TIFF raw whose preview +tag sits in an IFD the prefix cuts short — are planned instead (next section but one), and what the +planner cannot describe keeps the whole-file path, so nothing regresses: `preview_window` returns +`None` and the loader reads the file as before. A pointer that lies, a short read, a plan that does +not fit or a file that changes underneath us ends in the same fallback — the user's preview is never +lost to a damaged header. `LIGHTCRAFT_PROFILE=1` prints what each preview cost: @@ -40,6 +41,53 @@ preview is never lost to a damaged header. [profile] preview …/DSCF9492.RAF: read 5857257 of 59277632 bytes (header 5857257, window 5857109) ``` +## What the header cannot point at: the plan + +A Canon CR3 keeps its preview in a JPEG track, and a TIFF raw (CR2, ARW, DNG, RW2, 3FR …) can keep +its preview tag in an IFD the header route's prefix cuts short. Neither has a field step 2 above can +read, so `preview_window` asks `lightcraft_raw::sparse::preview_plan` — the module the +Lightroom-catalog import probes with — which ranges `embedded_preview` reads, and reads exactly +those: + +- the buffer is the file's length with only the planned ranges filled in; every other byte stays zero + and its pages are never written. Its cost is the bytes read — on macOS a freed buffer of that size + may stay in the allocator's cache instead of going back to the OS (measured on a 38k-photo import, + [#832](https://github.com/storytold/lightcraft/issues/832)) — and the loader's memory gate counts + the whole file either way; +- the module's own guarantee is what this path needs, and its corpus test asserts it: with the + skipped bytes `0` and again `0xFF`, what a partly loaded buffer yields is what the whole file + yields. A second corpus pass now asks `preview_plan` for **every** file, including the ones whose + header probe reads samples and whose header plan is `Whole`, which is where that plan was untested + before; +- the quick path must be able to show what the whole-file path would. Two container facts are not in + the JPEG, so a file that states one where the bounded header cannot see it declines and the whole + file keeps the preview: a preview colour space of **Adobe RGB** (the whole-file path applies it + through `decode_raw_preview`; a JPEG-only decode has the JPEG's own space, else sRGB — a container + that says sRGB does not decline, since that is what the decode assumes anyway), and the file's own + EXIF orientation when the preview JPEG carries none of its own; +- a format the planner cannot describe — NEF, ORF and PEF probe from samples, CRW/MRW/X3F are found + by scanning — a plan that does not fit the buffer or wants more than 60 % of the file (the bound the + import path's own partial read uses), or a window that does not decode all answer `None`, and the + loader reads the file whole exactly as before: the plan can cost bytes but never the picture. + +Measured over the CC0 corpus (111 raws, `cargo xtask corpus --download`): **52 files take the plan +route**, reading a median **7.8 %** of their bytes (0.9 %–27.7 %) — by format RW2 3.3 %, Leica RWL +4.1 %, ARW 7.5 %, CR3 8.4 %, DNG 9.7 %, CR2 14.3 % — while the 21 RAFs stay on the header route +(median 4.5 %). The remaining 38 read whole: NEF, NRW, ORF, PEF and Samsung SRW because the planner +does not describe them (the first four say so; an SRW plans to the whole file, and the 60 % budget +then keeps the whole-file read), and the ARWs, CR2s, CR3s and DNGs that declare Adobe RGB or whose +plan wants more than that budget. Across the whole corpus the plan handed on **the same preview as +the whole file, every time** — and wrote it down: `cargo test -p lightcraft-raw --release --test +sparse_corpus -- --nocapture` prints these numbers. `LIGHTCRAFT_PROFILE=1` says which route answered: + +``` +[profile] preview …/arw-sony-a7rm4a-compressed.arw: read 688128 of 61832192 bytes (plan, window 483441) +``` + +A Canon CR3's preview is a JPEG track instead (issue #832 measured 3 MB of one R5 Mark II file, +~15 %), and Canon is the largest bucket in [gaps.md](gaps.md)'s ranking (33 % of photographers), so +before this change every unedited CR3 thumbnail paid for the whole file. + ## The grid thumbnail is the embedded JPEG too A 512 px grid thumbnail of an unedited raw now keeps the camera's own JPEG instead of rendering the @@ -66,7 +114,14 @@ render queue stayed empty. clamped prefix, a padded extent, a late pointer, a lying pointer and a non-JPEG window are all covered. - `crates/engine/src/files.rs`: on a synthetic RAF with a 200 KB tail, the quick path and the - whole-file path produce identical pixels, and the read stays under half the file. + whole-file path produce identical pixels, and the read stays under half the file. On a synthetic + TIFF/DNG whose preview only its tags locate — the plan path — the same identity holds and the CFA + strip is not read; the same structure without the tag that makes it a raw declines instead of + guessing, and the caller keeps reading it whole. So do a file whose own orientation or Adobe RGB + colour space only the whole-file path can show, while one that states sRGB stays on the fast path. +- `crates/raw/tests/sparse_corpus.rs`: `preview_plan` is asked for **every** corpus file, not only + the ones whose header plan is `Ready`, and the preview it yields must equal the whole file's with + the skipped bytes `0` and `0xFF`. The test prints what each format reads. - `crates/engine/tests/preview_read.rs` (opt-in, `#[ignore]`): the same measurement on a **real** file, typically on a share: From 103089535a17a82e863d37ca7a051710d6624896 Mon Sep 17 00:00:00 2001 From: goujicheng Date: Mon, 12 Oct 2026 00:57:30 +0800 Subject: [PATCH 026/142] LR-IMP-PREVIEW-READ: the corpus test asks preview_plan for every file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `preview_plan`'s assertions sat inside the `header_plan == Ready` branch, so a NEF, an ORF or a PEF — whose header probe reads samples and whose header plan is therefore `Whole` — never had its preview plan looked at, and neither did any file the header plan declines. The display path wants exactly that answer, so both plans are now asked for every corpus file, and the preview the plan hands on must be the whole file's with the skipped bytes `0` and again `0xFF`; `Whole` stays a legitimate answer (that format reads whole for a preview too) and is counted. The test prints what each format reads for both plans. Co-Authored-By: DeepSeek V3.2 --- crates/raw/tests/sparse_corpus.rs | 54 +++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs index 3ab735299..15e6398b2 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/sparse_corpus.rs @@ -64,45 +64,71 @@ fn partial_reads_describe_raws_as_whole_files_do() { paths.sort(); // format → (files ready, bytes read, bytes total, files read whole) let mut stats: BTreeMap = BTreeMap::new(); + let mut preview_stats: BTreeMap = BTreeMap::new(); for p in paths { let name = p.file_name().unwrap().to_string_lossy().to_string(); let file = std::fs::read(&p).unwrap(); let format = probe(&file).map_or("?".to_string(), |f| format!("{f:?}")); - let (plan, zeros, loaded) = run(&file, 0, false); + let whole = describe(&file); + let preview = embedded_preview(&file); let entry = stats.entry(format.clone()).or_default(); - match plan { - HeaderPlan::Ready => { + let header_ready = match run(&file, 0, false) { + (HeaderPlan::Ready, zeros, loaded) => { let (plan_ff, ones, loaded_ff) = run(&file, 0xff, false); assert_eq!((plan_ff, &loaded_ff), (HeaderPlan::Ready, &loaded), "{name}: the plan read skipped bytes"); - let whole = describe(&file); assert_eq!(describe(&zeros), whole, "{name}: differs from the whole file (skipped bytes 0)"); assert_eq!(describe(&ones), whole, "{name}: differs from the whole file (skipped bytes 0xFF)"); entry.0 += 1; entry.1 += loaded.total(); entry.2 += file.len() as u64; - // with the preview (for a raw shown from it), the preview is what the whole file gives too - let preview = embedded_preview(&file); - for fill in [0, 0xff] { - let (plan, buf, _) = run(&file, fill, true); - assert_eq!(plan, HeaderPlan::Ready, "{name}: preview plan"); - assert_eq!(describe(&buf), whole, "{name}: preview plan, skipped bytes {fill}"); - assert!(embedded_preview(&buf) == preview, "{name}: a different embedded preview (skipped bytes {fill})"); - } if loaded.total() * 4 > file.len() as u64 { eprintln!("{name}: read {:.0} %", loaded.total() as f64 * 100.0 / file.len() as f64); } if matches!(format.as_str(), "Cr3" | "Dng") && file.len() > 8 << 20 { assert!(loaded.total() * 4 < file.len() as u64, "{name}: read {} of {} bytes", loaded.total(), file.len()); } + true } - HeaderPlan::Whole(why) => { + (HeaderPlan::Whole(why), _, _) => { entry.3 += 1; eprintln!("{name}: whole ({why})"); + false } - HeaderPlan::Need(_) => unreachable!(), + (HeaderPlan::Need(_), _, _) => unreachable!(), + }; + // The preview plan (what the display path reads) is asked for **every** file, because a + // header probe that reads samples (a NEF, an ORF, a PEF) makes the plan above `Whole` and + // would otherwise hide its preview from this check. Ready means the preview handed on is the + // whole file's, byte for byte, with the skipped bytes zeroed and again 0xFF; `Whole` is a + // legitimate answer here (that format keeps reading whole for a preview too) and is counted. + let pentry = preview_stats.entry(format.clone()).or_default(); + match run(&file, 0, true) { + (HeaderPlan::Ready, buf, loaded_p) => { + if header_ready { + assert_eq!(describe(&buf), whole, "{name}: preview plan differs from the whole file"); + } + assert!(embedded_preview(&buf) == preview, "{name}: a different embedded preview"); + let (plan_ff, buf_ff, loaded_ff) = run(&file, 0xff, true); + assert_eq!((plan_ff, loaded_ff.total()), (HeaderPlan::Ready, loaded_p.total()), "{name}: the preview plan read skipped bytes"); + assert!(embedded_preview(&buf_ff) == preview, "{name}: a different embedded preview (skipped bytes 0xFF)"); + pentry.0 += 1; + pentry.1 += loaded_p.total(); + pentry.2 += file.len() as u64; + } + (HeaderPlan::Whole(why), _, _) => { + pentry.3 += 1; + eprintln!("{name}: preview whole ({why})"); + } + (HeaderPlan::Need(_), _, _) => unreachable!(), } } for (format, (ready, read, total, whole)) in stats { eprintln!("{format:>10}: {ready} described from {:.1} % of their bytes, {whole} read whole", read as f64 * 100.0 / total.max(1) as f64); } + for (format, (ready, read, total, whole)) in preview_stats { + eprintln!( + "{format:>10} preview: {ready} previews read from {:.1} % of their bytes, {whole} read whole", + read as f64 * 100.0 / total.max(1) as f64 + ); + } } From a1d7c488f817e23841e6e0330e2509a6988591dc Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 04:33:12 +1100 Subject: [PATCH 027/142] LR-EXP-RENDERDNG: Render to DNG export format (linear float DNG of the rendered photo) New export format `rendereddng` (export dialog: Format DNG, "DNG of: Rendered photo"; `app.export`; MCP export tool): the rendered photo, with the edit, crop, size, output sharpening and watermark baked in, written by the new `lightcraft_raw::write_rendered_dng` as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space. ColorMatrix1 (XYZ -> space), ForwardMatrix1 (space -> XYZ D50), a neutral AsShotNeutral and an identity ProfileToneCurve make raw converters show it as rendered. HDR edits keep highlights above white. Bad input (wrong sample count, zero or huge sizes, NaN/inf, out of half-float range) gives an error or is clamped, never a panic. Also fixes the Rust 1.99 clippy lint chunks_exact_to_as_chunks in pipeline/spots.rs that fails `cargo xtask ci` on main. Sources: Adobe DNG Specification 1.4+ (LinearRaw, ColorMatrix/ForwardMatrix, ProfileToneCurve); own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/export.rs | 34 +++++++++++++++- crates/mcp/src/tools.rs | 2 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/src/dngwrite.rs | 61 ++++++++++++++++++++++++++++ crates/raw/src/lib.rs | 2 +- crates/raw/tests/rendered_dng.rs | 50 +++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 10 ++++- docs/file-format-parity.md | 5 ++- docs/gaps.md | 5 ++- docs/parity-checklist.md | 1 + 10 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 crates/raw/tests/rendered_dng.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..2985949e5 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -28,6 +28,8 @@ pub enum ExportFormat { Original, /// A DNG of the raw data (raw photos only), the edits embedded as XMP. Dng, + /// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in. + RenderedDng, } impl ExportFormat { @@ -42,6 +44,7 @@ impl ExportFormat { "jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl, "original" => Self::Original, "dng" => Self::Dng, + "rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng, _ => return None, }) } @@ -56,7 +59,7 @@ impl ExportFormat { Self::Jxl => "jxl", // the original keeps its own extension (see `ExportOptions::file_name_for`) Self::Original => "", - Self::Dng => "dng", + Self::Dng | Self::RenderedDng => "dng", } } @@ -1133,6 +1136,8 @@ impl ExportOptions { (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::RenderedDng, _) => OutputDepth::F32Linear, (ExportFormat::Psd, Some(8)) => OutputDepth::U8, (ExportFormat::Psd, _) => OutputDepth::U16, (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, @@ -1151,6 +1156,7 @@ impl ExportOptions { ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], + ExportFormat::RenderedDng => &[(16, "16-bit float")], ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")], } @@ -1341,6 +1347,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta), f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), + ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()), }; r.map_err(|e| e.to_string()) } @@ -1431,6 +1438,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met /// Encode a high-bit-depth image (see [`encode_rendered`]). pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let meta_src = meta; let img = finish_deep(img, o); let profile = match img.samples { DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), @@ -1449,6 +1457,10 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) (ExportFormat::Png, _) => encode::encode_png(&e, &meta), (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), + (ExportFormat::RenderedDng, DeepSamples::F32(v)) => { + let m = meta_src.cloned().unwrap_or_default(); + return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); + } (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) } @@ -3109,6 +3121,26 @@ mod tests { } // Issue #496: photos rendered side by side give the same files, names, order and progress as + // Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture + #[test] + fn rendered_dng_matches_the_rendered_tiff() { + let mut s = crate::Session::with_demo(); + let id = s.visible()[0]; + let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap(); + assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name); + let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap(); + let raw = lightcraft_raw::decode(&dng.bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3)); + assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one"); + let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image; + assert_eq!((reference.width, reference.height), (back.width, back.height)); + // mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB + let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::() / back.data.len() as f32; + let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::() / reference.data.len() as f32; + assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}"); + } + // one at a time, and a cancel stops at the same photo #[test] fn side_by_side_batches_match_one_at_a_time() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..556e0d1a3 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, - "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng"], "description": "psd = a flat PSD (no layers); original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, + "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, "width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"}, diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 2f04f016b..ba71825fd 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -362,3 +362,64 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { } Ok(TiffWriter::new(opts.order, false).write(&[ifd])?) } + +/// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23). +fn illuminant_of(white: lightcraft_color::Xy) -> u16 { + if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 } +} + +/// Render to DNG: a linear DNG (`LinearRaw`, 16-bit float Deflate tiles) of an already rendered, +/// oriented image. `rgb` is `width × height` interleaved linear RGB in `space` (white = 1; values +/// above 1 are kept up to the half-float maximum, negative and non-finite ones become 0). The colour tags describe `space` +/// exactly — `ColorMatrix1` = XYZ → space, `ForwardMatrix1` = space → XYZ D50, a neutral +/// `AsShotNeutral` — and an identity `ProfileToneCurve` keeps raw converters from adding their +/// default tone curve on top of the baked-in edit. +pub fn write_rendered_dng( + width: usize, + height: usize, + rgb: &[f32], + space: &lightcraft_color::RgbSpace, + metadata: &lightcraft_meta::Metadata, + xmp: Option, +) -> Result> { + let n = width.checked_mul(height).and_then(|p| p.checked_mul(3)).ok_or(RawError::Limit("image too large"))?; + if width == 0 || height == 0 || width > u32::MAX as usize || height > u32::MAX as usize { + return Err(RawError::Corrupt(format!("cannot write a {width}×{height} DNG"))); + } + let src = rgb.get(..n).ok_or_else(|| RawError::Corrupt(format!("{} samples for a {width}×{height} RGB image", rgb.len())))?; + let data: Vec = src.iter().map(|&v| if v.is_finite() { v.clamp(0.0, 65504.0) } else { 0.0 }).collect(); + let full = crate::Rect::new(0, 0, width, height); + let forward = lightcraft_color::bradford(space.white, lightcraft_color::D50).mul(&space.to_xyz()); + let color = crate::ColorData { + illuminant: [illuminant_of(space.white), 0], + color_matrix: [Some(space.from_xyz()), None], + forward_matrix: [Some(forward), None], + as_shot_neutral: Some([1.0; 3]), + profile: crate::profile::ProfileLook { + tone_curve: Some(crate::profile::ToneCurve { points: vec![[0.0, 0.0], [1.0, 1.0]] }), + ..Default::default() + }, + ..Default::default() + }; + let raw = RawImage { + format: crate::RawFormat::Dng, + width, + height, + cpp: 3, + data: RawData::F32(data), + cfa: None, + bits: 16, + black: crate::BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: full, + crop: full, + orientation: lightcraft_geom::Orientation::Normal, + color, + wb_multipliers: None, + linearized: false, + opcodes: crate::OpcodeLists::default(), + metadata: metadata.clone(), + }; + let order = ByteOrder::Little; + write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9edb..50596d3a2 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -48,7 +48,7 @@ mod unpack; mod vendor; pub use demosaic::{Method, demosaic}; -pub use dngwrite::{DngCompression, DngWriteOptions, write_dng}; +pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; diff --git a/crates/raw/tests/rendered_dng.rs b/crates/raw/tests/rendered_dng.rs new file mode 100644 index 000000000..fad006fe4 --- /dev/null +++ b/crates/raw/tests/rendered_dng.rs @@ -0,0 +1,50 @@ +//! Render to DNG: a linear DNG of a rendered image reads back as the same pixels and colours. + +use lightcraft_color::{Mat3, PROPHOTO, REC2020, SRGB}; +use lightcraft_raw::{Method, color, write_rendered_dng}; + +fn image(w: usize, h: usize) -> Vec { + (0..w * h).flat_map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.25 + (i % 7) as f32 * 0.25]).collect() +} + +fn close(a: &Mat3, b: &Mat3, tol: f64) -> bool { + a.0.iter().flatten().zip(b.0.iter().flatten()).all(|(x, y)| (x - y).abs() < tol) +} + +#[test] +fn rendered_dng_round_trips_pixels_and_colour() { + let (w, h) = (300usize, 70usize); + let rgb = image(w, h); + for space in [SRGB, PROPHOTO] { + let meta = lightcraft_meta::Metadata { make: Some("Maker".into()), model: Some("Model".into()), ..Default::default() }; + let bytes = write_rendered_dng(w, h, &rgb, &space, &meta, None).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp, raw.cfa.is_none()), (w, h, 3, true)); + assert_eq!(raw.metadata.model.as_deref(), Some("Model")); + let dev = raw.develop(Method::Ahd).unwrap(); + for (p, q) in rgb.as_chunks::<3>().0.iter().zip(&dev.data) { + for k in 0..3 { + // above-white samples (1.25, 1.5, 1.75) are clipped by white level 1 on develop + let want = p[k].min(1.0); + assert!((q[k].min(1.0) - want).abs() <= want * 1e-3 + 1e-4, "{space:?} {p:?} {q:?}"); + } + } + // the camera transform is the space → Rec. 2020 matrix under a neutral white + let t = color::camera_transform(&raw, color::as_shot_white_xy(&raw)); + assert!(!t.matrix_is_fallback); + assert!(t.wb.iter().all(|v| (v - 1.0).abs() < 1e-3), "{:?}", t.wb); + assert!(close(&t.matrix, &space.to_space(&REC2020), 2e-3), "{}: {:?} vs {:?}", space.name, t.matrix, space.to_space(&REC2020)); + } +} + +#[test] +fn rendered_dng_rejects_bad_input_without_panicking() { + let m = lightcraft_meta::Metadata::default(); + assert!(write_rendered_dng(0, 4, &[], &SRGB, &m, None).is_err()); + assert!(write_rendered_dng(4, 4, &[0.5; 10], &SRGB, &m, None).is_err()); + assert!(write_rendered_dng(usize::MAX, 3, &[0.5; 10], &SRGB, &m, None).is_err()); + let odd = [f32::NAN, f32::INFINITY, -2.0, 70000.0, 0.5, 1.0]; + let raw = lightcraft_raw::decode(&write_rendered_dng(2, 1, &odd, &SRGB, &m, None).unwrap()).unwrap(); + let dev = raw.develop(Method::Bilinear).unwrap(); + assert!(dev.data.iter().flatten().all(|v| v.is_finite() && *v >= 0.0)); +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..03048231b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -678,13 +678,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); ui.add_space(4.0); let before = opts.format; + // "Rendered DNG" is a DNG option (below), not a format of its own in this list + let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; choices( ui, "Format", "exportFormat", &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")], - &mut opts.format, + &mut shown, ); + if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { + opts.format = shown; + } + if matches!(opts.format, F::Dng | F::RenderedDng) { + choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); + } if !opts.format.is_rendered() { let note = if opts.format == F::Dng { "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 79c66b69c..d4bfb8fa9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats measured from the code and the target's Info.plist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Render to DNG as an export format) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every format Lightroom reads or writes, with our support, fidelity and tests. Camera raw formats are deep enough to have their own document: [raw-parity.md](raw-parity.md). @@ -25,7 +25,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target | | GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target | | DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed or LJ92; ⬜ lossy DNG, embedded original | `crates/raw/src/dngwrite.rs` | -| Render to DNG (15.5) | — | ✅ | — | ⬜ | new in Classic 15.5 | +| Render to DNG (15.5) | — | ✅ | — | 🟡 export format "Rendered DNG" (linear float DNG, edit baked in); not yet added to the catalog | `crates/raw/src/dngwrite.rs` (`write_rendered_dng`) | | Original + sidecar | — | ✅ | — | ✅ | | | HDR gain map JPEG (ISO 21496-1) | ✅ | ✅ | ✅ (gain map read) | ✅ | `crates/codecs/src/gainmap.rs` | | PQ / HLG HDR | ✅ | ✅ AVIF, JXL, TIFF | — | 🟡 PQ AVIF, PQ JPEG XL, float TIFF; no HLG | | @@ -77,3 +77,4 @@ Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is writte |---|---|---| | 2026-10-10 | major | Created from the code and the installed Lightroom's document types | | 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR | +| 2026-10-12 | minor | Render to DNG: "Rendered DNG" export format (linear 16-bit float DNG of the rendered photo) | diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..a799b85cd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 25: Render to DNG export format; earlier: CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -39,7 +39,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | -| 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | +| 25 | **File writes**: lossy DNG; AVIF import; Render to DNG as a catalog command (the export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..1b0c6f811 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -399,6 +399,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | +| LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | | LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | From 8695c3391f3ca9d1c449e1a7b28422f50f2c251a Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 04:42:12 +1100 Subject: [PATCH 028/142] LR-PREF-DISPLAY: on Linux, previews use the display profile from colord While no monitor profile is chosen, the Linux app reads the profile the desktop assigns to the display in colord (org.freedesktop.ColorManager, system bus, via zbus) on a background thread every 3 s, and shows previews through it. With several displays it is used only when they all share one profile; without colord the view stays sRGB. Windows' WCS needs FFI and stays manual. Also satisfies clippy 1.99's chunks_exact_to_as_chunks in spots.rs. Sources: the colord D-Bus API documentation; own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/lightcraft/src/colord.rs | 125 ++++++++++++++++++++++++++++++++++ apps/lightcraft/src/main.rs | 7 +- crates/pipeline/src/spots.rs | 4 +- docs/display-profiles.md | 18 +++-- docs/gaps.md | 5 +- docs/hardware-parity.md | 7 +- docs/parity-checklist.md | 5 +- 7 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 apps/lightcraft/src/colord.rs diff --git a/apps/lightcraft/src/colord.rs b/apps/lightcraft/src/colord.rs new file mode 100644 index 000000000..70f7fbdd2 --- /dev/null +++ b/apps/lightcraft/src/colord.rs @@ -0,0 +1,125 @@ +//! The ICC profile the desktop assigns to the display (Linux, through colord). +//! +//! GNOME, KDE and most other desktops keep each monitor's profile in colord +//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a +//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile +//! is chosen in Settings ▸ Display, previews are shown through that profile +//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never +//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB. +//! +//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a +//! window is on), so the profile is used only when every display that has one has the same: a +//! mixed set stays sRGB rather than guess. + +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +/// How often colord is asked again (a profile change in the desktop's settings shows within this). +const POLL: Duration = Duration::from_secs(3); +/// A wait before trying again when colord isn't there (not installed, no system bus). +const RETRY: Duration = Duration::from_secs(30); +/// The largest profile file read (real display profiles are a few KiB to a few MiB). +const MAX_PROFILE: u64 = 16 << 20; + +const SERVICE: &str = "org.freedesktop.ColorManager"; +const DEVICE: &str = "org.freedesktop.ColorManager.Device"; +const PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + +/// Start the background reader and return the service the UI polls (`Services::screen_profile`). +pub fn service() -> Box Option>> { + let shared: Arc>>> = Arc::new(Mutex::new(None)); + let writer = Arc::clone(&shared); + let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || { + loop { + let wait = match connect() { + Some(conn) => { + let icc = read(&conn); + *writer.lock().unwrap_or_else(PoisonError::into_inner) = icc; + POLL + } + None => RETRY, + }; + std::thread::sleep(wait); + // the app is gone: nobody reads the profile any more + if Arc::strong_count(&writer) == 1 { + return; + } + } + }); + if let Err(e) = spawned { + log::warn!("screen profile: no colord reader: {e}"); + } + Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone()) +} + +fn connect() -> Option { + zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok() +} + +/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays). +fn read(conn: &zbus::blocking::Connection) -> Option> { + let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?; + let devices: Vec = manager.call("GetDevicesByKind", &("display",)).ok()?; + let mut files = Vec::new(); + for device in devices.iter().take(64) { + let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue }; + let profiles: Vec = proxy.get_property("Profiles").unwrap_or_default(); + // colord lists a device's profiles default first + let Some(first) = profiles.first() else { continue }; + let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue }; + if let Ok(file) = profile.get_property::("Filename") { + files.push(file); + } + } + let file = pick(&files)?; + load(std::path::Path::new(file)) +} + +/// The profile to use for these displays' profile files: the one they share, `None` with none or +/// with different ones (empty names are ignored). +fn pick(files: &[String]) -> Option<&str> { + let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty()); + let first = named.next()?; + named.all(|f| f == first).then_some(first) +} + +/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large. +fn load(path: &std::path::Path) -> Option> { + use std::io::Read as _; + let file = std::fs::File::open(path).ok()?; + let mut bytes = Vec::new(); + file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?; + (!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_shared_profile_is_used_and_a_mixed_set_is_not() { + let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::>(); + assert_eq!(pick(&s(&[])), None); + assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc")); + assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc")); + assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known"); + assert_eq!(pick(&s(&[""])), None); + } + + #[test] + fn profile_files_are_read_with_a_size_cap() { + let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let small = dir.join("small.icc"); + std::fs::write(&small, b"acsp").unwrap(); + assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..])); + let empty = dir.join("empty.icc"); + std::fs::write(&empty, b"").unwrap(); + assert_eq!(load(&empty), None); + assert_eq!(load(&dir.join("missing.icc")), None); + let big = dir.join("big.icc"); + std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap(); + assert_eq!(load(&big), None); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index cc71cc06d..ac1dec5fa 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -24,6 +24,8 @@ mod alloc_release; mod appearance; +#[cfg(target_os = "linux")] +mod colord; mod control_server; mod dialog_filter; mod editors; @@ -525,7 +527,10 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) #[cfg(target_os = "macos")] screen_profile: Some(Box::new(screen_profile::icc)), - #[cfg(not(target_os = "macos"))] + // Linux: the profile the desktop assigns in colord, read off the UI thread + #[cfg(target_os = "linux")] + screen_profile: Some(colord::service()), + #[cfg(not(any(target_os = "macos", target_os = "linux")))] screen_profile: None, pick_display_profile: Some(Box::new(|| { let mut d = file_dialog() diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/docs/display-profiles.md b/docs/display-profiles.md index 9d6b11af7..67892a3cb 100644 --- a/docs/display-profiles.md +++ b/docs/display-profiles.md @@ -8,17 +8,25 @@ colour-manage LightCraft's window (wgpu leaves its Metal layer without a colour unmatched), so on a P3 screen previews used to look more saturated than the exported files in Preview or Photos (issue #787). The profile is read again about once a second, so moving the window to another screen is followed. +**Linux uses the profile the desktop assigns in colord** (GNOME / KDE Settings ▸ Color, or a calibrator such as +DisplayCAL) while none is chosen. A background thread asks colord (`org.freedesktop.ColorManager` on the system bus) +every 3 s, so the UI never waits on D-Bus, and a profile change in the desktop's settings shows within that. eframe +doesn't say which monitor the window is on, so with several displays the profile is used only when all of them that +have one share it; otherwise, and without colord, previews stay sRGB (choose a profile by hand then). A compositor +that colour-manages windows itself with the same profile (KWin with an ICC profile set in Display Configuration) +would correct twice; KWin keeps that profile in its own settings, not colord, so this only happens when both are set. + ## Using it Settings ▸ Display ▸ **Choose Profile…** picks an `.icc` / `.icm` file (the dialog opens where the system keeps them: `~/.local/share/icc` on Linux, ColorSync's folders on macOS, `…\spool\drivers\color` on Windows). **Use sRGB** -(on macOS **Use Screen Profile**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) +(on macOS and Linux **Use Screen Profile**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) and applies at once; a file that can't be used is reported there and previews stay sRGB. Agents and scripts: `app.displayProfile {"path": "/path/to/monitor.icc"}` (`""` or `null`: none; no `path`: report only) returns `{path, description, kind: "matrix" | "lut", primaries: {red, green, blue: [x, y]}}`; the screen's -profile (macOS, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile` -(`apps/lightcraft/src/screen_profile.rs`); headless sessions have none and stay sRGB. +profile (macOS or Linux, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile` +(`apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`); headless sessions have none and stay sRGB. ## How it works @@ -47,5 +55,5 @@ LUT-based profile's correction about 65 ms. Drafts during slider drags skip the ## Not yet -- Picking up the profile the operating system assigns on Linux and Windows (X11 `_ICC_PROFILE`, colord, WCS) and - following the window across monitors there (macOS does both, above). +- Picking up the profile Windows assigns (WCS: needs FFI, so an isolated `unsafe` helper crate) and X11's + `_ICC_PROFILE`; following the window across monitors on Linux and Windows (macOS does both, above). diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..c47088d0f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 22: Linux reads the colord display profile); previously minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -36,7 +36,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | -| 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | +| 22 | **Pen pressure**, OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 22: Linux previews use the display profile from colord | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f1defc57..c2ad4a121 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (monitor profile from colord on Linux); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources: [gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md), @@ -12,7 +12,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra | GPU export | ✅ | ✅ (6000 × 4000 in ~0.3 s vs ~1.2 s CPU) | ✅ | ✅ | — | ✅ (black-export fix on an Intel iGPU, #78) | | AI on GPU / neural engine | Metal / DirectML / Core ML, model per feature | AI denoise on GPU or CPU (setting); SAM 3 on Metal via candle; faces on CPU | CPU / GPU denoise | CPU / GPU denoise | — | 🟡 no Core ML / Neural Engine / DirectML | | HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) | -| Monitor profile | automatic per display, follows the window | 🟡 picked by hand | 🟡 | 🟡 | — | 🟡 OS profile not read; no follow across monitors | +| Monitor profile | automatic per display, follows the window | ✅ the screen's profile, followed across screens | 🟡 picked by hand (WCS needs FFI) | 🟡 colord's profile (one display, or all sharing one); no follow across monitors | — | 🟡 Windows OS profile not read; Linux per-monitor follow missing | | Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) | | Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS | @@ -35,7 +35,7 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Work | Hours | Needs | |---|---:|---| | HDR display (EDR on macOS, Windows HDR, Linux where possible) | 10–20 | an HDR display to verify (human) | -| OS monitor profile, follow window across displays | 5–10 | multi-monitor setup | +| OS monitor profile: Windows WCS (an FFI helper crate, like `crates/sysmem`), follow the window across displays on Linux | 4–8 | multi-monitor setup | | Pen pressure for brush masks (winit tablet events or a pure-Rust tablet layer) | 6–12 | a tablet | | Remaining stages on GPU (defringe, spot removal, masks) | 10–20 | | | Tethered capture (PTP over USB in pure Rust, Canon / Nikon / Sony first) | 25–45 | cameras (human) | @@ -47,4 +47,5 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Monitor profile: Linux reads the display profile from colord; macOS row reflects #787 | | 2026-10-10 | major | Created | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..e42f110fc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-PREF-DISPLAY: the Linux desktop's colord profile); previously minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -426,7 +426,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | -| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: picking up the profile the OS assigns on Linux and Windows (X11 `_ICC_PROFILE`, colord, WCS), per-monitor profiles there | +| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: Windows' WCS profile (needs FFI, so `unsafe`), X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) | | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.download`, `cmd:faces.models.downloads`, `cmd:faces.models.downloadCancel`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/panels/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with `lightcraft-fetch`), or add any `.onnx` by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (`crates/faces/src/net.rs`, `crates/faces/src/runtime.rs`; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces; the Recognise faces switch is off until a recognition model is chosen and installed (`faces.enable {enabled: true}` refuses otherwise; removing the last recogniser switches it off), issue #678 | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-PREF-DISPLAY: on Linux, previews follow the display profile the desktop assigns in colord | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | From 482dcacd37f94885dde6a31932591a78a8fbf2b4 Mon Sep 17 00:00:00 2001 From: OneSlips Date: Sun, 11 Oct 2026 22:38:34 +1000 Subject: [PATCH 029/142] UI: targeted adjustment says how to leave it; a wheel notch scrolls 100 points A Color Mixer Hue/Saturation/Luminance (or curve) targeted-adjustment tool left on takes over every click and drag on the photo, so click-to-zoom and panning stopped working and the canvas looked frozen (user report). Esc already turned it off, but nothing said the tool was on: the mixer's buttons look like tabs. Turning one on now shows a toast: drag up or down on the photo, Esc to stop. A mouse wheel notch scrolled egui's default 40 points; an open Edit panel is 2000+ points tall. Lines now scroll 100 points (theme::WHEEL_LINE_POINTS, set in apply_kind so every theme has it), about Lightroom's step; trackpads report points and are unaffected. What assumed a 40-point notch keeps its per-notch behaviour: wheel zoom (Ctrl+wheel and the loupe's plain wheel) uses theme::WHEEL_ZOOM_PER_POINT so a notch still zooms ~1.22x, and a focused slider's wheel nudge counts one notch (WHEEL_LINE_POINTS) per step. Tests: the tool announces itself and Esc leaves it (view unchanged); three wheel notches scroll at least 300 points (120 before); every theme sets the notch and keeps the zoom per notch; wheel nudges in notch units; the slider wheel-nudge test lets egui's smoothing deliver its 120 points. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/edit.rs | 16 ++++++++++++++-- crates/ui-egui/src/tests_curve.rs | 17 +++++++++++++++++ crates/ui-egui/src/tests_masking.rs | 5 ++++- crates/ui-egui/src/tests_scroll.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 28 ++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 20 +++++++++++++------- 6 files changed, 98 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..ba2a6e89b 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1058,7 +1058,19 @@ fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) let active = app.ui.tool == tool; let id = tool.replace(':', "-"); if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { - app.ui.tool = if active { String::new() } else { tool.to_string() }; + set_tat(app, ui.ctx(), tool, active); + } +} + +/// Turn targeted-adjustment tool `tool` off (`active`) or on. Turning it on says how to use and +/// leave it: while it is on, dragging on the photo adjusts instead of zooming or panning, which is +/// easy to miss when the button looks like a tab. +fn set_tat(app: &mut LightcraftApp, ctx: &egui::Context, tool: &str, active: bool) { + if active { + app.ui.tool.clear(); + } else { + app.ui.tool = tool.to_string(); + app.toast(ctx, crate::i18n::tr("Targeted adjustment on: drag up or down on the photo · Esc to stop")); } } @@ -1100,7 +1112,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) .clicked() { - app.ui.tool = if active { String::new() } else { tool }; + set_tat(app, ui.ctx(), &tool, active); } } }); diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index ca5848bee..342e384bf 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -152,3 +152,20 @@ fn dragging_a_point_past_its_neighbour_is_clamped() { assert!(c[1].0 < c[2].0 && c[1].0 > 0.55, "clamped just left of its neighbour: {c:?}"); assert_eq!(c[2], (0.6, 0.6), "the neighbour stays: {c:?}"); } + +/// A targeted-adjustment tool takes over clicks and drags on the photo, so turning it on says so +/// (and how to leave), and Esc leaves it: one left on by mistake looked like a frozen canvas. +#[test] +fn targeted_adjustment_announces_itself_and_esc_turns_it_off() { + let mut h = curve_open("master"); + let r = h.request("ui.clickWidget", json!({"id": "icon:tat-curve"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.tool, "tat:curve"); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Esc")), "{:?}", h.app.ui.toast); + let r = h.request("ui.key", json!({"key": "esc"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.tool.is_empty(), "Esc turns the tool off: {:?}", h.app.ui.tool); + assert!(matches!(h.app.ui.view, crate::state::ViewMode::Detail), "the first Esc only leaves the tool"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..845baf2fa 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1352,9 +1352,12 @@ fn a_selected_develop_slider_accepts_wheel_nudges() { // A wheel event over the selected control adjusts exposure instead of // scrolling the right sidebar (an unselected slider leaves scrolling alone). + // (120 points is more than one notch, `theme::WHEEL_LINE_POINTS`; egui smooths it over a few frames) let result = h.request("ui.scroll", json!({"dy": 120.0}), T); assert_eq!(result["ok"], true, "{result}"); - h.step(); + for _ in 0..10 { + h.step(); + } let after = lightcraft_develop::controls::get(&develop(&h), "light.exposure").expect("exposure"); assert!(after > before, "selected exposure did not increase: {before} -> {after}"); } diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests_scroll.rs index 1eff1ec7f..d04a818a4 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests_scroll.rs @@ -208,3 +208,25 @@ fn filmstrip_shows_every_selected_photo() { assert_eq!(r["ok"], true, "{r}"); assert_eq!(fill(&mut h, second), active); } + +/// A mouse wheel notch (a line) scrolls `theme::WHEEL_LINE_POINTS`, not egui's default 40: long +/// panels took 50+ notches. +#[test] +fn a_wheel_notch_scrolls_a_hundred_points() { + let mut h = demo("photoGrid"); + let canvas = h.app.canvas_rect.expect("grid canvas"); + h.request("ui.move", json!({"x": canvas.center().x, "y": canvas.center().y}), T); + let y0 = grid_y(&h); + for _ in 0..3 { + h.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + } + idle(&mut h, 120); + let moved = grid_y(&h) - y0; + assert!(moved >= 3.0 * crate::theme::WHEEL_LINE_POINTS - 1.0, "three notches scrolled {moved} points"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a87a196ae..5ca7c2470 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -343,6 +343,13 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } +/// Points one mouse-wheel notch (a line) scrolls. +pub const WHEEL_LINE_POINTS: f32 = 100.0; + +/// Zoom per wheel point (`exp(speed × points)`): egui's 1/200 at its stock 40-point notch, rescaled so +/// one notch still zooms the same amount now that it scrolls [`WHEEL_LINE_POINTS`]. +pub const WHEEL_ZOOM_PER_POINT: f32 = 40.0 / 200.0 / WHEEL_LINE_POINTS; + fn theme_id() -> egui::Id { egui::Id::new("lightcraft-theme") } @@ -362,6 +369,14 @@ pub fn apply(ctx: &egui::Context) { pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { // Keep photo zoom shortcuts from resizing egui itself. ctx.options_mut(|o| o.zoom_with_keyboard = false); + // A mouse wheel notch scrolls 100 points (egui's default 40 took 50+ notches through an open + // Edit panel; Lightroom moves about this much per notch). Trackpads report points and are + // unaffected. + // A modifier-wheel (and the loupe's plain-wheel) zoom notch stays about 1.22x, as it was at 40 points. + ctx.options_mut(|o| { + o.input_options.line_scroll_speed = WHEEL_LINE_POINTS; + o.input_options.scroll_zoom_speed = WHEEL_ZOOM_PER_POINT; + }); let t = Tokens::for_kind(kind); ctx.data_mut(|d| { d.insert_temp(egui::Id::NULL, t); @@ -410,6 +425,19 @@ mod tests { use super::*; use crate::i18n::{Locale, set_language}; + /// Every theme scrolls 100 points a wheel notch, and a zoom notch stays egui's stock ~1.22x + /// (`exp(40 / 200)`) instead of growing with the longer scroll. + #[test] + fn a_wheel_notch_scrolls_further_but_zooms_as_before_in_every_theme() { + for kind in [ThemeKind::Charcoal, ThemeKind::Midnight, ThemeKind::Silver, ThemeKind::Paper] { + let ctx = egui::Context::default(); + apply_kind(&ctx, kind); + let (line, zoom) = ctx.options(|o| (o.input_options.line_scroll_speed, o.input_options.scroll_zoom_speed)); + assert_eq!(line, WHEEL_LINE_POINTS, "{kind:?}"); + assert!(((zoom * line).exp() - (40.0_f32 / 200.0).exp()).abs() < 1e-4, "{kind:?}: a notch zooms {}", (zoom * line).exp()); + } + } + /// #736: a Thai folder name drew boxes because only CJK craft-fonts faces were fallbacks. With a /// craft-fonts build that carries a Thai face, an English UI offers it in every family. #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0872b2ec4..cd86d5f38 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -369,10 +369,11 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } /// Convert vertical wheel movement to bounded slider steps, keeping sub-notch -/// trackpad movement between frames without amplifying a single large event. +/// trackpad movement between frames without amplifying a single large event. One mouse-wheel +/// notch ([`crate::theme::WHEEL_LINE_POINTS`]) is one step. fn wheel_nudges(remainder: &mut f32, delta: f32) -> f64 { - const POINTS_PER_STEP: f32 = 40.0; - let total = (*remainder + delta).clamp(-400.0, 400.0); + const POINTS_PER_STEP: f32 = crate::theme::WHEEL_LINE_POINTS; + let total = (*remainder + delta).clamp(-10.0 * POINTS_PER_STEP, 10.0 * POINTS_PER_STEP); let steps = (total / POINTS_PER_STEP).trunc(); *remainder = total - steps * POINTS_PER_STEP; f64::from(steps) @@ -625,13 +626,18 @@ mod tests { #[test] fn wheel_steps_accumulate_small_movements_and_preserve_direction() { + // one mouse-wheel notch is one step, whatever a notch scrolls + let notch = crate::theme::WHEEL_LINE_POINTS; let mut remainder = 0.0; - assert_eq!(wheel_nudges(&mut remainder, 15.0), 0.0); - assert_eq!(wheel_nudges(&mut remainder, 25.0), 1.0); + assert_eq!(wheel_nudges(&mut remainder, notch * 0.375), 0.0); + assert_eq!(wheel_nudges(&mut remainder, notch * 0.625), 1.0); assert_eq!(remainder, 0.0); - assert_eq!(wheel_nudges(&mut remainder, -100.0), -2.0); - assert_eq!(wheel_nudges(&mut remainder, -20.0), -1.0); + assert_eq!(wheel_nudges(&mut remainder, -notch * 2.5), -2.0); + assert_eq!(wheel_nudges(&mut remainder, -notch * 0.5), -1.0); assert_eq!(remainder, 0.0); + assert_eq!(wheel_nudges(&mut remainder, notch), 1.0, "a single notch is a single nudge"); + assert_eq!(remainder, 0.0); + assert_eq!(wheel_nudges(&mut remainder, notch * 50.0), 10.0, "a single huge event is capped"); } /// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range. From 87be53279b60aa3357825ec1c0bde2dd32756628 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:10:31 +0700 Subject: [PATCH 030/142] fix(preview): account for replaced thumbnail cache entries --- crates/preview/src/disk.rs | 16 ++++++++-------- crates/preview/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 10a7166c6..26d180ec9 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -94,20 +94,20 @@ impl DiskCache { if std::fs::create_dir_all(parent).is_err() { return; } - // know the current total before adding (the first scan must not count this file twice) - let _ = self.size(); + // Serialize the file replacement and its size accounting. A repeated render of + // the same key replaces its JPEG; it must not count as another cache entry. + let mut total = self.total.lock().unwrap_or_else(|e| e.into_inner()); + let used = total.get_or_insert_with(|| self.scan().iter().map(|f| f.1).sum()); + let old_len = std::fs::metadata(&p).map_or(0, |m| m.len()); let tmp = p.with_extension(format!("tmp{:?}", std::thread::current().id()).replace(['(', ')'], "")); if std::fs::write(&tmp, &bytes).is_err() || std::fs::rename(&tmp, &p).is_err() { let _ = std::fs::remove_file(&tmp); return; } self.writes.fetch_add(1, Ordering::Relaxed); - let over = { - let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner()); - let total = t.get_or_insert(0); - *total += bytes.len() as u64; - *total > self.budget() - }; + *used = used.saturating_sub(old_len).saturating_add(bytes.len() as u64); + let over = *used > self.budget(); + drop(total); if over { self.prune(); } diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index d733ad829..7c560782f 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -123,6 +123,34 @@ fn disk_cache_roundtrip_and_prune() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() { + let dir = temp_dir("overwrite"); + let cache = DiskCache::new(&dir, 1 << 20); + let (a, b) = (hash_bytes(b"overwrite-a"), hash_bytes(b"overwrite-b")); + let first = gradient(80, 60, 4); + cache.put(a, &first); + cache.put(b, &gradient(80, 60, 7)); + let initial = cache.size(); + + // Re-rendering the same key used to double-count bytes although the + // replacement occupied exactly the same pathname on disk. + cache.put(a, &first); + assert_eq!(cache.size(), initial); + assert!(cache.get(b).is_some()); + + // A replacement with a different encoded size must also update the total. + cache.put(a, &gradient(120, 90, 9)); + let path = |key: Hash128| { + let hex = key.to_string(); + dir.join(&hex[..2]).join(format!("{hex}.jpg")) + }; + let actual = std::fs::metadata(path(a)).unwrap().len() + std::fs::metadata(path(b)).unwrap().len(); + assert_eq!(cache.size(), actual); + cache.clear(); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn preview_cache_memory_then_disk() { let dir = temp_dir("pc"); From 2c3d7a743f693193a6a8b8167fc2bc68d4a285ae Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 05:13:02 +1100 Subject: [PATCH 031/142] Control channel: give up a request the app draws no frames for, never run it late (#718) On Wayland a window on a hidden workspace or minimized gets no frame callbacks, so queued control requests waited 60 s and then ran when the window came back, doubling retried work. The server now abandons a request the app hasn't started after 5 s without a frame, says why, and the app skips abandoned requests. Running requests keep the full 60 s. Also as_chunks in spots.rs for the newer clippy lint. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/lightcraft/src/control_server.rs | 96 ++++++++++++++++++++++++++- crates/pipeline/src/spots.rs | 4 +- crates/ui-egui/src/control.rs | 24 ++++++- crates/ui-egui/src/lib.rs | 5 ++ docs/control-protocol.md | 8 ++- 5 files changed, 132 insertions(+), 5 deletions(-) diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index 16bd5df0f..537c75bd1 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -55,16 +55,65 @@ fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Con } } +/// How long a request waits for its reply. +const REPLY_TIMEOUT: Duration = Duration::from_secs(60); +/// A request the app has not picked up while it drew no frame for this long is given up: the +/// window is not being redrawn (on Wayland, a window on a hidden workspace or minimized gets no +/// frame callbacks, so the app's loop is paused: issue #718). +const NO_FRAMES_AFTER: Duration = Duration::from_secs(5); + +/// The reply for a request given up because the app drew no frames. +pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \ +(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again"; + fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { let Ok(read) = stream.try_clone() else { return }; serve_lines(read, stream, |method, params| { let (req, rrx) = ControlRequest::new(method, params); + let state = Arc::clone(&req.state); tx.send(req).ok()?; ctx.request_repaint(); - Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))) + Some(await_reply(&rrx, &state, || ctx.cumulative_pass_nr(), || ctx.request_repaint(), NO_FRAMES_AFTER, REPLY_TIMEOUT)) }); } +/// Wait for a request's reply. While the app has not picked the request up and `frames` (its +/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late) +/// and the reply says why; a request the app is running gets the whole `timeout`. +fn await_reply( + rrx: &Receiver, + state: &std::sync::atomic::AtomicU8, + frames: impl Fn() -> u64, + wake: impl Fn(), + no_frames: Duration, + timeout: Duration, +) -> Value { + let start = std::time::Instant::now(); + let slice = (no_frames / 10).max(Duration::from_millis(1)); + let (mut last, mut since) = (frames(), std::time::Instant::now()); + loop { + match rrx.recv_timeout(slice) { + Ok(v) => return v, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + let now = frames(); + if now != last { + (last, since) = (now, std::time::Instant::now()); + } + let taken = state.load(Ordering::SeqCst) == lightcraft_ui_egui::control::TAKEN; + if !taken && since.elapsed() >= no_frames && ControlRequest::abandon(state) { + return json!({"ok": false, "error": HIDDEN_WINDOW_ERROR}); + } + if start.elapsed() >= timeout { + // not run yet: make sure it never is; already running: it may still finish + ControlRequest::abandon(state); + return json!({"ok": false, "error": "timeout"}); + } + wake(); + } +} + /// One parsed line. enum Line { Request { @@ -160,6 +209,51 @@ fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(St mod tests { use super::*; + /// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given + /// up with a reason, quickly, and the app then skips it instead of running it late. + #[test] + fn a_request_to_an_app_drawing_no_frames_is_given_up_with_a_reason() { + let (req, rrx) = ControlRequest::new("ui.inspect", json!({})); + let t0 = std::time::Instant::now(); + let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10)); + assert!(t0.elapsed() < Duration::from_secs(2)); + assert_eq!(r["ok"], false); + assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}"); + assert!(!req.take(), "the app does not run it once it shows up"); + } + + /// A request the app is running is not given up because frames stopped (a long command + /// blocks the frame loop), and its reply arrives. + #[test] + fn a_request_being_run_waits_for_its_reply() { + let (req, rrx) = ControlRequest::new("export.run", json!({})); + assert!(req.take()); + let reply = req.reply.clone(); + let worker = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + let _ = reply.send(json!({"ok": true, "result": 1})); + }); + let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(20), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + worker.join().unwrap(); + } + + /// Frames still coming: the request just waits, then is answered. + #[test] + fn a_drawing_app_is_waited_for() { + let (req, rrx) = ControlRequest::new("ui.inspect", json!({})); + let n = std::sync::atomic::AtomicU64::new(0); + let reply = req.reply.clone(); + let state = Arc::clone(&req.state); + let worker = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(150)); + let _ = reply.send(json!({"ok": true, "result": 2})); + }); + let r = await_reply(&rrx, &state, || n.fetch_add(1, Ordering::SeqCst), || {}, Duration::from_millis(30), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + worker.join().unwrap(); + } + fn run(input: &[u8]) -> (Vec, Vec) { let mut out = Vec::new(); let mut calls = Vec::new(); diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..c16b414af 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -26,12 +26,34 @@ pub struct ControlRequest { pub method: String, pub params: Value, pub reply: Sender, + /// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the + /// sender gave up on it, so the app must not run it late: issue #718). + pub state: std::sync::Arc, } +/// Queued, not yet run. +pub const PENDING: u8 = 0; +/// The app has started it: it will answer. +pub const TAKEN: u8 = 1; +/// Its sender gave up waiting (the window drew no frames): it is dropped unrun. +pub const ABANDONED: u8 = 2; + impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx }, rx) + (Self { method: method.into(), params, reply: tx, state: Default::default() }, rx) + } + + /// Claim the request for running; false when its sender already gave up on it. + pub fn take(&self) -> bool { + use std::sync::atomic::Ordering; + self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN + } + + /// Give up on the request; false when the app has already started it (it will answer). + pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool { + use std::sync::atomic::Ordering; + state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..87eb03d5b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -789,6 +789,11 @@ impl LightcraftApp { fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { + // a request its sender gave up on (the window drew nothing for a while) is not run + // late: it would happen twice once the sender retries (issue #718) + if !req.take() { + continue; + } let reply = req.reply.clone(); match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf648..e46897418 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -9,7 +9,13 @@ ← {"id": 2, "ok": false, "error": "unknown command `nope`"} ``` -Requests are answered on the UI thread between frames (timeout 60 s). +Requests are answered on the UI thread between frames (timeout 60 s). A request is given up +if the app has not started it and draws no frame for 5 s. The reply is then `{"ok": false, "error": "the app drew no frame for 5 s, …"}`, +and the app never runs that request later, so a retry doesn't do it twice. This happens on +Wayland when the window is on a hidden workspace or minimized: the compositor sends no frame +callbacks, so the app's loop is paused. Keep the window on screen while you drive it, or use +headless snapshots or `lightcraft-cli mcp` without `--connect`. A request that has already started +(a long command blocks frames) still gets the full 60 s. **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without From 73098bf5a3a3e1e2e469fefcc9cf9d8e1c35ba7a Mon Sep 17 00:00:00 2001 From: Bobby Jose <11406650+josenbobby@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:13:06 -0400 Subject: [PATCH 032/142] LR-IMP-FORMATS: EOS R6 Mark III as-shot white balance (ColorData version 66) Version 66 keeps version 64's as-shot white balance and black level offsets; its word 661 is below where the sensor clips, so the white level stays measured from the data. Checked on 33 R6 Mark III files from 12 shoots: all now open with their as-shot white balance, black levels match the masked columns. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017JKdR4oZ3jRSPPwMLLTa2U --- crates/raw/src/vendor/cr3.rs | 23 ++++++++++++++++++----- docs/cr3.md | 8 ++++++++ docs/parity-checklist.md | 5 +++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 5c9f8ee9a..33e4f4944 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -135,13 +135,21 @@ struct CameraLevels { /// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not /// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout. +/// +/// Version 66 (EOS R6 Mark III) keeps version 64's as-shot WB and black level offsets, measured on +/// 33 files from 12 shoots: the as-shot quadruple is followed by its colour temperature and then +/// the fixed preset table (Daylight 5200 K, Shade 7000 K, ...), and the black levels equal the +/// masked-column levels in every file. Its word 661 (14351 or 13995) is not the clipping point: +/// highlights pile up at 16383 and run smoothly through the range between, so the white level is +/// measured from the data instead. fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() }; let offsets = match values.first().copied() { - Some(16..=19) => (71, 329, 797), - Some(32 | 33) => (85, 343, 811), - Some(34 | 48) => (105, 363, 641), - Some(64 | 65) => (105, 383, 661), + Some(16..=19) => (71, 329, Some(797)), + Some(32 | 33) => (85, 343, Some(811)), + Some(34 | 48) => (105, 363, Some(641)), + Some(64 | 65) => (105, 383, Some(661)), + Some(66) => (105, 383, None), _ => return CameraLevels::default(), }; let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32; @@ -160,7 +168,7 @@ fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { }); let black = quadruple(offsets.1).filter(|v| v.iter().any(|&b| b > 0.0) && v.iter().all(|&b| b >= 0.0 && b < full * 0.5)); let max_black = black.map(|v| v.into_iter().fold(0.0f32, f32::max)).unwrap_or(0.0); - let white = values.get(offsets.2).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full); + let white = offsets.2.and_then(|at| values.get(at)).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full); CameraLevels { wb, black, white } } @@ -526,6 +534,11 @@ mod tests { assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); assert_eq!(levels.white, Some(15000.0)); } + // Version 66 (EOS R6 Mark III): as-shot WB and black from the table, white from the data. + let levels = camera_levels(Some(&maker(66, 105, 383, 661)), 14); + assert_eq!(levels.wb, Some([2.0, 1.0, 1.5])); + assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); + assert_eq!(levels.white, None); assert!(camera_levels(Some(&maker(255, 71, 329, 797)), 14).wb.is_none()); assert!(camera_levels(None, 14).black.is_none()); } diff --git a/docs/cr3.md b/docs/cr3.md index 147a83081..138b5d463 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -5,6 +5,14 @@ The CR3 adapter reads the CRX Bayer track, `CMP1` compression descriptor, black/white levels and the default crop come from the file. A Dual Pixel delta track is never substituted for the primary Bayer track. +The EOS R6 Mark III writes `ColorData` version 66, which keeps version 64's as-shot white balance (word 105) and +black level (word 383) offsets. Measured on 33 files from 12 shoots (ISO 100–12800, 1/1250 s–25 s): the as-shot +R, G, G, B quadruple is followed by its colour temperature (3271–7424 K across the shoots) and then the fixed +preset table (Daylight 5200 K, Shade 7000 K, Cloudy 6000 K, Tungsten 3200 K, ...), and the black levels equal the +masked-column levels in every file (512 at low ISO, 2047–2048 above). Word 661 holds 14351 or 13995, but it is not +the clipping point: highlights pile up at 16383 and run smoothly through the range above 14351, so version 66 +takes its white level from the data. + The in-camera aspect ratio (3:2, 4:3, 16:9, 1:1) is not applied to the sensor data. The maker note's `AspectInfo` (tag `0x009a`: aspect code, cropped width, height, left, top) gives the shot's rectangle inside the recommended crop, and the decoder uses it as the default crop, so the developed image has the shape the photographer diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..5a601d2c8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-IMP-FORMATS: EOS R6 Mark III as-shot white balance; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | +| 2026-10-11 | minor | LR-IMP-FORMATS: EOS R6 Mark III CR3 opens with its as-shot white balance and black level (`ColorData` version 66); status unchanged (🟡) | | 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) | | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | From fdccb413cd8887fce4e848ea396c50bdbcab11ed Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:15:34 +0700 Subject: [PATCH 033/142] fix(import): avoid scanning symlink aliases into the library --- crates/engine/src/walk.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 479c877cf..4c35b66bb 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -57,6 +57,8 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl if let Ok(c) = std::fs::canonicalize(root) { linked.insert(c); } + // Include aliases to the library in the exclusion, not just its lexical path. + let skip_canonical = skip.and_then(|p| std::fs::canonicalize(p).ok()); // (path, depth of the folder it is in, is a folder); popped in path order let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)]; while let Some((path, depth, is_dir)) = stack.pop() { @@ -66,7 +68,9 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl } continue; } - if skip.is_some_and(|s| path == s) { + if skip.is_some_and(|s| path == s) + || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) + { continue; } if depth >= limits.max_depth { @@ -165,6 +169,31 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Folder imports must not revisit the user's own library through symlinks + /// or through an alias to the library's parent. + #[cfg(unix)] + #[test] + fn skipped_library_stays_skipped_through_symlinks_and_ancestors() { + let d = tmp("skip-alias"); + let root = d.join("incoming"); + let library = d.join("library"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(library.join("Originals")).unwrap(); + std::fs::write(root.join("ordinary.jpg"), b"photo").unwrap(); + std::fs::write(library.join("Originals").join("library-only.jpg"), b"original").unwrap(); + std::os::unix::fs::symlink(&library, root.join("library-link")).unwrap(); + std::os::unix::fs::symlink(&d, root.join("parent-link")).unwrap(); + + let photos = files_in(&root, Some(&library), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg")); + assert!(photos.files.contains(&root.join("ordinary.jpg"))); + assert!(!photos.files.iter().any(|p| p.ends_with("library-only.jpg")), "{photos:?}"); + + // Scanning a subfolder already inside the excluded library is also forbidden. + let inside = files_in(&library.join("Originals"), Some(&library), Limits::default(), |_| true); + assert!(inside.files.is_empty(), "{inside:?}"); + let _ = std::fs::remove_dir_all(&d); + } + /// A link back up the tree (a loop) is followed at most once: the walk ends. #[cfg(unix)] #[test] From 1397c4b42ad74a2be6ba4c409cbd69cda12ed21a Mon Sep 17 00:00:00 2001 From: Bobby Jose <11406650+josenbobby@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:16:02 -0400 Subject: [PATCH 034/142] LR-IMP-FORMATS: shorten the ColorData version 66 comments Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017JKdR4oZ3jRSPPwMLLTa2U --- crates/raw/src/vendor/cr3.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 33e4f4944..7c93e3bac 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -135,13 +135,7 @@ struct CameraLevels { /// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not /// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout. -/// -/// Version 66 (EOS R6 Mark III) keeps version 64's as-shot WB and black level offsets, measured on -/// 33 files from 12 shoots: the as-shot quadruple is followed by its colour temperature and then -/// the fixed preset table (Daylight 5200 K, Shade 7000 K, ...), and the black levels equal the -/// masked-column levels in every file. Its word 661 (14351 or 13995) is not the clipping point: -/// highlights pile up at 16383 and run smoothly through the range between, so the white level is -/// measured from the data instead. +/// A version without a white-level offset takes its white level from the data (see `docs/cr3.md`). fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() }; let offsets = match values.first().copied() { @@ -534,7 +528,7 @@ mod tests { assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); assert_eq!(levels.white, Some(15000.0)); } - // Version 66 (EOS R6 Mark III): as-shot WB and black from the table, white from the data. + // A version without a white-level offset leaves the white level to the data. let levels = camera_levels(Some(&maker(66, 105, 383, 661)), 14); assert_eq!(levels.wb, Some([2.0, 1.0, 1.5])); assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); From ba4533398665fa8686dd91d83a69c85d827bf558 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:17:30 +0700 Subject: [PATCH 035/142] feat(shortcuts): support Lightroom Classic F6 and F7 panel toggles --- crates/ui-egui/src/shortcuts.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9e..8150cf019 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -198,6 +198,10 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("Shift+E", "dialog.export", "{}"), ("Cmd+E", "app.exportPrevious", "{}"), ("Space", "view.zoomToggle", "{}"), + // Lightroom Classic: F6 toggles the filmstrip, F7 the left library panel. + // Keep existing primary shortcuts and honour custom keymap assignments. + ("F6", "view.filmstrip", "{}"), + ("F7", "view.leftPanel", "{}"), ("Shift+M", "version.create", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#), @@ -620,6 +624,20 @@ mod tests { } } + #[test] + fn classic_f6_and_f7_toggle_the_filmstrip_and_left_panel() { + let mut h = library(); + let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); + key(&mut h, "F6", false); + assert_eq!(h.app.ui.filmstrip, !filmstrip); + assert_eq!(h.app.ui.left_panel, left); + key(&mut h, "F7", false); + assert_eq!(h.app.ui.left_panel, !left); + key(&mut h, "F6", false); + key(&mut h, "F7", false); + assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); + } + #[test] fn shift_culling_keys_advance_exactly_once() { use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; From 6cde09f38f1f01de66a667ffa0a380ae5ad0c211 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:31:18 +0700 Subject: [PATCH 036/142] fix(i18n): translate masking context menu actions --- crates/ui-egui/src/panels/masking.rs | 36 +++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..b3716f288 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -296,7 +296,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (kind, icon) = kind_label(&c.shape); let label = c.name.clone().unwrap_or_else(|| match &c.shape { MaskShape::Prompt { text, .. } => format!("“{text}”"), - _ => kind.to_string(), + _ => menu_label(kind).to_string(), }); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -403,10 +403,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = Stroke::NONE; } +/// Translate fixed action labels, but never mutate or translate user-entered mask names. +fn menu_label(source: &str) -> &str { + crate::i18n::tr(source) +} + /// The right-click menu of a mask in the Masks list. fn mask_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: u32, name: &str, visible: bool, index: usize, count: usize) { let mut run = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, p: serde_json::Value| { - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + if ui.add_enabled(enabled, egui::Button::new(menu_label(label))).clicked() { let _ = app.run(cmd, p); ui.close(); } @@ -439,10 +444,10 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Overlay")).color(t.text_dim)); - let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label); + let r = crate::widgets::dropdown(ui, "maskOverlayMode", menu_label(view.label()), t.font(12.5), t.text_label); egui::Popup::menu(&r).show(|ui| { for v in MaskView::ALL { - if ui.selectable_label(v == view, v.label()).clicked() { + if ui.selectable_label(v == view, menu_label(v.label())).clicked() { let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()})); } } @@ -633,7 +638,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape /// The right-click / "…" menu of one component of the selected mask. fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: usize, label: &str, count: usize) { let mut run = |ui: &mut egui::Ui, text: &str, p: serde_json::Value| { - if ui.button(text).clicked() { + if ui.button(menu_label(text)).clicked() { let mut p = p; p["id"] = json!(mask); p["component"] = json!(k); @@ -799,7 +804,7 @@ fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { if kind == "brush" && op == "intersect" { continue; } - let b = ui.button(label); + let b = ui.button(menu_label(label)); register(ui.ctx(), format!("maskComp:{op}:{kind}"), b.rect); if b.clicked() { if kind == "brush" { @@ -853,6 +858,25 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn masking_context_actions_use_catalog_translations() { + let saved = crate::i18n::language(); + crate::i18n::set_language(crate::i18n::Locale::Fr); + for (english, translated) in [ + ("Duplicate Mask", "Dupliquer le masque"), + ("Invert Mask", "Inverser le masque"), + ("Hide Mask", "Masquer le masque"), + ("Move Up", "Monter"), + ("Brush", "Pinceau"), + ("Luminance Range", "Plage de luminance"), + ] { + assert_eq!(menu_label(english), translated, "{english}"); + } + // An arbitrary user name has no catalog entry and is left unchanged. + assert_eq!(menu_label("My custom mask"), "My custom mask"); + crate::i18n::set_language(saved); + } + /// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label /// room by dropping a column rather than drawing it past its tile. #[test] From b453f587be04b67674528fc2c34503ef8f817ecc Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:33:55 +0700 Subject: [PATCH 037/142] feat(curve): overlay the current histogram behind tone curves --- crates/ui-egui/src/panels/edit.rs | 94 ++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..08d577552 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -521,17 +521,21 @@ fn section( // ------------------------------------------------------------------------------ histogram -fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - // the loupe render of this photo, else its thumbnail (grid view before the loupe has rendered it) - let hist = app - .renderer +/// The current photo's existing preview histogram, shared by Histogram and Tone Curve. +fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option { + app.renderer .textures .get(&Slot::Main) .filter(|t| t.photo == id) .and_then(|t| t.histogram.clone()) - .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())); + .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())) +} + +fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + // the loupe render of this photo, else its thumbnail + let hist = preview_histogram(app, id); // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label // row under the plot let hdr = hist.as_ref().and_then(|h| h.hdr); @@ -740,6 +744,52 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // ------------------------------------------------------------------------------ tone curve +/// A 256-point, peak-normalized display profile of the photo histogram. +/// For HDR, only the SDR portion maps to a curve's input (0..1). +fn curve_histogram_profile(h: &lightcraft_raster::Histogram, channel: &str) -> [f32; 256] { + let source = match channel { + "red" => &h.r, + "green" => &h.g, + "blue" => &h.b, + _ => &h.luma, + }; + let bins = h.hdr.map_or(256, |hdr| hdr.from.clamp(2, 256)); + let samples: [f32; 256] = std::array::from_fn(|i| { + let j = i * (bins - 1) / 255; + let start = j.saturating_sub(2); + let end = (j + 3).min(bins); + let sum: f32 = (start..end).map(|k| source.get(k).copied().unwrap_or(0) as f32).sum(); + sum / (end - start) as f32 + }); + let peak = samples.iter().copied().fold(1.0_f32, f32::max); + samples.map(|v| (v / peak).sqrt()) +} + +/// Show the currently rendered photo's tonal distribution behind the editable +/// curve, without requesting another render or blocking pointer interactions. +fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histogram, channel: &str) { + let profile = curve_histogram_profile(h, channel); + let color = match channel { + "red" => hex("#dd3333"), + "green" => hex("#33bb55"), + "blue" => hex("#3377ee"), + _ => Color32::from_gray(130), + } + .gamma_multiply(0.27); + let mut mesh = Mesh::default(); + for (i, height) in profile.into_iter().enumerate() { + let x = r.left() + r.width() * i as f32 / 255.0; + let top = pos2(x, r.bottom() - r.height() * height); + mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color }); + mesh.vertices.push(Vertex { pos: pos2(x, r.bottom()), uv: Pos2::ZERO, color }); + if i > 0 { + let k = i as u32 * 2; + mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); + } + } + p.add(mesh); +} + fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { let t = Tokens::get(ui.ctx()); // channel selector @@ -796,6 +846,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev resp.context_menu(|ui| curve_reset_menu(app, ui, &ch)); let p = ui.painter(); p.rect_filled(r, 2.0, t.canvas); + if let Some(hist) = preview_histogram(app, id) { + draw_curve_histogram(p, r, &hist, &ch); + } for i in 1..4 { let f = i as f32 / 4.0; p.line_segment( @@ -1435,3 +1488,30 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { }); }); } + +#[cfg(test)] +mod curve_histogram_tests { + use super::*; + + #[test] + fn curve_backdrop_tracks_luma_and_selected_channel() { + let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| { + if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] } + }); + let h = lightcraft_raster::Histogram::of_srgb8(&img); + let red = curve_histogram_profile(&h, "red"); + let green = curve_histogram_profile(&h, "green"); + let luma = curve_histogram_profile(&h, "master"); + assert!(red[32] > red[150] && red[224] > red[150]); + assert!(green[100] > green[32] && green[100] > green[224]); + assert!(luma.iter().any(|v| *v > 0.0)); + } + + #[test] + fn hdr_backdrop_uses_only_the_sdr_bins_and_never_overflows() { + let h = lightcraft_raster::Histogram::of_hdr(&[[0.1, 0.1, 0.1], [1.0, 1.0, 1.0], [8.0, 8.0, 8.0]], 4.0); + let values = curve_histogram_profile(&h, "parametric"); + assert!(values.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + assert!(values.iter().any(|v| *v > 0.0)); + } +} From 83e6108346cb90382163bda2a9bdb92939376e44 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:35:45 +0700 Subject: [PATCH 038/142] fix(i18n): translate library context and zoom menu labels --- crates/ui-egui/src/panels/bottombar.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/panels/grid.rs | 21 ++++++++++++++++++--- crates/ui-egui/src/panels/topbar.rs | 4 ++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 65c25c6b5..874df26cc 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -215,7 +215,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo ("200%", Zoom::Percent(200.0)), ("400%", Zoom::Percent(400.0)), ] { - if ui.selectable_label(app.ui.zoom == z, label).clicked() { + if ui.selectable_label(app.ui.zoom == z, crate::i18n::tr(label)).clicked() { app.ui.zoom = z; app.ui.pan = (0.5, 0.5); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..5412bea5f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -756,7 +756,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { resp.context_menu(|ui| { ui.menu_button(crate::i18n::tr("Zoom"), |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run(cmd, json!({})); } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..c6fe9a87b 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -976,7 +976,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.menu_button(crate::i18n::tr("Set Flag"), |ui| { for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { let _ = app.run("photo.flag", json!({"flag": f})); } } @@ -1065,7 +1065,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("Split Stack", "stack.split", stacked), ("Expand/Collapse Stack", "stack.toggle", stacked), ] { - if ui.add_enabled(on, egui::Button::new(label)).clicked() { + if ui.add_enabled(on, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(cmd, json!({})); } } @@ -1090,7 +1090,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| { let n = app.session.targets(&json!({})).len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { - if ui.add_enabled(n >= 2, egui::Button::new(label)).clicked() { + if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(id, json!({})); } } @@ -1223,6 +1223,21 @@ mod tests { } } + #[test] + fn library_context_menu_labels_have_french_translations() { + let saved = crate::i18n::language(); + crate::i18n::set_language(crate::i18n::Locale::Fr); + for (label, expected) in [ + ("Pick", "Sélectionnée"), + ("Reject", "Rejeter"), + ("Group into Stack", "Regrouper en pile"), + ("HDR Panorama…", "Panorama HDR…"), + ] { + assert_eq!(crate::i18n::tr(label), expected); + } + crate::i18n::set_language(saved); + } + #[test] fn square_grid_groups_and_auto_levels() { let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index a95805f83..4b7017f33 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -140,7 +140,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let fresp = fresp.on_hover_text(if active > 0 { crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) } else { - "Filter bar".into() + crate::i18n::tr("Filter bar").into() }); if fresp.clicked() { let _ = app.run("view.filterBar", json!({})); @@ -166,7 +166,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("bell", Icon::Bell, "Activity", "panel.activity"), ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); register(ui.ctx(), format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { From b538b23d40b4062df1f11c0a87e6e62b2bbfdb23 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 05:52:43 +1100 Subject: [PATCH 039/142] Gap 27: CPU-GPU equivalence verified on an AMD Radeon 8060S (Vulkan, Linux) First non-Apple GPU verification: 16 tests / 135 cases pass, 124 at max 1 LSB, 10 bit-identical, one at max 2 LSB (0.0002% of pixels), worst mean 0.0003 LSB. Recorded in gpu-pipeline.md, hardware-parity.md and gaps.md. Also as_chunks in spots.rs for the newer clippy lint. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pipeline/src/spots.rs | 4 ++-- docs/gaps.md | 5 +++-- docs/gpu-pipeline.md | 6 ++++++ docs/hardware-parity.md | 9 ++++++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..45d25c306 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 27: GPU equivalence verified on an AMD Radeon on Linux) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -41,7 +41,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | -| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | +| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 27: the CPU ↔ GPU equivalence suite passes on an AMD Radeon 8060S (Vulkan, Linux), the first non-Apple GPU verified; 15–30 h → 13–27 h; gap not closed | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 883c3a0bf..d458866c0 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -232,6 +232,12 @@ from outside the window: the vignette geometry arrives as the `VIG_VIEW` paramet noise-reduction size come from the whole frame, and a window that spots or Auto Mask strokes grew is cut back after the readback. The tests skip (pass with a note) when no adapter exists. +**AMD Radeon 8060S (RADV STRIX_HALO, Vulkan 1.4, Mesa 26.2.4, Linux 7.2), 2026-10-12**, the first +non-Apple GPU verified: all 16 tests pass, 135 compared cases. 124 differ by max 1 LSB, 10 are +bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002% of pixels. Worst +mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds +(`cargo test -p lightcraft-gpu --release --test equivalence -- --nocapture`). + Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on Metal and running-sum order in the box filters — all far below one 8-bit step. diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f1defc57..849407bb6 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (first non-Apple GPU verification: AMD Radeon 8060S on Linux) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources: [gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md), @@ -8,7 +8,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra | Feature | Target | LightCraft: macOS | Windows | Linux / BSD | Web | Status | |---|---|---|---|---|---|---| -| GPU for display and editing | Metal / DX12, "Use GPU for display / image processing / export" | wgpu Metal: geometry, resize, blur, WB, NR, guided filter, dehaze, masks, finish; CPU fallback per render | DX12 only (Vulkan opt-in, #136) | Vulkan | CPU only (no WebGPU yet) | 🟡 defringe, spot removal, heuristic masks, long brushes and high-bit exports stay on CPU | +| GPU for display and editing | Metal / DX12, "Use GPU for display / image processing / export" | wgpu Metal: geometry, resize, blur, WB, NR, guided filter, dehaze, masks, finish; CPU fallback per render | DX12 only (Vulkan opt-in, #136) | Vulkan (CPU ↔ GPU equivalence verified on an AMD Radeon 8060S, RADV) | CPU only (no WebGPU yet) | 🟡 defringe, spot removal, heuristic masks, long brushes and high-bit exports stay on CPU | | GPU export | ✅ | ✅ (6000 × 4000 in ~0.3 s vs ~1.2 s CPU) | ✅ | ✅ | — | ✅ (black-export fix on an Intel iGPU, #78) | | AI on GPU / neural engine | Metal / DirectML / Core ML, model per feature | AI denoise on GPU or CPU (setting); SAM 3 on Metal via candle; faces on CPU | CPU / GPU denoise | CPU / GPU denoise | — | 🟡 no Core ML / Neural Engine / DirectML | | HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) | @@ -28,7 +28,9 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra **~40% ready** (estimated): the GPU editing path is real and fast (measured: 24 MP exposure drag 3.9 ms on GPU vs 22–42 ms CPU; NR drag 18 ms vs 301–471 ms), but HDR display, pen pressure, tethering, printing, automatic monitor profiles and neural-engine inference are missing. GPU -correctness is proven on Apple GPUs; other vendors rely on user reports. +correctness is proven on Apple GPUs and on one AMD GPU on Linux (Radeon 8060S, Vulkan / RADV: the +equivalence suite passes with the same max 1–2 LSB as on Metal, [gpu-pipeline.md](gpu-pipeline.md#correctness-cpu-oracle-and-equivalence-tests)). +NVIDIA, Intel and Windows DX12 still rely on user reports. ## Remaining (Opus 5.5 agent hours) @@ -47,4 +49,5 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | CPU ↔ GPU equivalence suite run on an AMD Radeon 8060S (Vulkan, RADV, Linux): 16 tests / 135 cases pass, max 2 LSB, mean ≤ 0.0003; first GPU verified outside Apple | | 2026-10-10 | major | Created | From ed737afda061a5d3170f4ae5c5295ce950301466 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 21:17:12 +0200 Subject: [PATCH 040/142] fix: a folder listed twice in Local can't be collapsed (#1093) Local lists a folder once per place it is in: Pictures as a place of its own and again inside Home. Each Local row keyed its open state and its triangle's egui id by the folder's path alone, so once Home was open the two Pictures triangles shared one id: the copy drawn last took the clicks of the copy drawn first, and the first Pictures could not be collapsed. The open state was shared too, so both copies opened and closed together. The tree now keys both by the top-level place it hangs from as well, so every row opens and closes by itself. Regression test: two Local places, one inside the other, and a click on each copy's triangle. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/left.rs | 12 ++++++--- crates/ui-egui/src/tests_masking.rs | 42 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..258a804b7 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -798,7 +798,7 @@ fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { for (i, (name, path)) in local.places.iter().enumerate() { let transient = local.browse_root.as_deref() == Some(path.as_str()); let reveal = local.owner == Some(i); - folder_tree(app, ui, name, path, 0.0, current, reveal, transient); + folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); } if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() { @@ -990,10 +990,14 @@ pub(crate) fn fs_cached(ui: &egui::Ui, kind: &'static /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the /// way down to it open whenever the browsed folder changes, so it shows highlighted in place. /// `transient`: a top-level row listed for this session only (it offers Keep in Local). +/// `place`: the top-level folder this tree hangs from. A folder can be listed in more than one +/// tree (Pictures on its own and inside Home): each row opens and closes by itself, and their +/// triangles have ids of their own, else the copy drawn last takes the other's clicks (#1093). #[allow(clippy::too_many_arguments)] fn folder_tree( app: &mut LightcraftApp, ui: &mut egui::Ui, + place: &str, name: &str, path: &str, indent: f32, @@ -1001,13 +1005,13 @@ fn folder_tree( reveal: bool, transient: bool, ) { - let open_id = egui::Id::new(("folder-open", path.to_string())); + let open_id = egui::Id::new(("folder-open", place.to_string(), path.to_string())); let sel = current.is_some_and(|c| same_folder(c, path)); let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); let mut open = tree_open(ui, open_id, false); if reveal && on_the_way { // opened once per browsed folder: collapsing it again afterwards sticks - let revealed_id = egui::Id::new(("folder-revealed", path.to_string())); + let revealed_id = egui::Id::new(("folder-revealed", place.to_string(), path.to_string())); let target = current.map(str::to_string); if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { open = true; @@ -1070,7 +1074,7 @@ fn folder_tree( }); if open && indent < 12.0 * 8.0 { for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, &n, &p, indent + 12.0, current, reveal, false); + folder_tree(app, ui, place, &n, &p, indent + 12.0, current, reveal, false); } } } diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..7c7acac4c 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -896,6 +896,48 @@ fn local_folder_tree_expands_and_browses() { h.settle(SETTLE); } +/// A folder listed twice in Local (Pictures as a place of its own and again inside Home) opens +/// and closes on each row by itself: both copies of its triangle used to share one egui id, so +/// the copy drawn later took the clicks of the one drawn first (issue #1093). +#[test] +fn local_folder_listed_twice_opens_and_closes_each_row() { + let base = std::env::temp_dir().join(format!("lc-ui-twice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let inner = base.join("Inner"); + std::fs::create_dir_all(inner.join("Sub")).unwrap(); + let mut h = detail("panel.edit"); + exec(&mut h, "view.leftPanel", json!({"show": true})); + h.hide_home_above(&base); + let (base_s, inner_s) = (base.to_string_lossy().to_string(), inner.to_string_lossy().to_string()); + let sub = inner.join("Sub").to_string_lossy().to_string(); + exec(&mut h, "local.addRoot", json!({"path": base_s})); + exec(&mut h, "local.addRoot", json!({"path": inner_s})); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T); + assert_eq!(r["ok"], true, "{r}"); + let toggle = format!("folderToggle:{inner_s}"); + h.step_until(SETTLE, |h| h.app.widgets.iter().filter(|(w, _)| *w == toggle).count() == 2); + let rows = |h: &Headless, id: &str| h.app.widgets.iter().filter(|(w, _)| w == id).count(); + assert_eq!(rows(&h, &toggle), 2, "Inner is listed inside the base folder and as a place of its own"); + let sub_row = format!("source:local:{sub}"); + assert_eq!(rows(&h, &sub_row), 0); + // each copy's triangle opens that copy only, and closes it again + for copy in 0..2 { + let at = h.app.widgets.iter().filter(|(w, _)| *w == toggle).nth(copy).map(|(_, r)| r.center()).unwrap(); + let r = h.request("ui.click", json!({"x": at.x, "y": at.y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.widgets.iter().any(|(w, _)| *w == sub_row)); + assert_eq!(rows(&h, &sub_row), 1, "copy {copy} opens, the other stays closed"); + let at = h.app.widgets.iter().filter(|(w, _)| *w == toggle).nth(copy).map(|(_, r)| r.center()).unwrap(); + let r = h.request("ui.click", json!({"x": at.x, "y": at.y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(rows(&h, &sub_row), 0, "copy {copy} closes again"); + } + let _ = std::fs::remove_dir_all(&base); + h.settle(SETTLE); +} + #[test] fn slideshow_advances_pauses_and_ends() { let mut h = detail("panel.edit"); From 465601b76c50e3775f69c1ca7c62b013a96536e4 Mon Sep 17 00:00:00 2001 From: s17sh70x2jfl031ag <287216888+s17sh70x2jfl031ag@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:46:22 -0700 Subject: [PATCH 041/142] HEIF: move the heic-rs pin from 0.1.1 to 0.1.4 heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing: four in release builds (a uuid box too small for its extended type, a block starting right of the picture, a conformance window larger than the picture, and a reference row below the picture) and two with overflow checks on (a PCM bit depth above the sample bit depth, a slice QP out of range). The API, MSRV and output are unchanged. Mirrors the same change in PhotoCraft's photocraft-heif. --- Cargo.lock | 4 ++-- crates/heif/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f2609a5b..6fff653bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "heic-rs" -version = "0.1.1" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" dependencies = [ "rayon", ] diff --git a/crates/heif/Cargo.toml b/crates/heif/Cargo.toml index b3bc69d62..95641fb2f 100644 --- a/crates/heif/Cargo.toml +++ b/crates/heif/Cargo.toml @@ -11,11 +11,11 @@ license.workspace = true # Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the # other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL) # code structure; gamut-heic has no HEVC decoder of its own). -heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] } +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] } # The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] } +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] } rayon = { workspace = true } [features] From aa7e0bbc348530bbf648003fc309e51195f89282 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 21:54:42 +0200 Subject: [PATCH 042/142] fix: left panel slow with a large keyword list (#1011) Cause: the sidebar's Keywords section drew every keyword row on every frame (layout, text measuring, widget registration, hover text, context menu), even the thousands out of sight, and every catalog edit rebuilt the keyword tree from all photos with several String allocations per keyword level. On a 58,000-photo library with 4,000 keywords an unchanged frame took 7.4 ms in the sidebar alone and the frame after a rating 220 ms (156 ms of it the tree), release build on Apple Silicon. Fix: - Keywords rows out of sight only take their height (open children are still walked, so the layout is unchanged), as the grid already does (#35). A keyword made from the header's + is always drawn and the sidebar scrolls to it, so it is in sight even at the end of a long list. - Catalog::keyword_tree works out each distinct keyword string's levels once and then only counts (a per-level "last photo" stamp replaces the per-photo HashSet of Strings). Now: 0.55 ms per unchanged frame (7 rows drawn instead of 4,000), keyword_tree 17 ms, the frame after a rating 44 ms. Test: tests_keyword_sidebar::keywords_section_draws_only_the_rows_near_ the_screen (failed before: "2000 of 2000 keyword rows drawn"; now the rows near the screen, and scrolling reaches the last keyword), an ignored timing probe at the report's library size, a catalog test for a keyword repeated on one photo, and the_keywords_header_creates_a_keyword now checks the new keyword is in sight. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 105 +++++++++++++------- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/left.rs | 34 ++++++- crates/ui-egui/src/tests_keyword_sidebar.rs | 92 +++++++++++++++++ crates/ui-egui/src/tests_panels.rs | 4 + 5 files changed, 198 insertions(+), 39 deletions(-) create mode 100644 crates/ui-egui/src/tests_keyword_sidebar.rs diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 92a531951..619967f65 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -236,63 +236,85 @@ pub struct KeywordNode { impl Catalog { /// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive). pub fn keyword_tree(&self) -> Vec { - use std::collections::{HashMap, HashSet}; - // every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and + use std::collections::HashMap; + // every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and // path as first written, and the photos counted (once per photo, even when two of its - // keywords share a parent) + // keywords share a parent: `seen` is the last photo counted) struct Level { name: String, path: String, count: usize, - parent: Option, + parent: Option, + seen: usize, } - let mut levels: HashMap = HashMap::new(); - let mut this_photo: HashSet = HashSet::new(); + let mut levels: Vec = Vec::new(); + let mut by_lower: HashMap = HashMap::new(); // the keyword list first (no photos counted): a listed keyword is there without photos let listed: Vec = self.listed_keywords().map(|k| k.path.clone()).collect(); + // each keyword as written → its levels (its own and those above it), worked out once: a + // large library repeats a few thousand keywords tens of thousands of times (issue #1011) + let mut chains: HashMap<&str, Vec> = HashMap::new(); let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice())); - for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) { - this_photo.clear(); + for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() { for k in keywords { - let mut path = String::new(); - let mut lower = String::new(); - let mut parent: Option = None; - for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) { - if !path.is_empty() { - path.push(SEP); - lower.push(SEP); + let chain = chains.entry(k.as_str()).or_insert_with(|| { + let mut chain = Vec::new(); + let mut path = String::new(); + let mut lower = String::new(); + for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) { + if !path.is_empty() { + path.push(SEP); + lower.push(SEP); + } + path.push_str(part); + lower.push_str(&part.to_lowercase()); + let i = match by_lower.get(&lower) { + Some(&i) => i, + None => { + let i = levels.len(); + let parent = chain.last().copied(); + levels.push(Level { name: part.to_string(), path: path.clone(), count: 0, parent, seen: 0 }); + by_lower.insert(lower.clone(), i); + i + } + }; + chain.push(i); } - path.push_str(part); - lower.push_str(&part.to_lowercase()); - let l = levels.entry(lower.clone()).or_insert_with(|| Level { - name: part.to_string(), - path: path.clone(), - count: 0, - parent: parent.clone(), - }); - if counts && this_photo.insert(lower.clone()) { - l.count += 1; + chain + }); + if counts { + for &i in chain.iter() { + let Some(l) = levels.get_mut(i) else { continue }; + // photo 0 is the keyword list, which counts nothing + if l.seen != photo { + l.seen = photo; + l.count += 1; + } } - parent = Some(lower.clone()); } } } - let mut kids: HashMap, Vec> = HashMap::new(); - for (lower, l) in &levels { - kids.entry(l.parent.clone()).or_default().push(lower.clone()); + // each level's children; `roots` the top level + let mut kids: Vec> = vec![Vec::new(); levels.len()]; + let mut roots = Vec::new(); + for (i, l) in levels.iter().enumerate() { + match l.parent.and_then(|p| kids.get_mut(p)) { + Some(k) => k.push(i), + None => roots.push(i), + } } - fn build(at: Option, levels: &HashMap, kids: &mut HashMap, Vec>) -> Vec { - let Some(mut list) = kids.remove(&at) else { return Vec::new() }; - list.sort_by_key(|l| levels[l].name.to_lowercase()); + fn build(mut list: Vec, levels: &[Level], kids: &mut [Vec]) -> Vec { + list.sort_by_cached_key(|&i| levels.get(i).map(|l| l.name.to_lowercase())); list.into_iter() - .map(|l| { - let lv = &levels[&l]; - let children = build(Some(l.clone()), levels, kids); - KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children } + .filter_map(|i| { + let lv = levels.get(i)?; + let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default(); + let children = build(below, levels, kids); + Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }) }) .collect() } - build(None, &levels, &mut kids) + build(roots, &levels, &mut kids) } /// `SetMeta` ops for every photo whose keywords `f` changes. @@ -804,6 +826,15 @@ mod tests { assert_eq!(c.query(&f, &Default::default()).len(), 3); } + /// A keyword written twice on a photo, or in two spellings, counts the photo once; the next + /// photo with it counts again. + #[test] + fn tree_counts_a_repeated_keyword_once_per_photo() { + let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]); + let t = c.keyword_tree(); + assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]); + } + #[test] fn rename_delete_merge_are_single_undoable_batches() { let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..749d70f3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,8 @@ mod tests_keyword_list; #[cfg(test)] mod tests_keyword_set; #[cfg(test)] +mod tests_keyword_sidebar; +#[cfg(test)] mod tests_keywording; #[cfg(test)] mod tests_labels; diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..cec20e7df 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -21,6 +21,12 @@ pub(crate) fn content_width(ctx: &egui::Context) -> f32 { /// The most a row asks for its name: longer ones are cut when there is no more room. const MAX_NAME_NEED: f32 = 140.0; +/// Height of a sidebar row. +const ROW_HEIGHT: f32 = 29.0; + +/// Temp-data key of the keyword (lower-case path) the sidebar scrolls to, and the frame it was asked for. +const KEYWORD_SCROLL_TO: &str = "left-keyword-scroll-to"; + /// A row says how wide it needs to be. fn note_width(ui: &egui::Ui, w: f32) { ui.data_mut(|d| { @@ -75,7 +81,7 @@ fn row_sensed( ) -> egui::Response { let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), sense); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); register(ui.ctx(), format!("source:{id}"), r); let said = spoken.unwrap_or(label); let name = match count { @@ -638,6 +644,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { above.push_str(name); set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true); } + // …and the sidebar scrolls to it when its row is drawn (`keyword_rows`) + let frame = ui.ctx().cumulative_frame_nr(); + ui.data_mut(|d| d.insert_temp(egui::Id::new(KEYWORD_SCROLL_TO), (path.to_lowercase(), frame))); } // Local keeps track of the folder being browsed whether or not it is drawn let local = local_now(app, ui); @@ -1829,14 +1838,35 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { + // the keyword just made from the header, to scroll to (asked for a frame or two ago) + let scroll_id = egui::Id::new(KEYWORD_SCROLL_TO); + let frame = ui.ctx().cumulative_frame_nr(); + let scroll_to = ui.data(|d| d.get_temp::<(String, u64)>(scroll_id)).filter(|(_, asked)| frame.saturating_sub(*asked) <= 2).map(|(p, _)| p); for n in nodes { - let open_id = egui::Id::new(("kw-open", n.path.to_lowercase())); + let lower = n.path.to_lowercase(); + let reveal = scroll_to.as_deref() == Some(lower.as_str()); + let open_id = egui::Id::new(("kw-open", lower)); let mut open = tree_open(ui, open_id, false); + // a row out of sight only takes its room: a library can have thousands of keywords, and + // drawing them all every frame slows the whole app down (issue #1011) + let next = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); + if !reveal && !ui.is_rect_visible(next) { + ui.allocate_space(next.size()); + note_width(ui, 42.0 + indent + 18.0); + if open && !n.children.is_empty() { + keyword_rows(app, ui, &n.children, indent + 16.0); + } + continue; + } let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent); if !n.children.is_empty() { tree_toggle(ui, &resp, indent, open_id, &mut open, format!("keywordToggle:{}", n.path)); } + if reveal { + ui.scroll_to_rect_animation(resp.rect, None, egui::style::ScrollAnimation::none()); + ui.data_mut(|d| d.remove::<(String, u64)>(scroll_id)); + } let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); if resp.clicked() { let v = if sel { serde_json::Value::Null } else { json!(n.path) }; diff --git a/crates/ui-egui/src/tests_keyword_sidebar.rs b/crates/ui-egui/src/tests_keyword_sidebar.rs new file mode 100644 index 000000000..1d7457cd6 --- /dev/null +++ b/crates/ui-egui/src/tests_keyword_sidebar.rs @@ -0,0 +1,92 @@ +//! Headless tests of the left sidebar's Keywords section on a large library (issue #1011): with +//! thousands of keywords listed, a frame draws only the rows near the screen, and the rows out +//! of sight still take their room, so scrolling reaches the last keyword. + +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_engine::Session; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); +const SETTLE: Duration = Duration::from_secs(120); + +/// `photos` photos with six keywords each out of `keywords` top-level ones (each with a child). +fn library(photos: u64, keywords: u64) -> Session { + let mut s = Session::new(); + for i in 0..photos { + let path = format!("/lightcraft-kw-test/IMG_{i:05}.jpg"); + let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:05}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.meta.keywords = (0..6).map(|k| format!("Keyword {:05}|Child", (i * 6 + k) % keywords)).collect(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + s +} + +fn app(photos: u64, keywords: u64) -> Headless { + let app = LightcraftApp::new(library(photos, keywords), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// The keyword rows drawn this frame. +fn keyword_rows(h: &Headless) -> Vec { + h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("source:keyword:")).map(str::to_string).collect() +} + +/// Given a library with 2,000 keywords, when the sidebar's Keywords section is open, then a frame +/// draws only the few dozen rows that fit on screen (not all 2,000), and scrolling to the end +/// draws the last keyword: the rows out of sight keep their height. +#[test] +fn keywords_section_draws_only_the_rows_near_the_screen() { + const KEYWORDS: u64 = 2000; + let mut h = app(KEYWORDS / 6 + 1, KEYWORDS); + assert_eq!(h.app.caches.keyword_tree(&h.app.session.catalog).len(), KEYWORDS as usize); + let rows = keyword_rows(&h); + assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len()); + assert!(rows.contains(&"Keyword 00000".to_string()), "{rows:?}"); + // all the way down + h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T); + for _ in 0..40 { + let r = h.request("ui.scroll", json!({"dy": -5000.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + h.settle(SETTLE); + let rows = keyword_rows(&h); + assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len()); + assert!(rows.contains(&"Keyword 01999".to_string()), "the last keyword is reached: {rows:?}"); + assert!(!rows.contains(&"Keyword 00000".to_string()), "{rows:?}"); +} + +/// Timings on a library the size of the report's (58,000 photos): `cargo test --release -p +/// lightcraft-ui-egui --lib probe_keyword_sidebar_cost -- --ignored --nocapture`. +#[test] +#[ignore = "timing probe"] +fn probe_keyword_sidebar_cost() { + let mut h = app(58_000, 4000); + let t = Instant::now(); + let tree = h.app.session.catalog.keyword_tree(); + eprintln!("keyword_tree: {:?} ({} top level)", t.elapsed(), tree.len()); + eprintln!("keyword rows drawn: {}", keyword_rows(&h).len()); + let t = Instant::now(); + for _ in 0..10 { + h.step(); + } + eprintln!("unchanged frame: {:?}", t.elapsed() / 10); + let t = Instant::now(); + for i in 0..10u64 { + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [i + 1]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": (i % 5) + 1}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + eprintln!("frame after a rating: {:?}", t.elapsed() / 10); +} diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bfa..a94f613a5 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -889,6 +889,10 @@ fn the_keywords_header_creates_a_keyword() { h.step(); h.step(); assert!(has(&h, "source:keyword:zebra crossing"), "the new keyword is listed"); + // the sidebar scrolls to it: it is listed last, below what the panel shows + h.settle(SETTLE); + let (row, panel) = (widget(&h, "source:keyword:zebra crossing"), widget(&h, "panel:left_panel")); + assert!(panel.contains_rect(row), "the new keyword is in sight: {row:?} in {panel:?}"); } /// A folded section's row count sits to the left of its header button; a section without a button From 6797c6b483b42c3344e6a51f96d4910e6c6d7953 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 22:11:02 +0200 Subject: [PATCH 043/142] fix: share the sidebar's off-screen row skip, keep widths stable (#1011) Review follow-up on the keyword sidebar fix: - The skip is a shared helper, `out_of_sight`, that Keywords and Folders call before a row's work; a row out of sight still takes its room, is listed for the control channel (agents and tests find it as before) and asks for its measured width, so the content no longer widens and narrows as rows scroll by. - The scroll to a keyword made from the header is a per-frame request for any row id (`SCROLL_TO_ROW`), set before the sections are drawn and cleared after: no frame stamp, nothing left behind. - keyword_rows/folder_rows are skip-or-draw then recurse (the drawn row is keyword_row/folder_row); leaves no longer look up an open state. - keyword_tree builds children lists as levels are made and sorts by the lower-case name it already computed; empty_hint uses ROW_HEIGHT; the test reuses tests_panels::scroll_sidebar. 58,000 photos / 4,000 keywords, release: 1.6 ms per unchanged frame (7.4 ms before the fix), keyword_tree 14 ms (156 ms). Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 31 ++- crates/ui-egui/src/panels/left.rs | 218 +++++++++++--------- crates/ui-egui/src/tests_keyword_sidebar.rs | 13 +- crates/ui-egui/src/tests_panels.rs | 2 +- 4 files changed, 142 insertions(+), 122 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 619967f65..0d8b666a1 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -239,16 +239,19 @@ impl Catalog { use std::collections::HashMap; // every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and // path as first written, and the photos counted (once per photo, even when two of its - // keywords share a parent: `seen` is the last photo counted) + // keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case struct Level { name: String, + sort: String, path: String, count: usize, - parent: Option, seen: usize, } let mut levels: Vec = Vec::new(); let mut by_lower: HashMap = HashMap::new(); + // each level's children (by index), and the top level + let mut kids: Vec> = Vec::new(); + let mut roots: Vec = Vec::new(); // the keyword list first (no photos counted): a listed keyword is there without photos let listed: Vec = self.listed_keywords().map(|k| k.path.clone()).collect(); // each keyword as written → its levels (its own and those above it), worked out once: a @@ -258,7 +261,7 @@ impl Catalog { for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() { for k in keywords { let chain = chains.entry(k.as_str()).or_insert_with(|| { - let mut chain = Vec::new(); + let mut chain: Vec = Vec::new(); let mut path = String::new(); let mut lower = String::new(); for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) { @@ -267,13 +270,18 @@ impl Catalog { lower.push(SEP); } path.push_str(part); - lower.push_str(&part.to_lowercase()); + let sort = part.to_lowercase(); + lower.push_str(&sort); let i = match by_lower.get(&lower) { Some(&i) => i, None => { let i = levels.len(); - let parent = chain.last().copied(); - levels.push(Level { name: part.to_string(), path: path.clone(), count: 0, parent, seen: 0 }); + match chain.last().and_then(|&p| kids.get_mut(p)) { + Some(k) => k.push(i), + None => roots.push(i), + } + levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 }); + kids.push(Vec::new()); by_lower.insert(lower.clone(), i); i } @@ -294,17 +302,8 @@ impl Catalog { } } } - // each level's children; `roots` the top level - let mut kids: Vec> = vec![Vec::new(); levels.len()]; - let mut roots = Vec::new(); - for (i, l) in levels.iter().enumerate() { - match l.parent.and_then(|p| kids.get_mut(p)) { - Some(k) => k.push(i), - None => roots.push(i), - } - } fn build(mut list: Vec, levels: &[Level], kids: &mut [Vec]) -> Vec { - list.sort_by_cached_key(|&i| levels.get(i).map(|l| l.name.to_lowercase())); + list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort))); list.into_iter() .filter_map(|i| { let lv = levels.get(i)?; diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index cec20e7df..24e1b33a1 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -24,8 +24,9 @@ const MAX_NAME_NEED: f32 = 140.0; /// Height of a sidebar row. const ROW_HEIGHT: f32 = 29.0; -/// Temp-data key of the keyword (lower-case path) the sidebar scrolls to, and the frame it was asked for. -const KEYWORD_SCROLL_TO: &str = "left-keyword-scroll-to"; +/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to +/// this frame: set before the sections are drawn, cleared after. +const SCROLL_TO_ROW: &str = "left-scroll-to-row"; /// A row says how wide it needs to be. fn note_width(ui: &egui::Ui, w: f32) { @@ -82,7 +83,11 @@ fn row_sensed( let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); + if scroll_asked(ui, id) { + ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); + } register(ui.ctx(), format!("source:{id}"), r); + let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; let said = spoken.unwrap_or(label); let name = match count { Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n), @@ -114,7 +119,6 @@ fn row_sensed( let label_left = r.left() + 42.0 + indent; let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); // a label's dot sits just before the count - let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; if let Some(c) = mark { let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0)); ui.painter().circle_filled(dot.center(), 4.0, c); @@ -128,20 +132,47 @@ fn row_sensed( let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); register(ui.ctx(), format!("label:{id}"), label_rect); let resp = if shown != label { resp.on_hover_text(label) } else { resp }; - // a row asks for room for its name up to a share of a panel, so one very long name does not - // make everything scroll: depth does that - let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0 + mark_w; + note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x))); if let Some(galley) = count_galley { let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size()); - needed += rect.width() + 16.0; register(ui.ctx(), format!("count:{id}"), rect); ui.painter().galley(rect.min, galley, t.text_dim); } - note_width(ui, needed); - let _ = app; resp } +/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed +/// for the control channel and asks for its width (so the content does not widen and narrow as +/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders, +/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is +/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`]. +fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option, indent: f32, mark: bool) -> bool { + let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); + if ui.is_rect_visible(r) || scroll_asked(ui, id) { + return false; + } + ui.allocate_space(r.size()); + register(ui.ctx(), format!("source:{id}"), r); + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; + let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x); + note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w)); + true +} + +/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]). +fn scroll_asked(ui: &egui::Ui, id: &str) -> bool { + ui.data(|d| d.get_temp::(egui::Id::new(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase()) +} + +/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for +/// room for its name up to a share of a panel, so one very long name does not make everything +/// scroll: depth does that. +fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 { + 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) +} + /// The disclosure triangle at the left of a tree row (`indent` is the row's): right-pointing /// when closed, down when open, registered as widget `widget` for the control channel. Returns /// its response: the caller flips the open state on `clicked()`. @@ -272,7 +303,7 @@ fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSec /// `sidebarEmpty:`), so the section stays where it is instead of disappearing. fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover()); register(ui.ctx(), format!("sidebarEmpty:{}", section.id()), r); let text = crate::i18n::tr(section.empty_hint()); let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); @@ -644,9 +675,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { above.push_str(name); set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true); } - // …and the sidebar scrolls to it when its row is drawn (`keyword_rows`) - let frame = ui.ctx().cumulative_frame_nr(); - ui.data_mut(|d| d.insert_temp(egui::Id::new(KEYWORD_SCROLL_TO), (path.to_lowercase(), frame))); + // …and the sidebar scrolls to it + ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase())); } // Local keeps track of the folder being browsed whether or not it is drawn let local = local_now(app, ui); @@ -680,6 +710,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; placed.push(at); } + ui.data_mut(|d| d.remove::(egui::Id::new(SCROLL_TO_ROW))); // room below the last section (a section dragged to the end is dropped here) ui.add_space(SECTION_GAP * 2.0); pinned_header(app, ui, &placed, view_top); @@ -1670,44 +1701,51 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], let leads_on = n.own == 0 && n.children.len() == 1; let mut open = tree_open(ui, open_id, n.volume || leads_on); // a row whose path would cover other disks' photos too only opens and closes - let selectable = n.selectable; - let sel = selectable - && app.session.source == LibrarySource::LibraryFolder - && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); - let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; - // the label is said with the name, as it is shown beside it - let spoken = match n.label { - Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), - None => name.clone(), - }; - let mark = n.label.map(crate::theme::label_color); + let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; let id = format!("libfolder:{}", n.path); - let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); - let mut toggled = false; - if !n.children.is_empty() { - let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("libraryFolderToggle:{}", n.path)); - toggled = tr.clicked(); - // the triangle sits on the row and takes its clicks: the menu opens from it too - row_menu(app, &tr, n); + if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) { + folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); } - let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; - let resp = resp.on_hover_text(tip); - if resp.clicked() && !toggled { - if selectable { - // a source like an album or a Local folder: it replaces what the grid showed - let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); - } else { - open = !open; - set_tree_open(ui, open_id, open); - } - } - row_menu(app, &resp, n); if open && !n.children.is_empty() { folder_rows(app, ui, &n.children, indent + 16.0); } } } +/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's. +#[allow(clippy::too_many_arguments)] +fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) { + let sel = n.selectable + && app.session.source == LibrarySource::LibraryFolder + && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); + // the label is said with the name, as it is shown beside it + let spoken = match n.label { + Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), + None => name.to_string(), + }; + let mark = n.label.map(crate::theme::label_color); + let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + let mut toggled = false; + if !n.children.is_empty() { + let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); + toggled = tr.clicked(); + // the triangle sits on the row and takes its clicks: the menu opens from it too + row_menu(app, &tr, n); + } + let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; + let resp = resp.on_hover_text(tip); + if resp.clicked() && !toggled { + if n.selectable { + // a source like an album or a Local folder: it replaces what the grid showed + let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); + } else { + *open = !*open; + set_tree_open(ui, open_id, *open); + } + } + row_menu(app, &resp, n); +} + /// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the /// startup disk. fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { @@ -1838,66 +1876,54 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { - // the keyword just made from the header, to scroll to (asked for a frame or two ago) - let scroll_id = egui::Id::new(KEYWORD_SCROLL_TO); - let frame = ui.ctx().cumulative_frame_nr(); - let scroll_to = ui.data(|d| d.get_temp::<(String, u64)>(scroll_id)).filter(|(_, asked)| frame.saturating_sub(*asked) <= 2).map(|(p, _)| p); for n in nodes { - let lower = n.path.to_lowercase(); - let reveal = scroll_to.as_deref() == Some(lower.as_str()); - let open_id = egui::Id::new(("kw-open", lower)); - let mut open = tree_open(ui, open_id, false); - // a row out of sight only takes its room: a library can have thousands of keywords, and - // drawing them all every frame slows the whole app down (issue #1011) - let next = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); - if !reveal && !ui.is_rect_visible(next) { - ui.allocate_space(next.size()); - note_width(ui, 42.0 + indent + 18.0); - if open && !n.children.is_empty() { - keyword_rows(app, ui, &n.children, indent + 16.0); - } - continue; + // leaves (most keywords) have no open state to look up + let open_id = (!n.children.is_empty()).then(|| egui::Id::new(("kw-open", n.path.to_lowercase()))); + let mut open = open_id.is_some_and(|k| tree_open(ui, k, false)); + let id = format!("keyword:{}", n.path); + if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) { + keyword_row(app, ui, n, &id, indent, open_id, &mut open); } - let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); - let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent); - if !n.children.is_empty() { - tree_toggle(ui, &resp, indent, open_id, &mut open, format!("keywordToggle:{}", n.path)); - } - if reveal { - ui.scroll_to_rect_animation(resp.rect, None, egui::style::ScrollAnimation::none()); - ui.data_mut(|d| d.remove::<(String, u64)>(scroll_id)); - } - let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); - if resp.clicked() { - let v = if sel { serde_json::Value::Null } else { json!(n.path) }; - browse_all_photos(app, !sel); - let _ = app.run("library.filter", json!({"keyword": v})); - } - resp.context_menu(|ui| { - let has_sel = app.session.active().is_some(); - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); - } - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); - } - if ui.button(crate::i18n::tr("Merge into…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); - } - if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { - let _ = app.run("keyword.delete", json!({"keyword": n.path})); - } - }); - if open && !n.children.is_empty() { + if open { keyword_rows(app, ui, &n.children, indent + 16.0); } } } +/// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. +fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { + let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); + let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); + if let Some(k) = open_id { + tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); + } + let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(n.path) }; + browse_all_photos(app, !sel); + let _ = app.run("library.filter", json!({"keyword": v})); + } + resp.context_menu(|ui| { + let has_sel = app.session.active().is_some(); + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); + } + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); + } + if ui.button(crate::i18n::tr("Merge into…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); + } + if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { + let _ = app.run("keyword.delete", json!({"keyword": n.path})); + } + }); +} + #[cfg(test)] mod tests { use super::local_places; diff --git a/crates/ui-egui/src/tests_keyword_sidebar.rs b/crates/ui-egui/src/tests_keyword_sidebar.rs index 1d7457cd6..95c81ff0d 100644 --- a/crates/ui-egui/src/tests_keyword_sidebar.rs +++ b/crates/ui-egui/src/tests_keyword_sidebar.rs @@ -35,9 +35,10 @@ fn app(photos: u64, keywords: u64) -> Headless { h } -/// The keyword rows drawn this frame. +/// The keyword rows drawn this frame (every row is listed as `source:…`; only a drawn one has its +/// `label:…`). fn keyword_rows(h: &Headless) -> Vec { - h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("source:keyword:")).map(str::to_string).collect() + h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("label:keyword:")).map(str::to_string).collect() } /// Given a library with 2,000 keywords, when the sidebar's Keywords section is open, then a frame @@ -52,13 +53,7 @@ fn keywords_section_draws_only_the_rows_near_the_screen() { assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len()); assert!(rows.contains(&"Keyword 00000".to_string()), "{rows:?}"); // all the way down - h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T); - for _ in 0..40 { - let r = h.request("ui.scroll", json!({"dy": -5000.0}), T); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - } - h.settle(SETTLE); + crate::tests_panels::scroll_sidebar(&mut h, -(KEYWORDS as f32) * 40.0); let rows = keyword_rows(&h); assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len()); assert!(rows.contains(&"Keyword 01999".to_string()), "the last keyword is reached: {rows:?}"); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index a94f613a5..15f8fb0d3 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -729,7 +729,7 @@ fn the_header_of_a_long_section_stays_in_sight() { } /// Scroll the sidebar (the pointer over it) by `dy`. -fn scroll_sidebar(h: &mut Headless, dy: f32) { +pub(crate) fn scroll_sidebar(h: &mut Headless, dy: f32) { h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T); let r = h.request("ui.scroll", json!({"dy": dy}), T); assert_eq!(r["ok"], true, "{r}"); From 12498b3acf86f6d5c5e92d1c876976aa30e90daa Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:13:57 +0200 Subject: [PATCH 044/142] CLI --import uses the engine's import extensions (HEIC, HEIF, NRW) expand_paths kept its own PHOTO_EXTENSIONS list, which had fallen behind lightcraft_engine::import::EXTENSIONS. It now filters with import::is_supported, so folder walks and library.import take the same files. Fixes #1080 Co-Authored-By: Claude Opus 5.5 --- crates/mcp/src/headless.rs | 35 +++++++++++++++++++++++++---------- crates/mcp/src/lib.rs | 2 +- docs/whats-new.md | 4 ++++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 8678b1f7e..095cc4684 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -10,13 +10,6 @@ use serde_json::{Value, json}; use crate::backend::{Backend, ProgressHook}; -/// File extensions recognised as photos when expanding folders. -pub const PHOTO_EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl", - "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", -]; - /// Headless backend: a [`Session`] with filesystem hooks. pub struct Headless { pub session: Session, @@ -175,15 +168,16 @@ impl Backend for Headless { } /// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files -/// and make paths absolute. +/// and make paths absolute. Folders take the extensions `library.import` takes +/// (`lightcraft_engine::import::EXTENSIONS`), so the two can't drift apart (issue #1080). pub fn expand_paths(paths: &[String]) -> Vec { let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string(); - let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())); let mut out = Vec::new(); for p in paths { let path = Path::new(p); if path.is_dir() { - let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo); + let w = + lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), lightcraft_engine::import::is_supported); out.extend(w.files.iter().map(|f| absolute(f))); } else { // Explicit files are kept even with unknown extensions (the probe decides). @@ -226,6 +220,27 @@ mod tests { assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}"); } + /// `--import ` picks up every extension `library.import` takes, HEIC included (issue #1080). + #[test] + fn expand_paths_takes_the_engine_extensions() { + let dir = std::env::temp_dir().join(format!("lc-mcp-expand-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for e in lightcraft_engine::import::EXTENSIONS { + std::fs::write(dir.join(format!("a.{e}")), b"x").unwrap(); + } + std::fs::write(dir.join("B.HEIC"), b"x").unwrap(); + std::fs::write(dir.join("notes.txt"), b"x").unwrap(); + let found = expand_paths(&[dir.to_string_lossy().to_string()]); + let _ = std::fs::remove_dir_all(&dir); + let ext = |f: &String| Path::new(f).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let mut got: Vec = found.iter().map(ext).collect(); + got.sort(); + let mut want: Vec = lightcraft_engine::import::EXTENSIONS.iter().map(|e| e.to_string()).chain(["HEIC".to_string()]).collect(); + want.sort(); + assert_eq!(got, want); + } + /// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock. #[test] fn headless_uses_the_system_clock() { diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index b2417fe03..f95dd7a53 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -23,7 +23,7 @@ mod tools; pub use backend::{Backend, ProgressHook, Remote}; pub use base64::{base64_decode, base64_encode}; -pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image}; +pub use headless::{Headless, encode_image, expand_paths, write_image}; pub use server::{PROTOCOL_VERSION, Server}; pub use tools::{COMMAND_TOOL_PREFIX, ToolResult, call_tool, command_tool_name, helper_tools, tool_definitions}; diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..245aefb1d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,10 @@ ## 2026-10-11 +### `--import ` takes HEIC +- `lightcraft-cli run --import ` (and the MCP tools that take folders) now picks up HEIC, HEIF and Nikon NRW + files, the same files as `library.import` (issue #1080). They used to be skipped without a word. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a From 090553d14d4b4632c2cc01740c7ed186c39c66e2 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sun, 11 Oct 2026 22:42:18 +0200 Subject: [PATCH 045/142] LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965) Photo Merge decoded each raw itself and wrote the reference frame's own colour tags into the merged DNG. A CR2 (and NEF, ARW and the other raws whose colour is fitted from the camera JPEG) has none, so an HDR merge of CR2s rendered with no camera colour: green and about 16 L* dark. `lightcraft_merge::load_frame_with` takes the raw's colour tags from the caller, and the engine passes `starting_colour_tags`: the colour the raw opens with (`starting_colour`) as DNG tags. That is one forward matrix with a colour matrix and AsShotNeutral that agree with it at the as-shot white, the look's hue/sat table as ProfileHueSatMap and its tone curve as ProfileToneCurve. Raws with their own matrices (DNG) keep their tags. The look's chroma curve has no DNG tag and is left out. On two CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies against the photo alone: mean CIE76 dE 26.15 -> 1.07 and 22.89 -> 0.47. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 105 +++++++++++++++++++++++++++- crates/engine/src/merge.rs | 4 +- crates/engine/src/tests_merge.rs | 25 +++++++ crates/merge/src/frame.rs | 13 ++++ crates/merge/src/lib.rs | 2 +- docs/camera-preview-colour.md | 16 +++++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 5 ++ 8 files changed, 170 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..bf01ff6cf 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -10,7 +10,11 @@ use lightcraft_raster::{ Rgb32f, resample::{Filter, fit, resize}, }; -use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable}; +use lightcraft_raw::{ + ColorData, RawFormat, RawImage, + color::CameraTransform, + profile::{HsvTable, ProfileLook, ToneCurve}, +}; /// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone /// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because @@ -107,6 +111,68 @@ fn starting_colour_with( (xy, t, look) } +/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo +/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered +/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them). +pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option { + if lightcraft_raw::color::has_matrix(&raw.color) { + return None; + } + let (_, t, look) = starting_colour(raw, bytes); + dng_colour(&raw.color, &t, look.as_ref()) +} + +/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward +/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation +/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table +/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two +/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo +/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). +/// `None` when the white balance or the matrix can't give tags. +fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option { + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes + // into the baseline exposure + let white = m.apply([1.0; 3]); + let k = (white[0] + white[1] + white[2]) / 3.0; + if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) { + return None; + } + let m = Mat3(m.0.map(|r| r.map(|v| v / k))); + let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); + let wb = t.wb.map(f64::from); + let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); + let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2])); + Some(ColorData { + illuminant: [21, 0], + color_matrix: [Some(camera_to_xyz.inverse()?), None], + forward_matrix: [Some(forward), None], + camera_calibration: [None, None], + analog_balance: None, + as_shot_neutral: Some(neutral), + as_shot_white_xy: None, + baseline_exposure: t.baseline_exposure + k.log2(), + baseline_sharpness: tags.baseline_sharpness, + profile: ProfileLook { + hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], + tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), + ..Default::default() + }, + }) +} + +/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the +/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). +fn dng_tone_curve(tone: &CameraTone) -> Option { + const N: usize = 256; + let samples = (0..=N).map(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32); + [x, tone.apply(x).min(1.0)] + }); + let flat: Vec = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect(); + ToneCurve::from_tag(&flat) +} + /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { @@ -2062,6 +2128,43 @@ mod tests { assert!(info.camera_tone.is_some(), "no camera look fitted"); } + /// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same + /// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation + /// table and its tone curve; without a look, those of the camera transform. + #[test] + fn dng_colour_renders_like_the_raws_starting_colour() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37)); + assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb); + let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); + for look in [Some(&look), None] { + let dng = dng_colour(&ColorData::default(), &t, look).unwrap(); + let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); + for i in 0..3 { + assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); + } + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + for c in samples { + let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c)); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}"); + } + assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone())); + let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve); + match look { + Some(look) => { + let tone = tone.expect("the look's tone curve"); + let worst = + (0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max); + // (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here) + assert!(worst < 4e-3, "{worst}"); + } + None => assert!(tone.is_none()), + } + } + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 53c7f546e..6a51dc316 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -110,7 +110,9 @@ impl MergeJob { .par_iter() .map(|(_, path)| { let bytes = (self.read)(path)?; - let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}")); + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .map_err(|e| format!("{path}: {e}")); let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); f diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index a0784dd15..27a82fbed 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -67,6 +67,31 @@ fn hdr_merge_command_creates_and_imports_a_dng() { let _ = std::fs::remove_dir_all(&dir); } +/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with, +/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark). +#[test] +fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() { + let dir = temp_dir("hdr-colour"); + let neutral = [0.5, 1.0, 0.7]; + let mut paths = Vec::new(); + for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() { + // the same pixels with a white balance but no colour matrices + let mut raw = lightcraft_raw::decode(&b).unwrap(); + raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() }; + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap(); + let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap(); + assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color); + let got = merged.color.as_shot_neutral.expect("the sources' white balance"); + assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn panorama_command_with_auto_crop() { let dir = temp_dir("pano"); diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 360c7424e..69743a1b3 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -103,15 +103,28 @@ pub fn detect_clip(img: &Rgb32f) -> f32 { if maxv >= 1.0 || piled { level * 0.995 } else { 1.0 } } +/// The colour tags a raw source starts from when the caller knows better than the file (`None`: the +/// file's own tags). Called with the decoded raw, its samples still there, and the file's bytes. +pub type RawColour<'a> = dyn Fn(&mut lightcraft_raw::RawImage, &[u8]) -> Option + Sync + 'a; + /// Decode a photo (raw or standard format) into a frame no larger than `max_edge` (when given). /// `orient` applies the EXIF orientation to the pixels. pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result { + load_frame_with(bytes, max_edge, orient, &|_, _| None) +} + +/// [`load_frame`] with a raw's colour tags from `colour`: a raw without colour matrices of its own +/// (CR2, NEF, ARW…) starts from a colour the app works out (issue #965). +pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colour: &RawColour) -> Result { let metadata = lightcraft_meta::extract(bytes); let exposure = exif_exposure(&metadata); if lightcraft_raw::probe(bytes).is_some() { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; // geometric lens corrections stay with the develop settings ("profile corrections") raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(color) = colour(&mut raw, bytes) { + raw.color = color; + } let small = max_edge.is_some_and(|m| m <= 1200); let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index edfd19348..4b4e206e6 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -23,7 +23,7 @@ pub mod pano; pub mod ransac; pub mod synth; -pub use frame::{Frame, FrameColor, load_frame}; +pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with}; pub use hdr::{Deghost, HdrOptions, HdrResult, merge_hdr}; pub use output::{DngSamples, write_linear_dng}; pub use pano::{PanoOptions, PanoResult, Projection, stitch}; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..da1b87ed7 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -151,6 +151,22 @@ A proxy older than the constant is brought up to date by the existing Build Smar - when the original can't be read (offline drive), the proxy and its curve are kept exactly as they are (`staleKept`). The old version in its header is the mark: the next Build Smart Previews with the original online rebuilds it. A rebuilt proxy carries the current version, so nothing is rebuilt twice. A proxy is only ever rebuilt from the original, never from another proxy; - the two never write the same proxy at once: each proxy is checked and written under one lock. +## Photo Merge + +A merge (HDR, panorama, HDR panorama) writes a linear DNG of the sources' camera RGB, with the reference frame's colour +tags. A raw without colour matrices of its own has none to give, so its merge used to render with no camera colour: an +HDR merge of CR2s came out green and dark (issue #965). Each source now goes through `starting_colour` as when it is +opened alone, and `starting_colour_tags` writes the result as DNG tags: one forward matrix (from the look's matrix on +top of the camera transform) with a colour matrix and `AsShotNeutral` that agree with it at the as-shot white, the +look's brightness of white as baseline exposure, its hue/saturation table as `ProfileHueSatMap` and its tone curve as +`ProfileToneCurve`. Raws with their own matrices (DNG) keep their tags. Without a look the tags give the colour the raw +falls back to (spectral matrices or the neutral fallback). + +The DNG renders close to, not exactly like, the source: a DNG hue/saturation table doesn't keep each pixel's luminance +as the fitted table does, and the chroma curve has no DNG tag, so it is left out. On two CC0 CR2s (EOS 550D, 40D), an +HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th +percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..6dda8d1ee 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -346,7 +346,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..a7cea3bc0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-11 +### HDR and panorama merges of CR2 keep their colour +- Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and + ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come + out green and dark. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a From 4ddbb5a39896f6fc0ca68d979015c5f15eb53bcb Mon Sep 17 00:00:00 2001 From: Dennis <34663561+Madcat71@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:50:54 +0200 Subject: [PATCH 046/142] LR-EDIT-OPTICS-PROFILE: in-camera crop modes get the frame's crop factor, not the body's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lensfun profile is scaled by the crop factor of the recorded frame. `lookup` took the body's from the database whenever the camera was in it, so a full-frame body shooting a crop of its sensor (APS-C / Super 35 mode, or switched there by an APS-C lens) had the profile applied as if the crop were the full frame: the crop's corners got the full frame's corner distortion and vignetting. On a Sony ILCE-1M2 in APS-C mode with the FE 100-400mm F4.5-5.6 GM OSS at 400 mm, the warp's r³ coefficient was the full frame's 0.0215 instead of 0.0096. The 35 mm equivalent focal length shows the crop: when it implies a crop factor more than 10 % above the body's, that wins (`frame_crop_factor`); within 10 % the difference is the tag's whole-millimetre rounding and the body's value stands. Without the body in the database the 35 mm equivalent is used, as before. Tests: `in_camera_crop_mode_uses_the_frames_crop_factor` (fails on main: in its profile the APS-C frame's vignetting gain is 0.40 instead of 0.18) covers APS-C mode, full frame, a 35 mm equivalent rounded by a millimetre, no body in the database and a hostile focal length. Checked on 19 private ILCE-1M2 ARWs (FE 100-400mm GM, Sigma 24-70mm F2.8 DG DN | Art, full frame and APS-C, firmware 2.01-4.00): every APS-C coefficient is the full-frame one scaled by 1/1.5². Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lens_profiles.rs | 57 ++++++++++++++++++++++++++++-- crates/lensdb/src/lib.rs | 3 +- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 8a3d846fd..1894f7189 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -41,14 +41,25 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?; let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default()); let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?; - // the crop factor: the camera's, else what the 35 mm equivalent focal length implies - let crop = - camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c))); + let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm); let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h }; let corr = lens.correction(&shot)?; Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h)) } +/// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the +/// 35 mm equivalent focal length implies. A body shooting a crop of its sensor (a full-frame camera in APS-C / +/// Super 35 mode, or switched there by an APS-C lens) records a larger 35 mm equivalent than its own crop factor +/// gives; the frame's corners are then nearer the optical axis, so that crop wins. Within 10 % of the body's, the +/// difference is the tag's whole-millimetre rounding and the body's value stands. +fn frame_crop_factor(camera: Option, focal: f64, focal_35mm: Option) -> Option { + let frame = focal_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)); + match (camera, frame) { + (Some(body), Some(frame)) if frame > body * 1.1 => Some(frame), + (body, frame) => body.or(frame), + } +} + /// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`. pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option { crate::files::embedded_lens(raw).or_else(|| { @@ -98,6 +109,46 @@ mod tests { assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none()); } + /// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the + /// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's + /// corners don't get the full frame's corner correction. + #[test] + fn in_camera_crop_mode_uses_the_frames_crop_factor() { + let db = LensDb::parse( + r#" + SonyILCE-1M2Sony E1 + Sigma24-70mm F2.8 DG DN | Art 019Sony E1.0 + + + + + "#, + ) + .unwrap(); + let m = |f35: Option| lightcraft_meta::Metadata { + make: Some("SONY".into()), + model: Some("ILCE-1M2".into()), + lens_model: Some("24-70mm F2.8 DG DN | Art 019".into()), + focal_length: Some(70.0), + focal_length_35mm: f35, + f_number: Some(2.8), + ..Default::default() + }; + let at = |crop: f64, w: f64, h: f64| { + let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h }; + db.lenses[0].correction(&shot).unwrap() + }; + let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap(); + assert_eq!(aps_c, at(1.5, 5616.0, 3744.0)); + assert_ne!(aps_c, at(1.0, 5616.0, 3744.0), "the crop is not corrected as a full frame"); + // full frame, a 35 mm equivalent rounded off by a millimetre, or none at all: the body's crop factor + for f35 in [Some(70.0), Some(71.0), None] { + assert_eq!(lookup(&db, &m(f35), 8640.0, 5760.0, Orientation::Normal).unwrap(), at(1.0, 8640.0, 5760.0), "{f35:?}"); + } + assert_eq!(frame_crop_factor(None, 70.0, Some(105.0)), Some(1.5), "no body in the database: the 35 mm equivalent's"); + assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored"); + } + /// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry /// stage with straight lines. #[test] diff --git a/crates/lensdb/src/lib.rs b/crates/lensdb/src/lib.rs index fca9f9dca..185ae09cc 100644 --- a/crates/lensdb/src/lib.rs +++ b/crates/lensdb/src/lib.rs @@ -100,7 +100,8 @@ pub struct Shot { pub aperture: Option, /// Focus distance, metres. pub distance: Option, - /// Crop factor of the camera (from [`LensDb::find_camera`]); the profile's own when unknown. + /// Crop factor of the recorded frame: the camera's (from [`LensDb::find_camera`]), or larger when the camera + /// recorded a crop of its sensor; the profile's own when unknown. pub crop_factor: Option, /// Image size in pixels (the frame the correction is expressed for; the optical centre is its centre). pub width: f64, From 2df78c9e5806a6b3a418b9b264e950dfd09dc805 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sun, 11 Oct 2026 19:09:38 +0200 Subject: [PATCH 047/142] LR-BEHAV-BG-TASKS: a render job that panics fails instead of never answering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panic inside RenderJob::run or QuickJob::run (a raw decoder or pipeline bug) unwound the job pool's worker without sending a result. The slot stayed pending and its key was never asked for again, so the loupe or the thumbnail rendered forever; and the pool never replaces a dead worker, so after as many such panics as it has workers (2–6) no photo rendered until LightCraft was restarted. Both now catch the panic (guard::catch) and return it as the job's failure, answering the request it came from, so the renderer records it like any failed render: no retry until the catalog changes, and the workers live on. run() is the one place every render goes through (the pool, export, Build Previews, contact sheets, face and AI-mask workers); callers that had their own guard keep working, and the watermark preview's, now redundant, is gone. The web build aborts on panic, so nothing changes there. Co-Authored-By: Claude --- crates/engine/src/media.rs | 80 +++++++++++++++++++++++----- crates/ui-egui/src/panels/dialogs.rs | 4 +- crates/ui-egui/src/render.rs | 33 ++++++++++++ docs/whats-new.md | 4 ++ 4 files changed, 107 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..6c917b231 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -803,7 +803,31 @@ impl RenderJob { self } + /// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting + /// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace + /// it, so a few such panics used to stop every render). pub fn run(self) -> RenderResult { + let failed = self.failed(String::new()); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed }) + } + + /// This job's result when it fails with `e`. + fn failed(&self, e: String) -> RenderResult { + RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + } + } + + fn run_unguarded(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) { @@ -877,18 +901,7 @@ impl RenderJob { display, } } - Err(e) => RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Err(e), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }, + Err(e) => self.failed(e), } } } @@ -976,7 +989,25 @@ pub struct QuickJob { } impl QuickJob { + /// The stand-in, from the first source that has one. A panic inside is this job's failure + /// (see [`RenderJob::run`]). pub fn run(self) -> RenderResult { + let (request_id, photo, key) = (self.request_id, self.photo, self.key); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { + request_id, + source_key: None, + photo, + level: SourceLevel::Thumb, + key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }) + } + + fn run_unguarded(self) -> RenderResult { let (photo, key) = (self.photo, self.key); let request_id = self.request_id; let done = |rendered: Result, quick| RenderResult { @@ -1588,6 +1619,31 @@ mod thumbnail_hash_tests { s.accept(&old); assert_eq!(s.media.source_usage().0, 0); } + /// A panic while rendering (here in the decoder) used to end the pool's worker thread without + /// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now, + /// for renders and for stand-ins, answering the request it came from. + #[test] + fn a_render_that_panics_fails_instead_of_never_answering() { + let mut s = session(); + s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + let mut job = s.thumb_job(PhotoId(1), 256).unwrap(); + job.request_id = 42; + let (key, level, source_key) = (job.key, job.level, job.source_key); + let r = job.run(); + assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug")); + let quick = QuickJob { + request_id: 7, + photo: PhotoId(1), + key: 9, + cached: Vec::new(), + embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), + small: None, + }; + let r = quick.run(); + assert_eq!((r.request_id, r.key), (7, 9)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug")); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..d94bc619b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -98,8 +98,8 @@ fn watermark_preview( let (out, repaint) = (base.clone(), ctx.clone()); let work = move || { for job in jobs { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run().rendered.map(|r| r.image))) - .unwrap_or_else(|_| Err("the preview failed unexpectedly".into())); + // (a panic in the render is its failure: `RenderJob::run`) + let r = job.run().rendered.map(|r| r.image); let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // a failed sharp render keeps the thumbnail if r.is_ok() || !matches!(got.1, Some(Ok(_))) { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..03ac18945 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -1057,6 +1057,39 @@ mod thumbnail_tests { let _ = std::fs::remove_dir_all(&lib); } + /// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting, + /// the error is kept (not retried every frame), and the pool's workers live on. Before, the + /// worker died without an answer and the slot stayed pending forever. + #[test] + fn a_panicking_render_is_a_failure_not_a_dead_worker() { + let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); + app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + app.renderer.offload = None; // the real job pool + let slot = Slot::Thumb(PhotoId(1)); + let ask = |app: &mut crate::LightcraftApp| { + if let Some(job) = app.session.thumb_job(PhotoId(1), 256) { + app.renderer.request(slot, job, 30); + } + app.renderer.poll(&ctx, &mut app.session); + }; + ask(&mut app); + let t0 = std::time::Instant::now(); + while app.renderer.is_pending(slot) { + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.renderer.poll(&ctx, &mut app.session); + } + assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug")); + let workers = app.renderer.live_workers(); + assert!(workers > 0, "the worker that panicked is still there"); + let done = app.renderer.completed; + for _ in 0..30 { + ask(&mut app); + } + assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop"); + assert_eq!(app.renderer.live_workers(), workers); + } + #[test] fn raw_quick_path_frames_build_no_jobs() { // an unedited raw: the grid shows its embedded preview (held here until released) and stops diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..60c470d75 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -45,6 +45,10 @@ - Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade installs to Program Files. +- A photo that runs into a bug in LightCraft while it renders (in a raw decoder or the editing pipeline) now fails + like a photo that can't be opened, with the reason, instead of rendering forever; its thumbnail stops waiting too. + Each such bug used to take one of the render threads with it, so after a few of them no photo rendered until + LightCraft was restarted. The bug is still written to the log (Help ▸ Open Log Folder) for a report. ## 2026-10-10 From 89fa4e4b59d43bf45410b31e4b93df7cd29aeb04 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sun, 11 Oct 2026 23:48:12 +0200 Subject: [PATCH 048/142] LR-IMP-FORMATS: a float raw with NaN or infinite samples no longer panics the demosaic Cause: a floating-point CFA DNG is read as stored (no check of its float samples). AHD and PPG clamp each green estimate between its two neighbouring greens with `est.clamp(a.min(b), a.max(b))`; when both greens are NaN the bounds are NaN and `f32::clamp` panics (ppg.rs:45, ahd.rs green pass). Any float DNG with two adjacent NaN greens crashed decode + develop. Infinities alone did not panic but turned into NaN in the demosaic arithmetic. Fix, at two layers: - demosaic: a `clamp_between` helper replaces the three clamps. With finite or infinite bounds it is the same `f32::clamp` call (bit-identical results); with NaN bounds it returns the estimate unclamped, so the panic is gone whatever puts NaN into the mosaic. - DNG decode: non-finite float samples are replaced where the white level is known. NaN and -inf read as black (0), +inf as the white level (a clipped highlight). No sensor records NaN or infinity, so a valid file is never touched; finite samples, negative or above white included, stay as stored. Tests: `demosaic::tests::non_finite_samples_do_not_panic` (NaN block, +/-inf and an all-NaN mosaic through Bilinear, PPG and AHD; plus `clamp_between` equal to the old clamp bit for bit over finite, signed-zero and infinite values) and `non_finite_float_samples_decode_and_develop` (a float CFA DNG, uncompressed and Deflate, with NaN and +/-inf samples: decoded values checked, develop with all three methods gives a finite image). Both panicked before the fix at ppg.rs:45 / ahd.rs. Co-Authored-By: Claude --- crates/raw/src/demosaic/ahd.rs | 4 ++-- crates/raw/src/demosaic/mod.rs | 39 ++++++++++++++++++++++++++++++++++ crates/raw/src/demosaic/ppg.rs | 2 +- crates/raw/src/dng.rs | 10 +++++++++ crates/raw/tests/dng.rs | 37 ++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index aaf50bc6e..3f17413ff 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -77,10 +77,10 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> } let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]); let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25; - gh[i] = est.clamp(l.min(r), l.max(r)); + gh[i] = super::clamp_between(est, l, r); let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]); let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25; - gv[i] = est.clamp(u.min(d), u.max(d)); + gv[i] = super::clamp_between(est, u, d); } } // 2. full candidates diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic/mod.rs index 1ab3675cb..c868dc6aa 100644 --- a/crates/raw/src/demosaic/mod.rs +++ b/crates/raw/src/demosaic/mod.rs @@ -52,6 +52,14 @@ pub(crate) fn reflect(i: isize, n: usize) -> usize { i as usize } +/// `v` clamped between `a` and `b`: `v.clamp(a.min(b), a.max(b))`, except that it returns `v` as is when both +/// bounds are NaN (NaN samples in a float raw), where `f32::clamp` panics. +#[inline] +pub(crate) fn clamp_between(v: f32, a: f32, b: f32) -> f32 { + let (lo, hi) = (a.min(b), a.max(b)); + if lo <= hi { v.clamp(lo, hi) } else { v } +} + impl Mosaic<'_> { #[inline] pub fn get(&self, x: isize, y: isize) -> f32 { @@ -222,6 +230,37 @@ mod tests { assert_eq!(demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.3]]); } + #[test] + fn non_finite_samples_do_not_panic() { + // regression: NaN samples (a float DNG) gave AHD and PPG NaN bounds for `f32::clamp`, which panics + let cfa = Cfa::bayer("RGGB").unwrap(); + let mut patchy = mosaic_from_rgb(&edgy_scene(24, 20), &cfa); + for (i, v) in patchy.data.iter_mut().enumerate() { + let (x, y) = (i % 24, i / 24); + if (8..12).contains(&x) && (6..10).contains(&y) { + *v = f32::NAN; + } else if i % 7 == 0 { + *v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY }; + } + } + let all_nan = Normalized { width: 9, height: 7, cpp: 1, data: vec![f32::NAN; 63], cfa: Some(cfa) }; + for n in [&patchy, &all_nan] { + for m in [Method::Bilinear, Method::Ppg, Method::Ahd] { + let out = demosaic(n, m); + assert_eq!((out.width, out.height), (n.width, n.height), "{m:?}"); + } + } + // without NaN bounds it is the `f32::clamp` it replaced, bit for bit (finite results stay identical) + let vals = [f32::NEG_INFINITY, -3e38, -2.5, -0.0, 0.0, 1e-40, 0.5, 1.0, 3e38, f32::INFINITY]; + for v in vals { + for a in vals { + for b in vals { + assert_eq!(clamp_between(v, a, b).to_bits(), v.clamp(a.min(b), a.max(b)).to_bits(), "{v} {a} {b}"); + } + } + } + } + #[test] fn reflect_indices() { assert_eq!(reflect(-1, 5), 1); diff --git a/crates/raw/src/demosaic/ppg.rs b/crates/raw/src/demosaic/ppg.rs index 837796154..9efd3a867 100644 --- a/crates/raw/src/demosaic/ppg.rs +++ b/crates/raw/src/demosaic/ppg.rs @@ -42,7 +42,7 @@ pub(crate) fn green_plane(m: &Mosaic) -> Vec { _ => (ge, gw, ce), }; let est = (near * 3.0 + far + c - cfar) * 0.25; - *o = est.clamp(near.min(far), near.max(far)); + *o = super::clamp_between(est, near, far); } }); g diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 27e30002f..b7c2b0710 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -234,6 +234,16 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result 0.0 { white.get(i % cpp).or(white.first()).copied().unwrap_or(default_white) } else { 0.0 }; + } + } + } let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default(); let (br, bc) = match dim.as_slice() { diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 44527cf9c..130f3d229 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -378,6 +378,43 @@ fn floating_point_dngs() { assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::F32(vals)); } +#[test] +fn non_finite_float_samples_decode_and_develop() { + // regression: NaN samples in a float CFA DNG panicked AHD and PPG (`f32::clamp` with NaN bounds) + let (w, h) = (24, 20); + let mut raw = synthetic(w, h, Some(Cfa::bayer("RGGB").unwrap()), 1); + let mut vals: Vec = (0..w * h).map(|i| ((i * 37) % 200) as f32 / 256.0).collect(); + for (i, v) in vals.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + if (8..14).contains(&x) && (6..12).contains(&y) { + *v = f32::NAN; + } else if i % 11 == 0 { + *v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY }; + } + } + raw.data = RawData::F32(vals.clone()); + raw.black = BlackLevel::uniform(0.0); + raw.white = vec![0.75]; + for compression in [DngCompression::Uncompressed, DngCompression::Deflate { tile: 16, half: false }] { + let bytes = write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).unwrap(); + let img = decode(&bytes).unwrap(); + // NaN and −∞ read as black, +∞ as the white level; finite samples are kept bit-exactly + let RawData::F32(got) = &img.data else { panic!("float data expected") }; + for (a, b) in vals.iter().zip(got) { + let want = match *a { + f32::INFINITY => 0.75, + v if v.is_finite() => v, + _ => 0.0, + }; + assert_eq!(want.to_bits(), b.to_bits(), "{a} read as {b}"); + } + for m in [Method::Bilinear, Method::Ppg, Method::Ahd] { + let rgb = img.develop(m).unwrap(); + assert!(rgb.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{m:?}"); + } + } +} + #[test] fn lj92_strips_and_levels_and_linearization() { let (w, h) = (20, 12); From 67dc806ca925ccee99dbecab6c50d62a1b8bc9a7 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sun, 11 Oct 2026 17:59:44 -0400 Subject: [PATCH 049/142] LR-IMP-FORMATS: DNG CFAPattern is anchored at the ActiveArea, not the sensor (odd active-area offsets rendered magenta, #1086) The DNG CFAPattern describes the top-left of the ActiveArea, but RawImage::cfa is sensor-anchored (the crop to the active area shifts it by the area's offset). The DNG reader now moves the tag's pattern out to the sensor origin, and the DNG writer states the pattern back at the ActiveArea. A DNG whose area starts on an odd row or column (the Canon EOS 60D DNG of #1086, top 51) no longer reads RGGB as GBRG. --- crates/raw/src/dng.rs | 4 +++- crates/raw/src/dngwrite.rs | 4 +++- crates/raw/tests/dng.rs | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 27e30002f..ba7dac532 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -290,7 +290,9 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Result> { if let Some(cfa) = &raw.cfa { ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![cfa.height as u16, cfa.width as u16])); - ifd.set(t::CFA_PATTERN_EP, Value::Byte(cfa.pattern.clone())); + // the tag is anchored at the ActiveArea's top-left, `raw.cfa` at the sensor origin + let a = raw.active_area; + ifd.set(t::CFA_PATTERN_EP, Value::Byte(cfa.shifted(a.x, a.y).pattern)); ifd.set(t::CFA_PLANE_COLOR, Value::Byte(vec![0, 1, 2])); ifd.set(t::CFA_LAYOUT, Value::Short(vec![1])); } diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 44527cf9c..9feccdcbd 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -989,3 +989,24 @@ fn baseline_sharpness_is_read_when_plausible() { assert_eq!(decode(&bytes).unwrap().color.baseline_sharpness, None); assert_eq!(decode(&profile_dng(ByteOrder::Big, |_| {})).unwrap().color.baseline_sharpness, None); } + +/// The DNG CFAPattern is anchored at the top-left of the ActiveArea: a DNG whose area starts on an odd row or +/// column still demosaics with the pattern the tag states (#1086), and the writer states it back the same way. +#[test] +fn cfa_pattern_is_relative_to_the_active_area() { + let (w, h) = (20, 12); + for (top, left, sensor_name) in [(0u32, 0u32, "GRBG"), (1, 0, "BGGR"), (0, 1, "RGGB"), (1, 1, "GBRG"), (3, 5, "GBRG"), (2, 4, "GRBG")] { + let mut raw = base_ifd(w, h, 16, 1, true); + raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&pattern(w, h, 1, 16), ByteOrder::Little)] }); + raw.set(t::WHITE_LEVEL, Value::Short(vec![65535])); + raw.set(t::ACTIVE_AREA, Value::Long(vec![top, left, h as u32 - 1, w as u32 - 1])); + let img = decode(&dng(raw, ByteOrder::Little)).unwrap(); + assert_eq!(img.cfa.as_ref().unwrap().name(), sensor_name, "sensor anchor for area at ({left}, {top})"); + assert_eq!(img.normalized().unwrap().cfa.unwrap().name(), "GRBG", "tag pattern for area at ({left}, {top})"); + let back = + decode(&write_dng(&img, &DngWriteOptions { compression: DngCompression::Uncompressed, order: ByteOrder::Little, xmp: None }).unwrap()) + .unwrap(); + assert_eq!(back.cfa.as_ref().unwrap().name(), sensor_name); + assert_eq!(back.normalized().unwrap().cfa.unwrap().name(), "GRBG"); + } +} From 31c31b726b52789b0d2231d1996c90b40b83f22d Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sun, 11 Oct 2026 19:16:10 +0200 Subject: [PATCH 050/142] LR-VIEW-DETAIL: the loupe says when the render behind a stand-in fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A photo whose render failed while the loupe showed its embedded JPEG or a thumbnail kept that stand-in up as if it were the photo; "This photo can't be opened" only showed when nothing at all could be drawn. Now a caution pill at the "Rendering…" spot says so: "Original offline · no smart preview" when that is why, else "This photo can't be opened", with the reason on hover and for screen readers (widget `loupe:renderFailed`). Agents see it as `ui.inspect` → `loupe.failed`, so one waiting for a finished loupe knows when there won't be one. A library change retries failed renders; while the retry of the same key runs, the pill stays and "Rendering…" doesn't take over. The renderer remembers which photo a failure belongs to (`failure_of`): the centre message no longer shows the previous photo's error for a moment after a step. Tests share one render offload (`headless::RenderGate`) that holds renders and can fail chosen slots. Co-Authored-By: Claude --- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/headless.rs | 103 +++++++++++++++++++++++----- crates/ui-egui/src/panels/detail.rs | 68 ++++++++++++++---- crates/ui-egui/src/render.rs | 25 +++++-- docs/control-protocol.md | 7 +- docs/parity-checklist.md | 2 +- docs/whats-new.md | 4 ++ 7 files changed, 173 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..721ebe49a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -96,6 +96,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main), + // why its render failed (the loupe then shows a stand-in, or nothing, for good) + "failed": app.renderer.failure_of(crate::render::Slot::Main, p), // zoomed past the whole-frame render: the window rendered at no more than 100 % (pixels of its frame) "region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})), // …and the same for the Before side of a Before/After view diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a7fded1c7..0dea81150 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -450,6 +450,50 @@ impl Headless { } } +/// For tests, a render offload: renders wait (stay queued) until `open` is set, then run; those +/// whose slot `fails` picks come back failed, as an undecodable raw's would. Install it with +/// `h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone())))`. +#[cfg(test)] +pub(crate) struct RenderGate { + open: std::rc::Rc>, + fails: fn(crate::render::Slot) -> bool, + jobs: Vec<(crate::render::Slot, lightcraft_engine::media::RenderJob)>, +} + +#[cfg(test)] +impl RenderGate { + pub(crate) fn new(open: std::rc::Rc>) -> Self { + Self::failing(open, |_| false) + } + pub(crate) fn failing(open: std::rc::Rc>, fails: fn(crate::render::Slot) -> bool) -> Self { + Self { open, fails, jobs: Vec::new() } + } +} + +#[cfg(test)] +impl crate::render::RenderOffload for RenderGate { + fn try_start(&mut self, slot: crate::render::Slot, job: lightcraft_engine::media::RenderJob) -> Option { + if !self.open.get() { + return Some(job); + } + self.jobs.push((slot, job)); + None + } + fn finished(&mut self) -> Vec<(crate::render::Slot, lightcraft_engine::media::RenderResult, f64)> { + let fails = self.fails; + self.jobs + .drain(..) + .map(|(slot, job)| { + let mut r = job.run(); + if fails(slot) { + r.rendered = Err("no decoder for this raw".into()); + } + (slot, r, 0.0) + }) + .collect() + } +} + #[cfg(test)] mod tests { #[test] @@ -3970,23 +4014,8 @@ mod tests { /// and stops when the render lands. #[test] fn the_loupe_says_rendering_while_an_edit_renders() { - use crate::render::{RenderOffload, Slot}; - use lightcraft_engine::media::{RenderJob, RenderResult}; + use crate::render::Slot; use std::{cell::Cell, rc::Rc}; - /// Renders wait until the gate opens. - struct Gate(Rc>, Vec<(Slot, RenderJob)>); - impl RenderOffload for Gate { - fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { - if !self.0.get() { - return Some(job); - } - self.1.push((slot, job)); - None - } - fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> { - self.1.drain(..).map(|(slot, job)| (slot, job.run(), 0.0)).collect() - } - } let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); let mut h = demo([1000.0, 700.0]); let t = Duration::from_secs(10); @@ -3994,7 +4023,7 @@ mod tests { h.settle(SETTLE); assert!(!pill(&h), "a finished render"); let open = Rc::new(Cell::new(false)); - h.app.renderer.set_offload(Box::new(Gate(open.clone(), Vec::new()))); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); // half a second in the grid: the loupe draws no frames h.request("ui.set", json!({"view": "photoGrid"}), t); for _ in 0..30 { @@ -4016,6 +4045,46 @@ mod tests { assert!(!h.app.renderer.is_pending(Slot::Main)); } + /// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left + /// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the + /// error on hover, and keeps saying so while a library change retries it. + #[test] + fn the_loupe_says_so_when_the_render_behind_a_stand_in_fails() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!has(&h, "loupe:renderFailed")); + let first = h.app.session.active().expect("a photo is open").0; + // every loupe render fails, as an undecodable raw's would; the rest render + let open = Rc::new(Cell::new(true)); + h.app.renderer.set_offload(Box::new(super::RenderGate::failing(open.clone(), |s| s == Slot::Main))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo").0; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other], "active": other}}), t); + assert!(h.step_until(SETTLE, |h| has(h, "loupe:renderFailed")), "the failure is said"); + let shown = h.app.loupe_shown.map(|l| l.1); + assert!(!matches!(shown, Some("render" | "none")), "over a stand-in: {shown:?}"); + assert!(!has(&h, "loupe:rendering")); + let r = h.request("ui.inspect", json!({}), t); + assert_eq!(r["result"]["loupe"]["failed"], "no decoder for this raw", "{}", r["result"]["loupe"]); + // a library change (here a rating) retries it: the failure stays said, no "Rendering…" + open.set(false); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 2}}), t); + for _ in 0..45 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the retry is held"); + assert!(has(&h, "loupe:renderFailed") && !has(&h, "loupe:rendering"), "no blink while it retries"); + // back on a photo that rendered: nothing to say + open.set(true); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first], "active": first}}), t); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + assert!(h.step_until(SETTLE, |h| h.app.loupe_shown.is_some_and(|l| l.1 == "render") && !has(h, "loupe:renderFailed"))); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 6ce9d0ae1..e2c044655 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -623,7 +623,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { shown = draw(Slot::Main, img_rect, canvas); if shown == "none" { - match app.renderer.failure(Slot::Main) { + match app.renderer.failure_of(Slot::Main, id) { Some(e) => { p.text( canvas.center() - vec2(0.0, 10.0), @@ -759,13 +759,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { &[Slot::Main] }; - let coming = !matches!(shown, "none" | "hover") && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s)); + let coming = !matches!(shown, "none" | "hover") + && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)); let keys = [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); - if ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now)) { + let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now)); + // the render failed while a stand-in is up: what is on screen is all there will be (also while + // a library change retries it) + let failed = (!rendering + && !(show_before && !split) + && !matches!(shown, "render" | "none" | "hover") + && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) + .then(|| app.renderer.failure_of(Slot::Main, id)) + .flatten(); + if rendering || failed.is_some() { // over the photo (the After in two views); in the crop tool's wide margin, under it let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; - rendering_pill(ui, canvas, if over.is_positive() { over } else { canvas }, nav); + let over = if over.is_positive() { over } else { canvas }; + match failed { + Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e), + None => { + loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); + } + } } if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { preview_only_pill(ui, canvas, why); @@ -1121,21 +1137,49 @@ fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: b *up } -/// The pill saying "Rendering…" (registered as `loupe:rendering`), 12 pt above the bottom of `over`, -/// or just above the Navigator when that is in the way. It says the pixels on screen aren't final, -/// so it isn't an activity row (see docs/background-tasks.md). -fn rendering_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option) { +/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that +/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The +/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see +/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all +/// there will be. +fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { let t = Tokens::get(ui.ctx()); let p = ui.painter_at(canvas); - let g = p.layout_no_wrap(crate::i18n::tr("Rendering…").to_string(), t.font(12.0), Color32::from_gray(225)); - let size = vec2(g.size().x + 20.0, 20.0); + let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225)); + let inset = if caution { 29.0 } else { 10.0 }; + let size = vec2(g.size().x + inset + 10.0, 20.0); let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size); if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) { r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size); } p.rect_filled(r, 10.0, Color32::from_black_alpha(185)); - p.galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); - register(ui.ctx(), "loupe:rendering", r); + if caution { + crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution); + } + p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + register(ui.ctx(), id, r); + r +} + +/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]): +/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with +/// the error on hover and for screen readers. +#[allow(clippy::too_many_arguments)] +pub(crate) fn failed_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + canvas: Rect, + over: Rect, + navigator: Option, + id: &str, + error: &str, +) { + let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path)); + let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" }); + let r = loupe_pill(ui, canvas, over, navigator, id, text, true); + let resp = ui.interact(r, egui::Id::new(("render-failed", id)), Sense::hover()).on_hover_text(error); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, format!("{text}: {error}"))); } fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..dfa4ae3d3 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -197,9 +197,10 @@ pub struct Renderer { stages: HashMap>, /// Slot → key of the last quick job requested for it (each is tried once). quick_tried: HashMap, - /// Jobs that failed (unreadable or missing file…), by slot and job key, with the error: not - /// requested again until the key changes (an edit, another size) — the grid asks every frame. - failed: HashMap, + /// Jobs that failed (unreadable or missing file…), by slot and job key, with the error and + /// the photo: not requested again until the key changes (an edit, another size) — the grid + /// asks every frame. + failed: HashMap, /// The catalog revision seen at the last poll: a failure is retried once the catalog changes /// (a relinked or re-imported file). catalog_rev: u64, @@ -541,6 +542,18 @@ impl Renderer { self.failed.get(&slot).map(|f| f.1.as_str()) } + /// Why the last render of `photo` for `slot` failed: a view slot shows one photo after + /// another, and another photo's error isn't this one's. + pub fn failure_of(&self, slot: Slot, photo: PhotoId) -> Option<&str> { + self.failed.get(&slot).filter(|f| f.3 == photo).map(|f| f.1.as_str()) + } + + /// Whether `slot`'s pending render asks again for the key that last failed (a library change + /// retries failures): what is on screen stays what the failure left until it answers. + pub fn retrying(&self, slot: Slot) -> bool { + self.pending.get(&slot).zip(self.failed.get(&slot)).is_some_and(|(p, f)| p.0 == f.0) + } + /// The slots with a render pending (diagnostics: `ui.inspect`). pub fn pending_slots(&self) -> Vec { self.pending.keys().map(|s| format!("{s:?}")).collect() @@ -700,7 +713,7 @@ impl Renderer { Err(e) => { if !matches!(slot, Slot::Prefetch(_)) { log::warn!("render {slot:?}: {e}"); - self.failed.insert(slot, (r.key, e, self.catalog_rev)); + self.failed.insert(slot, (r.key, e, self.catalog_rev, r.photo)); } continue; } @@ -947,8 +960,10 @@ mod thumbnail_tests { r.remember_thumb(&p, 384, 77, false); r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); assert!(!r.thumb_current(&p, 384, 10)); - r.failed.insert(slot, (77, "no decoder".into(), 0)); + r.failed.insert(slot, (77, "no decoder".into(), 0, p.id)); assert!(r.thumb_current(&p, 384, 10)); + assert_eq!(r.failure_of(slot, p.id), Some("no decoder")); + assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error"); r.catalog_rev = 1; assert!(!r.thumb_current(&p, 384, 10)); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b9190d2cb..9d3d192b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -44,7 +44,8 @@ their command behavior. That API invokes the app command dispatcher directly; it Cocoa menu dispatch. Native menu activation and keyboard accelerator delivery require a separate test. Before capturing a finished Detail view, poll `ui.inspect` until the expected `loupe.photo` is active, -`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty. +`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty — or until +`loupe.failed` is set: that photo's render failed, and what is on screen is all there will be. For the loupe, `cached` is a prior view render keyed by the current photo and develop settings; `thumb`, `small` and `embedded` are separate fallback sources. A presented frame can otherwise contain an in-progress rendering placeholder. Require loaded thumbnail textures only when the @@ -58,7 +59,7 @@ filmstrip is visible. | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | @@ -72,7 +73,7 @@ filmstrip is visible. | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | -| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill | +| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e179277c1..cf188cd65 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -125,7 +125,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own | | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | -| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice) | +| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | 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 photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7fd189e63..cceb49a29 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -6,6 +6,10 @@ - The loupe says "Rendering…" at the bottom of the photo when what it shows isn't the finished photo yet: the camera's embedded JPEG while a raw decodes, the previous render while an edit renders, or the magnified frame while the 1:1 detail is on its way. It appears once that has lasted half a second, so quick renders never show it. +- If LightCraft can't open a photo (its original is on a drive that isn't connected, or the file is damaged) but has + its embedded JPEG or a thumbnail to show, the loupe now says so at the bottom of the photo ("Original offline · no + smart preview" or "This photo can't be opened", the reason on hover) instead of showing that preview as if it were + the photo. ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI From 536d6b88ac1d93dcdfe0e903806d15d7cebfbcc2 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sun, 11 Oct 2026 19:24:01 +0200 Subject: [PATCH 051/142] =?UTF-8?q?LR-VIEW-COMPARE:=20Compare=20and=20Refe?= =?UTF-8?q?rence=20panes=20say=20"Rendering=E2=80=A6"=20as=20the=20loupe?= =?UTF-8?q?=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare and Reference View are where focus is checked at 1:1, and their panes show the same stand-ins as the loupe: a thumbnail while the photo renders, the last render after an edit, the magnified frame under a window not in yet. Each pane now shows the loupe's "Rendering…" pill once that has lasted half a second with nothing new (widget `compare:rendering:`), and its failed pill ("This photo can't be opened", or "Original offline · no smart preview", the reason on hover) when the render failed behind a thumbnail (`compare:renderFailed:`). Survey's small fit tiles don't. Compare panes have no draft renders, so a slider drag on the photo would leave its pane saying "Rendering…" for the whole drag: the photo being dragged doesn't count until the drag ends. Each pane's wait belongs to the photo in it, so one that moves in (swap, Make Select) starts its own. A retry of a failed render keeps the failed pill, as in the loupe. Co-Authored-By: Claude --- crates/ui-egui/src/panels/compare.rs | 30 +++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 4 +-- crates/ui-egui/src/tests_compare.rs | 50 ++++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/parity-checklist.md | 6 ++-- docs/whats-new.md | 2 ++ 6 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..365fab11d 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -258,6 +258,36 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if active { p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); } + // Compare and Reference are where sharpness is judged: as in the loupe, say when what is on + // screen isn't the render on its way (a thumbnail, the last render after an edit, a magnified + // frame under its window), or when that render failed + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { + let i = slot_index(slot); + let r = &app.renderer; + let mine = |s: Slot| r.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key); + let waits = |s: Slot| r.is_pending(s) && !r.retrying(s); + // a slider drag on this photo: Compare has no drafts, so its render can't keep up until the drag ends + let dragging = app.session.interaction.is_some() && app.session.selection.active == Some(id); + let coming = (waits(slot) && !dragging) || (window.is_some() && waits(Slot::CompareRegion(i))); + let keys = [mine(slot), mine(Slot::CompareRegion(i)), None, None]; + let now = ui.input(|i| i.time); + // (the wait belongs to the photo in the pane: one moved in starts its own) + let rendering = ui.ctx().data_mut(|d| { + let (photo, wait) = d.get_temp_mut_or(egui::Id::new(("compare-rendering", i)), (id, None)); + if *photo != id { + (*photo, *wait) = (id, None); + } + super::detail::rendering_overdue(wait, keys, coming, now) + }); + let failed = (!rendering && mine(slot).is_none() && (!r.is_pending(slot) || r.retrying(slot))).then(|| r.failure_of(slot, id)).flatten(); + let over = display.intersect(img_area); + let over = if over.is_positive() { over } else { img_area }; + if let Some(e) = failed { + super::detail::failed_pill(ui, app, &photo, img_area, over, None, &format!("compare:renderFailed:{i}"), e); + } else if rendering { + super::detail::loupe_pill(ui, img_area, over, None, &format!("compare:rendering:{i}"), crate::i18n::tr("Rendering…"), false); + } + } // caption: label · file name · stars · flag // caption right under the image (at the bottom when zoomed in) let cap_top = (display.bottom() + 2.0).min(img_area.bottom()); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index e2c044655..d2324c73f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1124,7 +1124,7 @@ type RenderingWait = Option<([Option; 4], f64, bool)>; /// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws /// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming: /// drafts slower than the wait don't make it blink. -fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { +pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { if !coming { *wait = None; return false; @@ -1142,7 +1142,7 @@ fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: b /// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see /// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all /// there will be. -fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { +pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { let t = Tokens::get(ui.ctx()); let p = ui.painter_at(canvas); let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225)); diff --git a/crates/ui-egui/src/tests_compare.rs b/crates/ui-egui/src/tests_compare.rs index 42f5fc027..59ba3e347 100644 --- a/crates/ui-egui/src/tests_compare.rs +++ b/crates/ui-egui/src/tests_compare.rs @@ -82,3 +82,53 @@ fn filmstrip_click_replaces_the_active_side_in_compare() { let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[0], "side": "left"}}), T); assert_eq!(r["ok"], false, "unknown side is an error: {r}"); } + +/// Compare is where sharpness is judged: while a pane's render of an edit is on its way, that pane +/// says "Rendering…" once it has lasted a moment, and stops when the render lands. +#[test] +fn a_compare_pane_says_rendering_while_its_edit_renders() { + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:")); + let mut h = demo(); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!pill(&h), "finished renders"); + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(crate::headless::RenderGate::new(open.clone()))); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + for _ in 0..12 { + h.step(); + } + assert!(!pill(&h), "not at once"); + for _ in 0..30 { + h.step(); + } + assert!(pill(&h), "the edited pane's render is on its way"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); +} + +/// A pane whose render fails behind its thumbnail says so, as the loupe does, instead of showing +/// the thumbnail as if it were the photo. +#[test] +fn a_compare_pane_says_when_its_render_failed() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let failed = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:renderFailed:")); + let mut h = demo(); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]], "active": vis[0]}}), T); + h.request("engine.execute", json!({"command": "view.compare"}), T); + h.settle(SETTLE); + assert!(!failed(&h)); + let open = Rc::new(Cell::new(true)); + h.app.renderer.set_offload(Box::new(crate::headless::RenderGate::failing(open, |s| matches!(s, Slot::Compare(_))))); + // another photo in the second pane: its render fails, its thumbnail stays up + let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": vis[2], "side": "candidate"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, failed), "the failure is said"); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:"))); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9d3d192b8..5513d1830 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,7 +73,7 @@ filmstrip is visible. | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | -| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in | +| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:`, `compare:renderFailed:`) | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index cf188cd65..c19a8e76a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -132,7 +132,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | 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; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | -| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | @@ -685,9 +685,9 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | | LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | -| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked | +| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | -| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | +| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | diff --git a/docs/whats-new.md b/docs/whats-new.md index cceb49a29..537ea3772 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,6 +10,8 @@ its embedded JPEG or a thumbnail to show, the loupe now says so at the bottom of the photo ("Original offline · no smart preview" or "This photo can't be opened", the reason on hover) instead of showing that preview as if it were the photo. +- Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a + magnified preview isn't taken for the photo when checking focus at 1:1. ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI From fa7ee6ee9d7af875b0aff73c38827390d24af2f4 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:18:16 +0700 Subject: [PATCH 052/142] UI: explain Nikon HE NEF codec limitation and conversion options (#1172) --- crates/ui-egui/src/widgets.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0872b2ec4..bbdadfab5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,6 +45,11 @@ pub fn preview_only_variant(reason: &str) -> &str { /// What a preview-only raw means for the user (see `Photo::preview_only`). pub fn preview_only_explanation(reason: &str) -> String { + if reason.starts_with("Nikon High Efficiency NEF (") { + return crate::i18n::tr( + "Nikon High Efficiency NEF uses licensed TicoRAW compression, which LightCraft cannot decode. Only the camera's embedded JPEG preview is available. For editable raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.", + ); + } crate::i18n::tr_format!( "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.", preview_only_variant(reason) @@ -594,6 +599,17 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co mod tests { use super::*; + #[test] + fn high_efficiency_nef_explains_the_codec_limit_and_workarounds() { + let reason = "Nikon High Efficiency NEF (HE / HE★, a JPEG XS-based codec with no public specification; shoot RAW Lossless compressed to edit the raw data)"; + let explanation = preview_only_explanation(reason); + assert!(explanation.contains("TicoRAW"), "{explanation}"); + assert!(explanation.contains("Lossless compressed NEF"), "{explanation}"); + assert!(explanation.contains("DNG"), "{explanation}"); + assert!(!explanation.contains("yet"), "{explanation}"); + assert!(preview_only_explanation("unsupported CR3").contains("unsupported CR3")); + } + /// Issue #647: hovering a star previews the rating a click would set. #[test] fn hovered_star_previews_its_rating() { From a73382a5e219b49673997752fb02aee5b763c58a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:18:38 +0700 Subject: [PATCH 053/142] UI: return translated Nikon codec notice as owned text --- crates/ui-egui/src/widgets.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bbdadfab5..0a4c6ca7a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -48,7 +48,8 @@ pub fn preview_only_explanation(reason: &str) -> String { if reason.starts_with("Nikon High Efficiency NEF (") { return crate::i18n::tr( "Nikon High Efficiency NEF uses licensed TicoRAW compression, which LightCraft cannot decode. Only the camera's embedded JPEG preview is available. For editable raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.", - ); + ) + .to_string(); } crate::i18n::tr_format!( "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.", From b05e6a6b86a8ce3929df5448c65e39500fa80ba9 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:20:36 +0700 Subject: [PATCH 054/142] UI: translate navigation icon tooltips --- crates/ui-egui/src/panels/topbar.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index a95805f83..fc96c47a0 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -63,14 +63,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let search_left = full.center().x - sw / 2.0; ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, "Show/hide My Photos panel").clicked() { + if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")).clicked() { let _ = app.run("view.leftPanel", json!({})); } ui.add_space(8.0); - if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, "Back").clicked() { + if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { let _ = app.run("view.back", json!({})); } - icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward"); + icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); if !app.native_menu { // no native menu bar (web, Windows, Linux): menus in the top bar ui.add_space(10.0); From 2a837254b040de5ead153164ace57a420e809e10 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:20:46 +0700 Subject: [PATCH 055/142] UI: translate develop tooltips and color control labels --- crates/ui-egui/src/panels/edit.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..4473188c3 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() { + if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, crate::i18n::tr("Browse Profiles")).clicked() { let _ = app.run("panel.profiles", json!({})); } }); @@ -230,7 +230,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let active = app.ui.tool == "wbPicker"; - if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)") + if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, crate::i18n::tr("White Balance Selector (W)")) .clicked() { if active { @@ -1096,7 +1096,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { for (k, label) in items { let tool = format!("tat:{k}"); let active = app.ui.tool == tool; - if text_button(ui, &format!("tatMixer-{k}"), label, active) + if text_button(ui, &format!("tatMixer-{k}"), crate::i18n::tr(label), active) .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) .clicked() { @@ -1149,7 +1149,7 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.spacing_mut().item_spacing.x = 5.0; let active = app.ui.tool == "pointColor"; let full = n >= lightcraft_develop::MAX_POINT_COLORS; - if crate::widgets::icon_button(ui, "pointColorPicker", Icon::Picker, vec2(26.0, 26.0), active, !full, "Sample a colour on the photo") + if crate::widgets::icon_button(ui, "pointColorPicker", Icon::Picker, vec2(26.0, 26.0), active, !full, crate::i18n::tr("Sample a colour on the photo")) .clicked() { app.ui.tool = if active { String::new() } else { "pointColor".into() }; @@ -1234,7 +1234,7 @@ fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, phot ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (key, tip, icon) in GRADING_TABS { - if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, tip).clicked() { + if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)).clicked() { app.ui.grading_mode = key.to_string(); } } From 96085366f00224683d973814bf7956f6b5535186 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:21:01 +0700 Subject: [PATCH 056/142] UI: translate Presets toolbar tooltips --- crates/ui-egui/src/panels/presets.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 0968ab67d..df6f17bc3 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -28,10 +28,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); - if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() { + if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), crate::i18n::tr("Create Preset…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::create_preset()); } - let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options"); + let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, crate::i18n::tr("More preset options")); egui::Popup::menu(&more).show(|ui| { let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails")); register(ui.ctx(), "presetMenu:thumbnails", r.rect); From 9ae3b365a1ba2de3e0511b229bbf1ddd0f8cc14a Mon Sep 17 00:00:00 2001 From: ChippedW <263910329+ChippedW@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:26:35 +0200 Subject: [PATCH 057/142] Windows / clippy 1.98: as_chunks in the spot fill, unix-only lock test helper `cargo xtask ci` failed at clippy on Windows with Rust 1.98.1 before any feature work: - crates/pipeline/src/spots.rs: clippy::chunks_exact_to_as_chunks (new lint); `as_chunks::<3>()` is what crates/codecs already uses, same behaviour, no indexing. - crates/catalog/src/lock.rs: `descriptor_copy` is only used by a `#[cfg(unix)]` test, so it was dead code in Windows test builds. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lock.rs | 4 ++-- crates/pipeline/src/spots.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/catalog/src/lock.rs b/crates/catalog/src/lock.rs index 8a2b496d6..c53e8178b 100644 --- a/crates/catalog/src/lock.rs +++ b/crates/catalog/src/lock.rs @@ -135,8 +135,8 @@ impl LibraryLock { } /// Another descriptor on the locked file: what a process spawned by another thread holds - /// between fork and exec (a `dup` refers to the same open file description). Tests only. - #[cfg(test)] + /// between fork and exec (a `dup` refers to the same open file description). Unix tests only. + #[cfg(all(test, unix))] pub(crate) fn descriptor_copy(&self) -> Option { self.file.as_ref().and_then(|f| f.try_clone().ok()) } diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } From 559d340d1c3f07bbdf7236e7f802062fae8259ce Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:27:29 +0700 Subject: [PATCH 058/142] Fix preset Amount undoing unrelated edits or another photo --- crates/ui-egui/src/panels/presets.rs | 85 ++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 0968ab67d..165c194ce 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -17,6 +17,33 @@ use crate::{HoverPreview, LightcraftApp}; /// Long edge of preset thumbnails (px). const THUMB_EDGE: usize = 128; +/// The preset amount slider may replace only the undo step it created. +#[derive(Clone)] +struct AppliedPreset { + id: String, + amount: f64, + selected: Vec, + active: Option, + commits: u64, +} + +impl AppliedPreset { + fn new(session: &lightcraft_engine::Session, id: String, amount: f64) -> Self { + Self { id, amount, selected: session.selection.ids.clone(), active: session.active(), commits: session.commits() } + } + + fn is_current(&self, session: &lightcraft_engine::Session) -> bool { + self.selected == session.selection.ids + && self.active == session.active() + && self.commits == session.commits() + && session.interaction.is_none() + && session.redo.is_empty() + && session.undo.last().is_some_and(|entry| { + session.presets.iter().any(|preset| preset.id == self.id && entry.label == format!("Preset: {}", preset.name)) + }) + } +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)).show(ui, |ui| { @@ -47,8 +74,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { divider(ui); // amount slider (applies to the last applied preset) let amt_id = egui::Id::new("preset-amount"); - let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id)); - if let Some((pid, amount)) = last.clone() { + let last: Option = ui.data(|d| d.get_temp(amt_id)); + if let Some(last) = last.filter(|last| last.is_current(&app.session)) { let spec = ControlSpec { id: "presetAmount", label: "Amount", @@ -60,14 +87,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { decimals: 0, track: Track::Plain, }; - let out = slider(ui, &spec, amount, true, None); - if let Some(v) = out.value { - // re-apply from the pre-preset state: undo last preset step then apply with the new amount - let _ = app.run("edit.undo", json!({})); - let _ = app.run("preset.apply", json!({"id": pid, "amount": v})); - ui.data_mut(|d| d.insert_temp(amt_id, (pid, v))); + let out = slider(ui, &spec, last.amount, true, None); + if let Some(v) = out.value && app.run("edit.undo", json!({})).is_ok() { + // Only replace the most recent preset; never undo intervening edits. + if app.run("preset.apply", json!({"id": &last.id, "amount": v})).is_ok() { + ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, last.id, v))); + } else { + let _ = app.run("edit.redo", json!({})); + ui.data_mut(|d| d.remove::(amt_id)); + } } divider(ui); + } else { + ui.data_mut(|d| d.remove::(amt_id)); } let active = app.session.active(); let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); @@ -148,10 +180,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } if resp.clicked() { - let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); - ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0))); - ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); - app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); + if app.run("preset.apply", json!({"id": pid, "amount": 100})).is_ok() { + ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, pid.clone(), 100.0))); + ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); + app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); + } } let (builtin, group) = (pr.builtin, pr.group.clone()); resp.context_menu(|ui| { @@ -233,3 +266,31 @@ pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn amount_slider_does_not_undo_a_later_edit() { + let mut session = lightcraft_engine::Session::with_demo(); + let pid = "lc.golden-hour"; + session.execute("preset.apply", &json!({"id": pid})).unwrap(); + let state = AppliedPreset::new(&session, pid.into(), 100.0); + assert!(state.is_current(&session)); + + session.execute("develop.set", &json!({"values": {"light.exposure": 0.75}})).unwrap(); + assert!(!state.is_current(&session), "a newer edit owns the undo step"); + } + + #[test] + fn amount_slider_does_not_undo_on_another_photo() { + let mut session = lightcraft_engine::Session::with_demo(); + let pid = "lc.golden-hour"; + session.execute("preset.apply", &json!({"id": pid})).unwrap(); + let state = AppliedPreset::new(&session, pid.into(), 100.0); + let other = session.visible().iter().copied().find(|id| Some(*id) != session.active()).unwrap(); + session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); + assert!(!state.is_current(&session), "the preset belongs to the earlier selection"); + } +} From 90a4088f1051c118a226a8b11927cd694ec5b28f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:27:54 +0700 Subject: [PATCH 059/142] fix(ui): make Nikon HE NEF codec licensing limitation explicit (#1172) --- crates/ui-egui/src/widgets.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0a4c6ca7a..411a1e374 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -47,7 +47,7 @@ pub fn preview_only_variant(reason: &str) -> &str { pub fn preview_only_explanation(reason: &str) -> String { if reason.starts_with("Nikon High Efficiency NEF (") { return crate::i18n::tr( - "Nikon High Efficiency NEF uses licensed TicoRAW compression, which LightCraft cannot decode. Only the camera's embedded JPEG preview is available. For editable raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.", ) .to_string(); } @@ -605,6 +605,8 @@ mod tests { let reason = "Nikon High Efficiency NEF (HE / HE★, a JPEG XS-based codec with no public specification; shoot RAW Lossless compressed to edit the raw data)"; let explanation = preview_only_explanation(reason); assert!(explanation.contains("TicoRAW"), "{explanation}"); + assert!(explanation.contains("does not currently have a license"), "{explanation}"); + assert!(!explanation.contains("cannot decode"), "{explanation}"); assert!(explanation.contains("Lossless compressed NEF"), "{explanation}"); assert!(explanation.contains("DNG"), "{explanation}"); assert!(!explanation.contains("yet"), "{explanation}"); From 3af5280ea6cc6cb5500c63d5b4d25be4a95abf37 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:27:55 +0700 Subject: [PATCH 060/142] Keep selection test independent of the visible list's mutable borrow --- crates/ui-egui/src/panels/presets.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 165c194ce..e2e97dfa9 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -289,7 +289,8 @@ mod tests { let pid = "lc.golden-hour"; session.execute("preset.apply", &json!({"id": pid})).unwrap(); let state = AppliedPreset::new(&session, pid.into(), 100.0); - let other = session.visible().iter().copied().find(|id| Some(*id) != session.active()).unwrap(); + let active = session.active(); + let other = session.visible().iter().copied().find(|id| Some(*id) != active).unwrap(); session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); assert!(!state.is_current(&session), "the preset belongs to the earlier selection"); } From 17aafcd5bfeb034318a37d65605d98359e8fccfd Mon Sep 17 00:00:00 2001 From: ChippedW <263910329+ChippedW@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:26:36 +0200 Subject: [PATCH 061/142] =?UTF-8?q?LR-EXP-PSD:=20Photo=20=E2=96=B8=20Edit?= =?UTF-8?q?=20In=20=E2=96=B8=20Open=20Original=20in=20Other=20Editor=20(RA?= =?UTF-8?q?W,=20no=20copy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hands each selected photo's original file itself (a raw stays a raw: no render, no copy, nothing added to the library) to the editor set in Settings ▸ External Editing ▸ Other editor, e.g. DxO PhotoLab. Also on a photo's right-click menu (Edit In). - `photo.openOriginalInExternal {ids?, app?}` (UI command, menus.rs): up to 50 photos; demo photos and missing files are skipped with the reason; when none has a file it fails saying why (toast + error, no panic). Enabled when the active photo has a file and the host can start programs (not the web). - Several photos go to one start of the editor (`Services::open_all_with`, editors.rs `open_all_with`): a single-window editor such as DxO PhotoLab drops the files that arrive while it is still starting when started once per file. The system default ("" editor) still opens them one by one. - Settings hint, translations of the two new toasts, parity rows LR-EXP-PSD / MENU-FILE-PHOTOSHOP. Tested by hand on Windows with DxO PhotoLab 8 (one and several raws). Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/src/editors.rs | 52 +++++++++++ apps/lightcraft/src/main.rs | 1 + crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menus.rs | 96 +++++++++++++++++++++ crates/ui-egui/src/panels/grid.rs | 10 +++ crates/ui-egui/src/panels/settings.rs | 7 ++ crates/ui-egui/src/tests_masking.rs | 83 ++++++++++++++++++ docs/parity-checklist.md | 7 +- 18 files changed, 286 insertions(+), 12 deletions(-) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index a4ef7cbee..18c896367 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -228,6 +228,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen reveal: None, log_file: None, open_with: None, + open_all_with: None, check_editor: None, open_url: Some(Box::new(|url: &str| { let w = web_sys::window().ok_or("no window")?; diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs index 9a8cc22d2..1a86adbc2 100644 --- a/apps/lightcraft/src/editors.rs +++ b/apps/lightcraft/src/editors.rs @@ -61,6 +61,39 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> { c.spawn().map(|_| ()).map_err(|e| e.to_string()) } +/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection: +/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is +/// still starting when it is started once per file. The system default opens them one by one +/// (Explorer and `xdg-open` take one file). +pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> { + match all_command(launcher(app)?, paths) { + Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()), + None => paths.iter().try_for_each(|p| open_with(p, app)), + } +} + +/// The one command that opens all of `paths` in `launcher` (none for the system default). +fn all_command(launcher: Launcher, paths: &[String]) -> Option { + match launcher { + Launcher::Default => None, + Launcher::MacApp(app) => { + let mut c = Command::new("open"); + c.args(["-a", &app]).args(paths); + Some(c) + } + Launcher::Program(p) => { + let mut c = Command::new(p); + c.args(paths); + Some(c) + } + Launcher::Flatpak => { + let mut c = Command::new("flatpak"); + c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@"); + Some(c) + } + } +} + /// Windows: open `path` in its default application through Explorer, given the path as its one /// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own /// syntax (running what follows an `&` as a command). @@ -191,6 +224,25 @@ mod tests { assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]); } + /// Open Original with several photos: one start of the editor with every file, which a + /// single-window editor (DxO PhotoLab) needs to get them all. + #[test] + fn several_files_go_to_one_start_of_the_editor() { + let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()]; + let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::>(); + let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap(); + assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe"); + assert_eq!(args(&c), paths); + let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap(); + assert_eq!( + (c.get_program().to_string_lossy().into_owned(), args(&c)), + ("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()]) + ); + let c = all_command(Launcher::Flatpak, &paths).unwrap(); + assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]); + assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one"); + } + #[test] fn windows_lookup_prefers_the_recorded_install_folder() { let env = |k: &str| match k { diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index cc71cc06d..c698403bf 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -450,6 +450,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), open_with: Some(Box::new(editors::open_with)), + open_all_with: Some(Box::new(editors::open_all_with)), check_editor: Some(Box::new(editors::check)), open_url: Some(Box::new(|url: &str| { if !url.starts_with("https://") { diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..2543ab681 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "{name} opened in the other editor": "{name} im anderen Editor geöffnet", + "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..00e15051c 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "{name} opened in the other editor": "{name} abierto en el otro editor", + "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..02181d7cb 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", + "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..89f0e633b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "{name} opened in the other editor": "{name}を別のエディタで開きました", + "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..cae92dddb 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "{name} opened in the other editor": "{name} aberto no outro editor", + "{n} originals opened in the other editor": "{n} originais abertos no outro editor" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..81c568b58 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "{name} opened in the other editor": "{name} открыт в другом редакторе", + "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..3d7cc3ee2 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "{name} opened in the other editor": "{name} відкрито в іншому редакторі", + "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..9868f6697 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "{name} opened in the other editor": "已在其他编辑器中打开 {name}", + "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..131f0495e 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,7 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", + "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..59d8a0f0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -149,6 +149,8 @@ pub type RevealFn = Box Result<(), String>>; pub type OpenUrlFn = Box Result<(), String>>; /// Open a file in an application (`app` = "" for the system's default one). pub type OpenWithFn = Box Result<(), String>>; +/// Open several files with one start of an application (`app` as for [`OpenWithFn`]). +pub type OpenAllWithFn = Box Result<(), String>>; /// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: /// not known). pub type ScreenProfileFn = Box Option>>; @@ -214,6 +216,9 @@ pub struct Services { pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). pub open_with: Option, + /// Open several files with one start of the editor (Open Original; desktop only): an editor + /// that keeps one window takes them all. None = [`Services::open_with`] for each. + pub open_all_with: Option, /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = /// assume it can). pub check_editor: Option, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..b8a2e6928 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -166,6 +166,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.contactSheet", "Export Contact Sheet PDF", None, ""), ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), + // {ids?, app?}: the original files themselves (a raw stays a raw: no render, no copy), opened + // in the other editor, e.g. a raw developer + ("photo.openOriginalInExternal", "Open Original in Other Editor (RAW, no copy)", None, "Photo>Edit In"), ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), @@ -1219,6 +1222,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option edit_in(app, &ctx, p, true), "photo.editInExternal" => edit_in(app, &ctx, p, false), + "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), "dialog.cull" => { app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); Ok(Value::Null) @@ -1807,6 +1811,14 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "photo.editInPhotoCraft" | "photo.editInExternal" => app.session.active().is_some() && app.services.open_with.is_some(), "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), + "photo.openOriginalInExternal" => { + app.services.open_with.is_some() + && app + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + } "app.showInFinder" => { app.services.reveal.is_some() && app @@ -1963,3 +1975,87 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: } Ok(r) } + +/// Most originals one Open Original hands the editor (each starts it once). +const MAX_ORIGINALS: usize = 50; + +/// Photo ▸ Edit In ▸ Open Original in Other Editor: hand each selected photo's original file +/// itself (a raw stays a raw: nothing is rendered, copied or added to the library) to the editor +/// set in Settings ▸ External Editing, e.g. a raw developer. `p` may give `ids` and the editor +/// (`app`). Demo photos and missing files are skipped and listed; when no photo has a file to +/// open, it fails saying why. +fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result { + let r = open_originals(app, p); + if let Err(e) = &r { + app.toast(ctx, e.clone()); + } + let r = r?; + let opened = r["opened"].as_array().map_or(0, Vec::len); + match (r["failed"].as_str(), r["opened"][0]["path"].as_str()) { + (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), + (None, Some(path)) if opened == 1 => { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(ctx, crate::i18n::tr_format!("{name} opened in the other editor", name = name)); + } + _ => app.toast(ctx, crate::i18n::tr_format!("{n} originals opened in the other editor", n = opened)), + } + Ok(r) +} + +fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { + let mut ids = app.session.targets(p); + ids.dedup(); + if ids.is_empty() { + return Err("no photo selected".into()); + } + if ids.len() > MAX_ORIGINALS { + return Err(format!("{} photos selected: open at most {MAX_ORIGINALS} originals at a time", ids.len())); + } + let mut files = Vec::new(); + let mut skipped = Vec::new(); + for id in ids { + match app.session.catalog.photo(id).map(|ph| &ph.source) { + Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())), + Some(lightcraft_engine::catalog::Source::File { path }) => { + skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")})) + } + Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), + None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), + } + } + if files.is_empty() { + let why = skipped.first().and_then(|s| s["reason"].as_str()).unwrap_or("no original file"); + return Err(if skipped.len() == 1 { + format!("nothing to open: {why}") + } else { + format!("nothing to open: none of these photos has its original file ({why})") + }); + } + let editor = p.get("app").and_then(Value::as_str).map_or_else(|| app.ui.settings.external_editor.clone(), str::to_string); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; + } + // all at once where the host can: an editor that keeps one window (DxO PhotoLab) loses the + // files that arrive while it is still starting + if let Some(open_all) = app.services.open_all_with.as_mut() { + let paths: Vec = files.iter().map(|(_, path)| path.clone()).collect(); + open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?; + let opened: Vec = files.iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect(); + return Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": null})); + } + let open = app.services.open_with.as_mut().ok_or("opening another application isn't available here")?; + let mut opened = Vec::new(); + let mut failed = None; + for (id, path) in files { + match open(&path, &editor) { + Ok(()) => opened.push(json!({"id": id.0, "path": path})), + Err(e) => failed = Some(e), + } + } + if opened.is_empty() + && let Some(e) = failed + { + return Err(format!("Couldn't open the editor: {e}")); + } + Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": failed})) +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..505538d87 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -948,6 +948,16 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Edit in Other Editor")).clicked() { let _ = app.run("photo.editInExternal", json!({})); } + // the raw itself, unrendered: for a raw developer set as the other editor + if ui + .add_enabled( + crate::menus::ui_enabled(app, "photo.openOriginalInExternal"), + egui::Button::new(crate::i18n::tr("Open Original in Other Editor (RAW, no copy)")), + ) + .clicked() + { + let _ = app.run("photo.openOriginalInExternal", json!({})); + } }); }); if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..68313fc9e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -165,6 +165,13 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); + hint( + ui, + t, + crate::i18n::tr( + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).", + ), + ); } /// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..37930e312 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1024,6 +1024,89 @@ fn edit_in_photocraft_opens_a_psd_copy() { h.settle(SETTLE); } +/// Open Original in Other Editor: each selected photo's own file (a raw stays a raw) goes to the +/// configured editor, untouched and without a copy; demo photos and missing files say why. +#[test] +fn open_original_in_external_editor_hands_over_the_raw_itself() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = std::env::temp_dir().join(format!("lc-ui-orig-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc>> = Default::default(); + let o = opened.clone(); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + check_editor: Some(Box::new(|_: &str| Ok(()))), + ..Default::default() + }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + let demo = session.catalog.photos().map(|p| p.id).next().unwrap(); + let mut file = |name: &str, exists: bool| { + let path = dir.join(name); + if exists { + std::fs::write(&path, b"raw sensor data").unwrap(); + } + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "NEF", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + (id, path.to_string_lossy().into_owned()) + }; + let (a, a_path) = file("DSC_0001.NEF", true); + let (b, b_path) = file("DSC_0002.NEF", true); + let (gone, _) = file("DSC_0003.NEF", false); + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "DxO PhotoLab".into(); + let n = h.app.session.catalog.len(); + + exec(&mut h, "library.select", json!({"ids": [a.0, b.0, demo.0]})); + let r = exec(&mut h, "photo.openOriginalInExternal", json!({})); + let calls = opened.lock().unwrap().clone(); + assert_eq!(calls, vec![(a_path.clone(), "DxO PhotoLab".to_string()), (b_path.clone(), "DxO PhotoLab".to_string())]); + assert_eq!(r["skipped"][0]["id"], demo.0, "{r}"); + assert_eq!(h.app.session.catalog.len(), n, "no copy is added to the library"); + assert_eq!(std::fs::read(&a_path).unwrap(), b"raw sensor data", "the original is left as it is"); + assert!(std::fs::read_dir(&dir).unwrap().count() == 2, "nothing is written next to the originals"); + + // the editor can be named per call; the active photo must have a file for the menu + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [a.0], "app": "/opt/dxo"})); + assert_eq!(r["opened"][0]["path"], a_path.as_str()); + assert_eq!(opened.lock().unwrap().last().unwrap().1, "/opt/dxo"); + exec(&mut h, "library.select", json!({"ids": [a.0]})); + assert!(crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal")); + exec(&mut h, "library.select", json!({"ids": [demo.0]})); + assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal")); + + // a host that can start the editor once with every file does (DxO PhotoLab keeps one window + // and drops the files that arrive while it starts) + let batches: std::sync::Arc, String)>>> = Default::default(); + let bt = batches.clone(); + h.app.services.open_all_with = Some(Box::new(move |paths: &[String], app: &str| { + bt.lock().unwrap().push((paths.to_vec(), app.to_string())); + Ok(()) + })); + let singles = opened.lock().unwrap().len(); + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [a.0, b.0, demo.0]})); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!(*batches.lock().unwrap(), vec![(vec![a_path.clone(), b_path.clone()], "DxO PhotoLab".to_string())]); + assert_eq!(opened.lock().unwrap().len(), singles, "one start for all, not one per file"); + + // a demo photo or a missing file: a clear error, nothing opened + let calls = opened.lock().unwrap().len(); + for (ids, why) in [(json!([demo.0]), "demo"), (json!([gone.0]), "missing")] { + let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": ids}}), T); + assert_eq!(r["ok"], false, "{r}"); + assert!(r.to_string().contains(why), "{r}"); + } + assert_eq!(opened.lock().unwrap().len(), calls); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..0df31434d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-EXP-PSD: Open Original in Other Editor; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -400,7 +400,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -506,7 +506,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | | MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | `cmd:app.export` (`preset`), `crates/ui-egui/src/menubar.rs` (Export with Preset) | built-ins, then user presets, then Custom… (the dialog); exports to the last folder | | MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | -| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor, and Edit In on a photo's right-click menu | +| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor / Open Original in Other Editor (RAW, no copy), and Edit In on a photo's right-click menu | | MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | | MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | | MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | From 81733ffd8158853a4d3f0f9f335cd80f41d88ab3 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sun, 11 Oct 2026 22:59:24 -0400 Subject: [PATCH 062/142] LR-IMP-FORMATS: bump RENDER_CACHE_VERSION for the DNG CFA phase fix --- crates/engine/src/media.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// 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]; From 6d9b1a64bff9a882b5535b19e81205b9a8da3514 Mon Sep 17 00:00:00 2001 From: Diego Ospina Date: Sun, 11 Oct 2026 22:21:57 -0500 Subject: [PATCH 063/142] i18n(es): face recognition and AI Denoise in Spanish, style-guide fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 36 face-recognition entries Spanish lacked, translates 100 that still held the English text (AI Denoise and its messages), and applies docs/localization-es.md: infinitive instructions instead of tú, Upright as «Vertical», Grab bag and Develop corrected. AI-assisted, native review welcome. Models: Opus 5.5, Terra 5.6, DeepSeek V4 Flash --- crates/ui-egui/locales/es-formats.json | 28 +-- crates/ui-egui/locales/es.json | 252 ++++++++++++++----------- 2 files changed, 158 insertions(+), 122 deletions(-) diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..4b79a360f 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -63,9 +63,9 @@ "Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.", - "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Upright está desactivado.", - "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Upright guiado no es compatible.", - "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Upright no es compatible.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Vertical está desactivado.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Vertical guiado no es compatible.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Vertical no es compatible.", "XMP adjustments were not imported for {path}: {fields}.": "Los ajustes XMP no se importaron para {path}: {fields}.", "Imported {n} point curve preset{}": "{n} ajuste{0} preestablecido{0} de curva de puntos importado{0}", "Imported {n} preset{}": "{n} ajuste{0} preestablecido{0} importado{0}", @@ -197,23 +197,23 @@ "{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)", ", {} star{}": ", {} estrella{}", ", {} label": ", etiqueta {}", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Se descarga desde {host}. Cuando llega y se comprueba, LightCraft lo instala y {after}.", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "Se ejecuta en el procesador: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Se guardan en la carpeta «denoise» de la biblioteca; al llegar al límite se eliminan primero las más antiguas que no usa ninguna foto.", + "Denoising {name}: {done} of {total} tiles{}": "Reduciendo el ruido de {name}: {done} de {total} mosaicos{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "{} de {} desde {host}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "Generando la imagen sin ruido… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "Entrenado con: {provenance}", + " · {queued} waiting": " · {queued} en espera", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Muestra las condiciones del modelo, descarga {} desde {host}, lo instala y empieza a usarlo. No se envía nada más.", + "In line for AI Denoise ({ahead} ahead)…": "En cola para la reducción de ruido con IA ({ahead} por delante)…", + "Running on {}.": "Se ejecuta en {}.", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "Poner dentro de “{parent}”", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..c7afab7d3 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -177,7 +177,9 @@ "Cancel": "Cancelar", "Cancel AI Mask Model Download": "Cancelar la descarga del modelo de máscaras con IA", "Cancel Download": "Cancelar descarga", + "Cancel Face Model Download": "Cancelar la descarga del modelo de caras", "Cancel Interaction": "Cancelar interacción", + "Cancel Pending Photo Action": "Cancelar la acción pendiente sobre la foto", "Cancel Preview Build": "Cancelar generación de vistas previas", "Candidate": "Candidata", "Caption": "Pie de foto", @@ -197,7 +199,9 @@ "Choose Another Library…": "Elegir otra biblioteca…", "Choose Edit Settings to Copy": "Elegir ajustes de edición para copiar", "Choose Edit Settings to Copy…": "Elegir ajustes de edición para copiar…", + "Choose Face Recognition Model": "Elegir el modelo de reconocimiento facial", "Choose Folder…": "Elegir carpeta…", + "Choose a model below (Use) to start.": "Elegir un modelo abajo (Usar) para empezar.", "Choose a photo in the grid or filmstrip": "Elegir una foto en la cuadrícula o en la tira de película", "Choose…": "Elegir…", "Cinematic": "Cinematográfico", @@ -388,6 +392,8 @@ "Detail": "Detalle", "Detail (D)": "Detalle (D)", "Details": "Detalles", + "Detect Faces": "Detectar caras", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Detectar caras (menú Foto) lo necesita: una pequeña descarga única.", "Diagonal": "Diagonal", "Discard Preview Cache": "Descartar caché de vistas previas", "Display P3": "Display P3", @@ -401,6 +407,7 @@ "Download AI Mask Model": "Descargar modelo de máscaras con IA", "Download the SAM 3 Model?": "¿Descargar el modelo SAM 3?", "Downloads": "Descargas", + "Drag Crop Handle": "Arrastrar el tirador de recorte", "Drag along the horizon; double-click for Auto": "Arrastrar a lo largo del horizonte; doble clic para Automático", "Drag over an eye on the photo; the pupil inside is found automatically.": "Arrastrar sobre un ojo en la foto; la pupila se detecta automáticamente.", "Draw Guides": "Dibujar guías", @@ -442,6 +449,7 @@ "Enter Full Screen": "Pantalla completa", "Enter a date as YYYY-MM-DD HH:MM:SS": "Introducir una fecha como AAAA-MM-DD HH:MM:SS", "Erase": "Borrar", + "Evaluate Face Recognition": "Evaluar el reconocimiento facial", "Every EXIF, GPS and XMP field in the file": "Todos los campos EXIF, GPS y XMP del archivo", "Existing previews": "Vistas previas existentes", "Exit": "Salir", @@ -470,8 +478,15 @@ "Extended Description": "Descripción ampliada", "External Editor": "Editor externo", "Face Boxes": "Recuadros de caras", + "Face Model Downloads": "Descargas de modelos de caras", + "Face Models": "Modelos de caras", + "Face Recognition On/Off": "Activar/desactivar el reconocimiento facial", "Face boxes": "Recuadros de caras", + "Face detector": "Detector de caras", "Face names written to XMP by Lightroom and other apps show up here.": "Aquí aparecen los nombres de caras escritos en XMP por Lightroom y otras aplicaciones.", + "Face recognition": "Reconocimiento facial", + "Faces": "Caras", + "Faces of a Person": "Caras de una persona", "Faded Matte": "Mate desvaído", "Faded Slide": "Diapositiva desvaída", "Faded Urban": "Urbano desvaído", @@ -562,11 +577,12 @@ "Global Luminance": "Luminancia global", "Global Saturation": "Saturación global", "Glossy": "Brillante", + "Go back to listing them by name": "Volver a la lista por nombre", "Go to History Step": "Ir al paso del historial", "Golden": "Dorado", "Golden Glow": "Resplandor dorado", "Golden Hour": "Hora dorada", - "Grab bag": "Lista de nombres", + "Grab bag": "Miscelánea", "Grain": "Grano", "Graphic": "Gráfico", "Green": "Verde", @@ -724,6 +740,7 @@ "Lens Corrections": "Correcciones de lente", "Level": "Nivel", "Library": "Biblioteca", + "Library Folders": "Carpetas de la biblioteca", "Library Info": "Información de la biblioteca", "Library Originals": "Originales de la biblioteca", "Library Preferences": "Preferencias de la biblioteca", @@ -860,6 +877,8 @@ "My Photos Panel": "Panel Mis fotos", "Name": "Nombre", "Name (A–Z)": "Nombre (A–Z)", + "Name Face": "Poner nombre a la cara", + "Name Faces": "Poner nombre a las caras", "Name of the folder the original is in": "Nombre de la carpeta en la que está el original", "Named": "Con nombre", "Named People": "Personas con nombre", @@ -1102,6 +1121,8 @@ "Recent Keywords": "Palabras clave recientes", "Recently Added": "Añadidas recientemente", "Recently Deleted": "Eliminadas recientemente", + "Recognise faces (experimental)": "Reconocer caras (experimental)", + "Recognition models": "Modelos de reconocimiento", "Red": "Rojo", "Red Eye": "Ojos rojos", "Red Hue": "Tono rojo", @@ -1136,10 +1157,11 @@ "Remove CA": "Eliminar AC", "Remove Chromatic Aberration": "Eliminar aberración cromática", "Camera local tone mapping": "Mapeo tonal local de la cámara", - "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar el mapeo tonal local guardado en el raw (Apple ProRAW), como lo renderiza la cámara", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar el mapeo tonal local guardado en el archivo RAW (Apple ProRAW), como lo renderiza la cámara", "Remove Disk from Library": "Quitar disco de la biblioteca", "Remove Disk from Library…": "Quitar disco de la biblioteca…", "Remove Face Box": "Quitar recuadro de cara", + "Remove Face Model": "Quitar modelo de caras", "Remove Folder from Library": "Quitar carpeta de la biblioteca", "Remove a filter above, or choose Clear all": "Quitar un filtro arriba o elegir Borrar todo", "Remove from Album": "Quitar del álbum", @@ -1174,6 +1196,7 @@ "Rename…": "Cambiar nombre…", "Rendering": "Renderizado", "Rendering…": "Renderizando…", + "Reorder Album": "Reordenar álbum", "Reorder Masks": "Reordenar máscaras", "Reset": "Restablecer", "Reset All": "Restablecer todo", @@ -1329,6 +1352,7 @@ "Show Mask Overlay": "Mostrar superposición de máscara", "Show Mask Pins": "Mostrar chinchetas de máscara", "Show Original": "Mostrar original", + "Show Person": "Mostrar persona", "Show Photo Counts": "Mostrar recuento de fotos", "Show Photos with Keyword": "Mostrar fotos con la palabra clave", "Show Pins": "Mostrar chinchetas", @@ -1373,6 +1397,7 @@ "Soft Proofing": "Prueba en pantalla", "Soft Skin": "Piel suave", "Sort": "Ordenar", + "Sort Albums by Name": "Ordenar álbumes por nombre", "Sort by": "Ordenar por", "Source": "Origen", "Source code on GitHub": "Código fuente en GitHub", @@ -1406,7 +1431,9 @@ "Subject": "Sujeto", "Subtract": "Restar", "Subtract from this mask": "Restar de esta máscara", + "Suggest Names for Faces": "Sugerir nombres para las caras", "Suggestions": "Sugerencias", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Sugiere quién aparece en una foto a partir de las caras con nombre. Todo se queda en el equipo.", "Summer Haze": "Bruma estival", "Sunday": "Domingo", "Survey": "Encuesta", @@ -1488,6 +1515,7 @@ "Ungroup Stack": "Desagrupar pila", "Unknown Date": "Fecha desconocida", "Unknown tags stay as typed — check the preview for a {typo}.": "Las etiquetas desconocidas se quedan tal como se escribieron: revisar la vista previa por si hay un {typo}.", + "Unnamed Faces": "Caras sin nombre", "Unrated": "Sin valorar", "Unreadable": "Ilegible", "Update": "Actualizar", @@ -1658,98 +1686,106 @@ "Captured until": "Capturadas hasta", "Only": "Solo", "Open Log Folder": "Abrir carpeta de registros", - "Back to this person": "Back to this person", - "AI Denoise": "AI Denoise", - "AI Denoise Model": "AI Denoise Model", - "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", - "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", - "Accept & Download": "Accept & Download", - "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", - "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", - "Cache": "Cache", - "Checking and installing the model…": "Checking and installing the model…", - "Checking…": "Checking…", - "Clear cache": "Clear cache", - "Denoise Models": "Denoise Models", - "Denoise Settings": "Denoise Settings", - "Denoise all photos with an Amount": "Denoise all photos with an Amount", - "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", - "Failed its last test": "Failed its last test", - "Graphics card": "Graphics card", - "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", - "Inspect Denoise Model": "Inspect Denoise Model", - "Install Denoise Model": "Install Denoise Model", - "Install from file…": "Install from file…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", - "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", - "Limit": "Limit", - "Make it now": "Make it now", - "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", - "Next up for AI Denoise…": "Next up for AI Denoise…", - "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", - "Open page": "Open page", - "Open the model's page": "Open the model's page", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", - "Processor": "Processor", - "Set up AI Denoise…": "Set up AI Denoise…", - "Speed": "Speed", - "Starting…": "Starting…", - "Tested and works on this computer": "Tested and works on this computer", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", - "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", - "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", - "The processor does the work.": "The processor does the work.", - "There is no model file to install": "There is no model file to install", - "This build cannot run denoise models.": "This build cannot run denoise models.", - "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", - "This file cannot be used yet": "This file cannot be used yet", - "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", - "Try again": "Try again", - "Try the graphics card again": "Try the graphics card again", - "Turn off": "Turn off", - "Unknown licence": "Unknown licence", - "Use": "Use", - "Waiting to start…": "Waiting to start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", - "cached picture": "cached picture", - "cached pictures": "cached pictures", - "commercial use allowed": "commercial use allowed", + "Back to this person": "Volver a esta persona", + "AI Denoise": "Reducción de ruido con IA", + "AI Denoise Model": "Modelo de reducción de ruido con IA", + "AI Denoise is off: press Use on a model.": "La reducción de ruido con IA está desactivada: pulsar Usar en un modelo.", + "AI Denoise needs a model. Install one in Settings.": "La reducción de ruido con IA necesita un modelo. Instalar uno en Ajustes.", + "Accept & Download": "Aceptar y descargar", + "Already installed. Remove it before installing a replacement.": "Ya está instalado. Quitarlo antes de instalar uno nuevo.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automático usa la tarjeta gráfica cuando es más rápida que el procesador: ambos se cronometran en este equipo la primera vez que se genera una foto.", + "Cache": "Caché", + "Checking and installing the model…": "Comprobando e instalando el modelo…", + "Checking…": "Comprobando…", + "Clear cache": "Vaciar caché", + "Denoise Models": "Modelos de reducción de ruido", + "Denoise Settings": "Ajustes de reducción de ruido", + "Denoise all photos with an Amount": "Reducir el ruido de todas las fotos con una cantidad", + "Downloaded and checked. Installing…": "Descargado y comprobado. Instalando…", + "Failed its last test": "Falló en su última prueba", + "Graphics card": "Tarjeta gráfica", + "I have read these terms and accept them for my own use": "He leído estas condiciones y las acepto para mi propio uso", + "Inspect Denoise Model": "Inspeccionar modelo de reducción de ruido", + "Inspect Face Model File": "Inspeccionar el archivo del modelo de caras", + "Install Denoise Model": "Instalar modelo de reducción de ruido", + "Install Face Model": "Instalar modelo de caras", + "Install from file…": "Instalar desde archivo…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Instalar un modelo ONNX propio con denoise-model.json junto a él. Esta versión no ofrece descargas de modelos.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Su imagen sin ruido aún no se ha generado, y las imágenes no se generan por sí solas (Ajustes, Reducción de ruido con IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft no conoce este modelo, así que supuso:", + "Limit": "Límite", + "Make it now": "Generarla ahora", + "Make the denoised picture of the photos I am looking at": "Generar la imagen sin ruido de las fotos que estoy viendo", + "Next up for AI Denoise…": "Siguiente en la reducción de ruido con IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "No se descarga nada hasta aceptar. El modelo se guarda solo en este equipo; LightCraft nunca lo sube ni lo comparte.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Solo se generan las imágenes sin ruido de las fotos cuya cantidad de reducción de ruido con IA es mayor que 0. Las exportaciones siempre generan la suya si falta.", + "Open page": "Abrir página", + "Open the model's page": "Abrir la página del modelo", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Abre la página del modelo en el navegador, para leer sobre él u obtener el archivo a mano.", + "Processor": "Procesador", + "Set up AI Denoise…": "Configurar la reducción de ruido con IA…", + "Speed": "Velocidad", + "Starting…": "Iniciando…", + "Tested and works on this computer": "Probado y funciona en este equipo", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "El modelo de reducción de ruido está instalado y en uso: el regulador Cantidad de reducción de ruido con IA, en Detalle, ya funciona", + "The graphics card is set up when the next photo is made.": "La tarjeta gráfica se configura al generar la siguiente foto.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "El modelo se guarda solo en este equipo. LightCraft nunca lo sube ni lo comparte.", + "The processor does the work.": "El procesador hace el trabajo.", + "There is no model file to install": "No hay ningún archivo de modelo que instalar", + "This build cannot run denoise models.": "Esta versión no puede ejecutar modelos de reducción de ruido.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Esta versión no puede ejecutar modelos de reconocimiento: se pueden añadir y elegir, pero no usar.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Esta versión no tiene dónde guardar modelos de reducción de ruido, así que la reducción de ruido con IA no está disponible. (La aplicación de escritorio sí.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Esta versión no tiene dónde guardar modelos de caras, así que la lista de modelos no está disponible. (La aplicación de escritorio sí.)", + "This file cannot be used yet": "Este archivo aún no se puede usar", + "Tick the box to accept the model's terms first": "Marcar antes la casilla para aceptar las condiciones del modelo", + "Try again": "Reintentar", + "Try the graphics card again": "Volver a probar la tarjeta gráfica", + "Turn off": "Desactivar", + "Unknown licence": "Licencia desconocida", + "Use": "Usar", + "Waiting to start…": "Esperando para empezar…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funciona con archivos RAW de cámaras con sensor estándar (Bayer). Los demás archivos conservan su reducción de ruido normal.", + "cached picture": "imagen en caché", + "cached pictures": "imágenes en caché", + "commercial use allowed": "uso comercial permitido", "denoiseModel.accept": "denoiseModel.accept", - "in use": "in use", - "installed": "installed", - "makes AI Denoise available": "makes AI Denoise available", - "non-commercial use only": "non-commercial use only", - "photo has its denoised picture.": "photo has its denoised picture.", - "photo waiting": "photo waiting", - "photos have their denoised picture.": "photos have their denoised picture.", - "photos waiting": "photos waiting", - "starts using it for AI Denoise": "starts using it for AI Denoise", - "terms unclear": "terms unclear", - "the graphics card": "the graphics card", - "the graphics card is not used": "the graphics card is not used", - "Cancel Denoise": "Cancel Denoise", - "Cancel Denoise Model Download": "Cancel Denoise Model Download", - "Choose Denoise Model": "Choose Denoise Model", - "Clear Denoise Cache": "Clear Denoise Cache", - "Denoise Model Downloads": "Denoise Model Downloads", - "Denoise Photos": "Denoise Photos", - "Denoise Pump": "Denoise Pump", - "Denoise Status": "Denoise Status", - "Download Denoise Model": "Download Denoise Model", - "Remove Denoise Model": "Remove Denoise Model", - "Test Denoise Model": "Test Denoise Model", - "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", - "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", - "Cancel pending action": "Cancel pending action", - "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", - "Pending photo action cancelled": "Pending photo action cancelled", - "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", - "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "in use": "en uso", + "installed": "instalado", + "makes AI Denoise available": "activa la reducción de ruido con IA", + "non-commercial use only": "solo uso no comercial", + "photo has its denoised picture.": "foto tiene su imagen sin ruido.", + "photo waiting": "foto en espera", + "photos have their denoised picture.": "fotos tienen su imagen sin ruido.", + "photos waiting": "fotos en espera", + "starts using it for AI Denoise": "empieza a usarlo para la reducción de ruido con IA", + "terms unclear": "condiciones poco claras", + "the graphics card": "la tarjeta gráfica", + "the graphics card is not used": "no se usa la tarjeta gráfica", + "Cancel Denoise": "Cancelar reducción de ruido", + "Cancel Denoise Model Download": "Cancelar la descarga del modelo de reducción de ruido", + "Choose Denoise Model": "Elegir modelo de reducción de ruido", + "Clear Denoise Cache": "Vaciar caché de reducción de ruido", + "Denoise Model Downloads": "Descargas de modelos de reducción de ruido", + "Denoise Photos": "Reducir el ruido de las fotos", + "Denoise Pump": "Cola de reducción de ruido", + "Denoise Status": "Estado de la reducción de ruido", + "Download Denoise Model": "Descargar modelo de reducción de ruido", + "Download Face Model": "Descargar modelo de caras", + "Remove Denoise Model": "Quitar modelo de reducción de ruido", + "Test Denoise Model": "Probar modelo de reducción de ruido", + "Test Face Model": "Probar el modelo de caras", + "AI Denoise is enabled on the requesting photo": "La reducción de ruido con IA está activada en la foto que la pidió", + "After installation, AI Denoise will turn on for the requesting photo.": "Tras la instalación, la reducción de ruido con IA se activará en la foto que la pidió.", + "Cancel pending action": "Cancelar acción pendiente", + "Install a denoise model to enable AI Denoise on this photo": "Instalar un modelo de reducción de ruido para activar la reducción de ruido con IA en esta foto", + "Pending photo action cancelled": "Acción pendiente sobre la foto cancelada", + "Pending photo action cancelled because its library or photo changed": "Acción pendiente sobre la foto cancelada porque cambió su biblioteca o su foto", + "ONNX model": "Modelo ONNX", + "commercial use subject to GPL conditions": "uso comercial sujeto a las condiciones de la GPL", "Decrease Rating": "Reducir valoración", "Increase Rating": "Aumentar valoración", + "Index Faces": "Indexar caras", + "Index Faces in the Background": "Indexar caras en segundo plano", "Click to type a value": "Hacer clic para escribir un valor", "Angle": "Ángulo", "Video": "Vídeo", @@ -1761,9 +1797,9 @@ "Square": "Cuadrada", "In Color": "En color", "No Label": "Sin etiqueta", - "Choose an album…": "Elige un álbum…", + "Choose an album…": "Elegir un álbum…", "No albums yet": "Aún no hay álbumes", - "Fix the marked rules to save this album.": "Corrige las reglas marcadas para guardar este álbum.", + "Fix the marked rules to save this album.": "Corregir las reglas marcadas para guardar este álbum.", "Some rules need fixing (Edit Smart Album…):": "Hay reglas que corregir (Editar álbum inteligente…):", "isn't a field rules know": "no es un campo conocido", "can't be tested that way": "no se puede comprobar así", @@ -1776,16 +1812,16 @@ "needs a number more than 0": "necesita un número mayor que 0", "needs hours, days, weeks, months or years": "necesita horas, días, semanas, meses o años", "isn't one of the choices": "no es ninguna de las opciones", - "needs something to look for (or use “is empty”)": "necesita algo que buscar (o usa «está vacío»)", - "needs something to look for (or use “isn't empty”)": "necesita algo que buscar (o usa «no está vacío»)", + "needs something to look for (or use “is empty”)": "necesita algo que buscar (o usar «está vacío»)", + "needs something to look for (or use “isn't empty”)": "necesita algo que buscar (o usar «no está vacío»)", "needs text": "necesita texto", "can't match any rating from 0 to 5": "no coincide con ninguna valoración de 0 a 5", "needs an album": "necesita un álbum", "names an album that no longer exists": "indica un álbum que ya no existe", - "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", - "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", - "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", - "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)", + "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elegir un álbum dentro", + "This group is empty: add a rule or remove it.": "Este grupo está vacío: añadir una regla o quitarlo.", + "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usar «están vacíos»)", + "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usar «no están vacíos»)", "Mo": "Lu", "Tu": "Ma", "We": "Mi", @@ -1797,7 +1833,7 @@ "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes", "Search albums": "Buscar álbumes", "No albums match": "Ningún álbum coincide", - "This is the album you're editing": "Es el álbum que estás editando", + "This is the album you're editing": "Es el álbum que se está editando", "It tests this album, so testing it back would loop": "Comprueba este álbum, así que comprobarlo a su vez crearía un bucle", "Copy Name": "Nombre de la copia", "In a Stack": "En una pila", @@ -1810,7 +1846,7 @@ "Keywords & People": "Palabras clave y personas", "Description": "Descripción", "Camera Info": "Información de la cámara", - "Develop": "Edición", + "Develop": "Revelado", "Keyword List": "Lista de palabras clave", "Filter Keywords": "Filtrar palabras clave", "No keywords yet": "Aún no hay palabras clave", @@ -1832,7 +1868,7 @@ "Delete Keyword…": "Eliminar palabra clave…", "Drop here for the top level": "Soltar aquí para el nivel superior", "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave.", - "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave.", + "Select photos to give them keywords.": "Seleccionar fotos para darles palabras clave.", "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas", "Will Export": "Se exportará", "No keywords are exported": "No se exporta ninguna palabra clave", @@ -1842,7 +1878,7 @@ "Keywords (⌥1–⌥9)": "Palabras clave (⌥1–⌥9)", "A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre", "Save as a new set": "Guardar como conjunto nuevo", - "Recent Keywords is built in: choose another name": "“Palabras clave recientes” es fijo: elige otro nombre", + "Recent Keywords is built in: choose another name": "«Palabras clave recientes» es fijo: elegir otro nombre", "Import Keywords…": "Importar palabras clave…", "Export Keywords…": "Exportar palabras clave…", "Import Keywords": "Importar palabras clave", @@ -1852,7 +1888,7 @@ "Synchronize Folder": "Sincronizar carpeta", "Synchronize": "Sincronizar", "Scanning…": "Explorando…", - "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Buscar fotos añadidas a esta carpeta del disco o que faltan en ella, y archivos XMP complementarios cambiados por otras apps", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Buscar fotos añadidas a esta carpeta del disco o que faltan en ella, y archivos XMP complementarios cambiados por otras aplicaciones", "Bring the library up to date with this folder and the folders inside it:": "Poner la biblioteca al día con esta carpeta y las carpetas que contiene:", "Import new photos": "Importar fotos nuevas", "Remove missing photos from the library": "Quitar de la biblioteca las fotos que faltan", @@ -1874,7 +1910,7 @@ "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", - "Wait for the import to finish": "Espera a que termine la importación", + "Wait for the import to finish": "Esperar a que termine la importación", "Appearance": "Apariencia", "Appearance Mode": "Modo de apariencia", "Sync with System": "Sincronizar con el sistema", @@ -1905,7 +1941,7 @@ "Looking for changes": "Buscando cambios", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Esto es un catálogo de Lightroom, no una biblioteca de LightCraft. ¿Importarlo a una biblioteca de LightCraft?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "La importación lee el catálogo sin modificarlo y añade sus fotos, ediciones, palabras clave y colecciones a la biblioteca abierta ahora.", - "Albums hold library photos: Add to My Photos first": "Los álbumes contienen fotos de la biblioteca: añádelas primero a Mis fotos", + "Albums hold library photos: Add to My Photos first": "Los álbumes contienen fotos de la biblioteca: añadirlas primero a Mis fotos", "Add to My Photos First": "Añadir primero a Mis fotos", "Fill Location from GPS": "Rellenar ubicación desde GPS", "Download Place Names (GeoNames)": "Descargar nombres de lugares (GeoNames)", @@ -1935,7 +1971,7 @@ "Recent Libraries": "Bibliotecas recientes", "Clear Recent Libraries": "Borrar bibliotecas recientes", "No Recent Libraries": "No hay bibliotecas recientes", - "New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla", + "New Library: Choose Where to Put It": "Nueva biblioteca: elegir dónde guardarla", "New Library": "Nueva biblioteca", "Library name": "Nombre de la biblioteca", "New Folder on Disk": "Nueva carpeta en el disco", @@ -1943,7 +1979,7 @@ "Move to Folder": "Mover a carpeta", "Move to Folder…": "Mover a carpeta…", "Move Selected Photos Here": "Mover aquí las fotos seleccionadas", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP complementarios a esta carpeta; nunca sobrescribe", "Appearance Settings…": "Ajustes de apariencia…", "Add to favorites": "Añadir a favoritos", "Remove from favorites": "Quitar de favoritos", From 6fd740ac2db0ce253d34510e73d29d4bb3e0d622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E6=B4=8B=20=E9=99=88?= <205215139@qq.com> Date: Mon, 12 Oct 2026 11:44:03 +0800 Subject: [PATCH 064/142] Address maintainer review: free shortcut, derived tooltip, fmt, locales - view.maskOverlayAll: Cmd+O clashed with library.open; rebind to Cmd+Shift+O - Masking panel tooltip now derives the key label from the binding (platform aware: Cmd vs Ctrl) instead of hardcoding Ctrl+O; base string localized - gpu/render.rs: rewrite the AllMasks alpha gather as a for-loop so it passes cargo fmt --check (max_width 150) - docs/parity-checklist.md: add LR-MASK-OVERLAYALL row - locales: add 'Show All Mask Overlays' and 'Show all overlays' to de, es, fr, ja, pt-br, ru, uk, zh-hans, zh-hant --- crates/gpu/src/render.rs | 14 +++++++++----- crates/ui-egui/locales/de.json | 2 ++ crates/ui-egui/locales/es.json | 2 ++ crates/ui-egui/locales/fr.json | 2 ++ crates/ui-egui/locales/ja.json | 2 ++ crates/ui-egui/locales/pt-br.json | 2 ++ crates/ui-egui/locales/ru.json | 2 ++ crates/ui-egui/locales/uk.json | 2 ++ crates/ui-egui/locales/zh-hans.json | 2 ++ crates/ui-egui/locales/zh-hant.json | 2 ++ crates/ui-egui/src/menus.rs | 4 ++-- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/panels/masking.rs | 8 +++++++- crates/ui-egui/src/tests_masking.rs | 6 +++--- docs/parity-checklist.md | 1 + 15 files changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 1ace48078..d0bd3aa45 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -690,14 +690,18 @@ pub fn render( }); // every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once) let all_overlay_alphas: Option> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) { - let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); masks.as_ref().map(|all| { - list.iter().enumerate().map(|(mi, _)| { + let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); + let mut alphas = Vec::with_capacity(list.len()); + for (mi, _) in list.iter().enumerate() { let b = cx.slice(all, mi * n, n); - cx.read_plane(&b, w, h) - }).collect() + alphas.push(cx.read_plane(&b, w, h)); + } + alphas }) - } else { None }; + } else { + None + }; let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 6520fb3e3..ca1013abe 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1376,6 +1376,8 @@ "Show Luminance Map": "Luminanzkarte anzeigen", "Show Mask": "Maske anzeigen", "Show Mask Overlay": "Maskenüberlagerung anzeigen", + "Show All Mask Overlays": "Alle Maskenüberlagerungen anzeigen", + "Show all overlays": "Alle Überlagerungen anzeigen", "Show Mask Pins": "Maskenpins anzeigen", "Show Original": "Original anzeigen", "Show Photo Counts": "Fotoanzahl anzeigen", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index fc53b6392..e379f2803 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1323,6 +1323,8 @@ "Show Luminance Map": "Mostrar mapa de luminancia", "Show Mask": "Mostrar máscara", "Show Mask Overlay": "Mostrar superposición de máscara", + "Show All Mask Overlays": "Mostrar todas las superposiciones de máscara", + "Show all overlays": "Mostrar todas las superposiciones", "Show Mask Pins": "Mostrar chinchetas de máscara", "Show Original": "Mostrar original", "Show Photo Counts": "Mostrar recuento de fotos", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 53509a503..ce7a94733 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1313,6 +1313,8 @@ "Show Luminance Map": "Afficher la carte de luminance", "Show Mask": "Afficher le masque", "Show Mask Overlay": "Afficher l'incrustation du masque", + "Show All Mask Overlays": "Afficher toutes les superpositions de masque", + "Show all overlays": "Afficher toutes les superpositions", "Show Mask Pins": "Afficher les épingles de masque", "Show Original": "Afficher l'original", "Show Photo Counts": "Afficher le nombre de photos", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2babd30b6..b4243e701 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1259,6 +1259,8 @@ "Show Luminance Map": "輝度マップを表示", "Show Mask": "マスクを表示", "Show Mask Overlay": "マスクの重ね表示", + "Show All Mask Overlays": "すべてのマスクオーバーレイを表示", + "Show all overlays": "すべてのオーバーレイを表示", "Show Mask Pins": "マスクのピンを表示", "Show Original": "元の写真を表示", "Show Photo Counts": "写真の枚数を表示", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index dfe472818..63515b41f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1334,6 +1334,8 @@ "Show Luminance Map": "Mostrar mapa de luminância", "Show Mask": "Mostrar máscara", "Show Mask Overlay": "Mostrar sobreposição da máscara", + "Show All Mask Overlays": "Mostrar todas as sobreposições de máscara", + "Show all overlays": "Mostrar todas as sobreposições", "Show Mask Pins": "Mostrar pinos da máscara", "Show Original": "Mostrar original", "Show Photo Counts": "Mostrar contagem de fotos", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fafb65a14..fc0c7edea 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1102,6 +1102,8 @@ "Show Luminance Map": "Показать карту яркости", "Show Mask": "Показать маску", "Show Mask Overlay": "Показать наложение маски", + "Show All Mask Overlays": "Показать все наложения масок", + "Show all overlays": "Показать все наложения", "Show Mask Pins": "Показать булавки масок", "Show Original": "Показать оригинал", "Show Photo Counts": "Показывать количество фото", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 7ebad5b10..56954b6b8 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1326,6 +1326,8 @@ "Show Hidden Local Locations": "Показати приховані локальні розташування", "Show Luminance Map": "Показати карту яскравості", "Show Mask Overlay": "Показати накладання маски", + "Show All Mask Overlays": "Показати всі накладення масок", + "Show all overlays": "Показати всі накладення", "Show Mask Pins": "Показати маркери масок", "Show Original": "Показати оригінал", "Show Photo Counts": "Показати кількість фото", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 8a0e58b8e..795dc70ae 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1321,6 +1321,8 @@ "Show Luminance Map": "显示明亮度图", "Show Mask": "显示蒙版", "Show Mask Overlay": "显示蒙版叠加", + "Show All Mask Overlays": "显示所有蒙版叠加", + "Show all overlays": "显示所有叠加", "Show Mask Pins": "显示蒙版图钉", "Show Original": "显示原图", "Show Photo Counts": "显示照片数量", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f1cbec3a0..c4a8ab1fd 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1321,6 +1321,8 @@ "Show Luminance Map": "顯示明度圖", "Show Mask": "顯示遮色片", "Show Mask Overlay": "顯示遮色片疊加", + "Show All Mask Overlays": "顯示所有蒙版疊加", + "Show all overlays": "顯示所有疊加", "Show Mask Pins": "顯示遮色片控制點", "Show Original": "顯示原始照片", "Show Photo Counts": "顯示照片數量", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5ae64e1a5..5f31025cb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -91,8 +91,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), - // Ctrl+O / Cmd+O: every mask's region at once, not just the selected one - ("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+O"), "View"), + // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one + ("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+Shift+O"), "View"), // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 24976d94e..f766fe663 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1065,7 +1065,7 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra if app.ui.fullscreen { return Overlay::None; } - // Show every visible mask's region at once (Ctrl+O / Cmd+O), ahead of the selected-mask overlay + // Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay // so the shortcut is not shadowed by the always-on single-mask overlay. if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 47b96ee6d..4698a1655 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -198,7 +198,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.mask_overlay = !on; } let all = app.ui.mask_overlay_all; - if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, "Show all overlays (Ctrl+O)").clicked() { + let all_mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let all_base = crate::i18n::tr("Show all overlays"); + let all_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.maskOverlayAll") { + Some(sc) => format!("{all_base} ({})", crate::menubar::shortcut_text(sc, all_mac)), + None => all_base.to_string(), + }; + if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, &all_tip).clicked() { app.ui.mask_overlay_all = !all; } }); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index db009e816..3ad52b8e5 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -168,13 +168,13 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged"); assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. })); - // Ctrl+O shows every visible mask's region at once (the new command), ahead of the selected + // Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected // mask overlay so it is not shadowed by the always-on single-mask overlay. exec(&mut h, "view.maskOverlayAll", json!({})); - assert!(h.app.ui.mask_overlay_all, "Ctrl+O turns on the all-masks overlay"); + assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on"); assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); exec(&mut h, "view.maskOverlayAll", json!({})); - assert!(!h.app.ui.mask_overlay_all, "Ctrl+O toggles the all-masks overlay off"); + assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off"); assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); // Failed edits and global adjustments must not hide the mask overlay. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..ec6991c4b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -302,6 +302,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; no auto-show on hover | +| LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | From 1f45462529d59e5327d2c1231ad36263e5b09beb Mon Sep 17 00:00:00 2001 From: Royce Date: Mon, 12 Oct 2026 12:36:35 +0800 Subject: [PATCH 065/142] fix(cli): `--connect 7980` takes a bare port as loopback (#1178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After `lightcraft --control 7980` was running, the natural next thing to type was `lightcraft-cli run --connect 7980 ui.inspect`. The CLI's argument parser only consumed the value if it contained a colon (it was looking for a full `HOST:PORT`), so `7980` fell through and was parsed as a command id, producing `{"error":"unknown command `7980`","ok":false}`. The default address was used behind the scenes, but only by coincidence matched `127.0.0.1:7980`. The parser now also consumes a token that is all digits (a bare port) and formats it as `127.0.0.1:`. Non-numeric, non-`HOST:PORT`-shaped tokens (e.g. `ui.inspect` after `--connect`) keep falling through so the existing `--connect ui.inspect` form (use the default address, then run ui.inspect) still works. Two parser sites have the same shape — the global `--connect` in `cli()` and the per-step `--connect` in `run_step()` — and both are updated. Test: connect_bare_port_is_treated_as_loopback_port. cargo test -p lightcraft-cli: 21 passed, 0 failed. cargo clippy -p lightcraft-cli --no-deps clean. --- apps/lightcraft-cli/src/main.rs | 14 +++++++++----- apps/lightcraft-cli/tests/cli.rs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 63a4deb76..b6cad57e0 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -303,10 +303,14 @@ fn mcp(args: &[String]) -> Result<(), String> { while i < args.len() { match args[i].as_str() { "--connect" => { - // Optional address argument. - match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) { + // Optional address argument. A bare port (all digits) is taken as a port on the + // loopback default — matches the help's `--connect [ADDR]` hint alongside the + // `--control 7980` example. A non-numeric, non-`--`-prefixed token (e.g. a + // command id like `ui.inspect`) leaves the address at its default so the + // command is still consumed next. (#1178) + match args.get(i + 1).filter(|a| a.contains(':') || a.chars().all(|c| c.is_ascii_digit())) { Some(a) => { - connect = Some(a.clone()); + connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") }); i += 1; } None => connect = Some(DEFAULT_ADDR.to_string()), @@ -525,9 +529,9 @@ fn run(args: &[String]) -> Result<(), String> { i += 1; break; } - "--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) { + "--connect" => match args.get(i + 1).filter(|a| !a.contains('=') && (a.contains(':') || a.chars().all(|c| c.is_ascii_digit()))) { Some(a) => { - connect = Some(a.clone()); + connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") }); i += 1; } None => connect = Some(DEFAULT_ADDR.to_string()), diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9aafc9310..bf56b02ab 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -476,6 +476,28 @@ fn connect_failure_hint_names_the_attempted_port() { assert!(!err.contains("--control 7980"), "{err}"); } +/// Issue #1178: `--connect 7980 ui.inspect` (after `lightcraft --control 7980`) was leaving +/// `7980` unconsumed so it parsed as a command id and produced "unknown command `7980`". +/// A bare port is now taken as `127.0.0.1:`; the same address used with `:port` still +/// works; non-numeric, non-`ADDR`-shaped tokens (e.g. a real command id) keep falling through. +#[test] +fn connect_bare_port_is_treated_as_loopback_port() { + // Bare port: the failure hint names that port. + let (ok, _lines, err) = run_cli(&["--connect", "18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "bare port must reach the hint: {err}"); + assert!(!err.contains("--control 7980"), "{err}"); + // `:` still works (issue #167 case). + let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "{err}"); + // `--connect=` already worked (the `a.starts_with("--connect=")` arm); spot-check + // the hint matches its port too. + let (ok, _lines, err) = run_cli(&["--connect=18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "{err}"); +} + /// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is /// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared. #[test] From 2d8fc776b2496f98a50b44b64f8d0a71c6acebaf Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 09:24:49 +0530 Subject: [PATCH 066/142] LR-EDIT-COLOR-GRADING: Cmd/Ctrl-drag keeps the saturation, Alt/Option fine-adjusts the colour wheels (#1160) The wheels had Shift (keep the hue, change saturation). Lightroom's other two modifiers are now there too: - Cmd/Ctrl-drag keeps the puck's saturation and changes only the hue, so it moves round its circle; the circle is drawn as the guide, as Shift draws the spoke. Shift wins when both are down (keeping both would freeze the puck). - Alt/Option-drag fine-adjusts: the puck moves a tenth as far as the pointer from where it was grabbed (the slider's fine-adjust factor), counted from the press so no travel is lost to egui's drag threshold. It combines with Shift and Cmd/Ctrl. - Pressing or releasing a modifier mid-drag re-grabs the puck as it is at that moment (WheelGrab); wheel_pick takes a WheelLock (Hue / Sat) and returns finite values for any input. - A puck at the centre has no hue, so a Shift-drag from there now takes the angle where the pointer pressed, not where egui first reported a drag. - The wheel has a hover tooltip naming the modifiers. Shift-drag already kept the puck's own hue whenever its saturation was 1 or more; shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs pins that. Tests (tests_grading.rs) drive the wheel through ui.dragWidget with each modifier and check the develop values and one undo step per drag, plus unit tests of wheel_pick (lock, clamp, centre, NaN/inf) and WheelGrab. --- crates/ui-egui/src/panels/edit.rs | 137 +++++++++++++--- crates/ui-egui/src/tests_grading.rs | 245 +++++++++++++++++++++++++++- docs/parity-checklist.md | 4 +- 3 files changed, 359 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..d730f9a5d 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1289,6 +1289,9 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings let zone = r.expand(10.0); let hovering = ui.rect_contains_pointer(zone); let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); + let resp = resp.on_hover_text(crate::i18n::tr( + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.", + )); register(ui.ctx(), format!("wheel:{key}"), r); paint_wheel(ui.painter(), c, rad); if hidden { @@ -1305,25 +1308,40 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings } let a = (wh.hue as f32).to_radians(); let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); - // Shift-drag: saturation only, the hue locked to the spoke the point is on (as in Lightroom). - // Pressing or releasing Shift mid-drag turns the lock on or off there. - let lock_id = egui::Id::new(("wheelHueLock", key)); - let shift = ui.input(|i| i.modifiers.shift); - let lock = match resp.interact_pointer_pos() { - Some(q) if resp.dragged() && shift => { - // a point at the centre has no hue yet: lock to where the pointer is - let start = if wh.sat >= 1.0 { wh.hue as f32 } else { wheel_pick(q - c, rad, None).0 }; - Some(ui.data_mut(|m| *m.get_temp_mut_or_insert_with(lock_id, || start))) + // Modifiers (as in Lightroom): Shift keeps the hue, Cmd/Ctrl keeps the saturation, Alt moves + // the puck a tenth as far as the pointer. Pressing or releasing one mid-drag re-grabs the + // puck as it is at that moment. + let grab_id = egui::Id::new(("wheelGrab", key)); + let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); + let grab = match resp.interact_pointer_pos() { + Some(q) if resp.dragged() || resp.clicked() => { + let held = ui.data(|m| m.get_temp::(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); + Some(held.unwrap_or_else(|| { + // a fine drag counts the pointer's travel from the press, not from the frame + // egui first called it a drag + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(q) } else { q }; + let g = WheelGrab::new(mods, wh.hue as f32, wh.sat as f32, origin - c, rad); + ui.data_mut(|m| m.insert_temp(grab_id, g)); + g + })) } _ => { - ui.data_mut(|m| m.remove::(lock_id)); + ui.data_mut(|m| m.remove::(grab_id)); None } }; - // while locked: the whole spoke, a guide for the saturation-only move - if let Some(h) = lock { - let a = h.to_radians(); - ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], Stroke::new(1.5, Color32::from_white_alpha(170))); + // while a constraint is held: the guide for the one move left (the whole spoke for the hue, + // the circle for the saturation) + let guide = Stroke::new(1.5, Color32::from_white_alpha(170)); + match grab.and_then(|g| g.lock()) { + Some(WheelLock::Hue(h)) => { + let a = h.to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], guide); + } + Some(WheelLock::Sat(s)) => { + ui.painter().circle_stroke(c, rad * s / 100.0, guide); + } + None => {} } // the neutral centre (issue #559), under the point ui.painter().circle_filled(c, 2.0, Color32::WHITE); @@ -1336,7 +1354,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings if (resp.dragged() || resp.clicked()) && let Some(q) = resp.interact_pointer_pos() { - let (hue, sat) = wheel_pick(q - c, rad, lock); + let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); } if resp.drag_stopped() || resp.clicked() { @@ -1369,17 +1387,98 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings } } +/// A wheel's modifier state. Shift keeps the hue, Cmd/Ctrl the saturation (Shift wins when both +/// are down: keeping both would freeze the puck), Alt fine-adjusts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct WheelMods { + pub keep_hue: bool, + pub keep_sat: bool, + pub fine: bool, +} + +impl WheelMods { + pub(crate) fn from_modifiers(m: egui::Modifiers) -> Self { + Self { keep_hue: m.shift, keep_sat: m.command && !m.shift, fine: m.alt } + } +} + +/// What a constraint holds still while the pointer moves. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum WheelLock { + /// Keep this hue (degrees): only the saturation changes, along the spoke at that hue. + Hue(f32), + /// Keep this saturation (0..100): only the hue changes, round the circle at that radius. + Sat(f32), +} + +/// Pointer travel to puck travel with Alt held, as for a slider's fine adjust. +const WHEEL_FINE: f32 = 0.1; + +/// The puck as it was when the pointer grabbed it (or a modifier last changed): the hue and +/// saturation a constraint keeps, and the origin a fine adjust measures the pointer's travel from. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct WheelGrab { + pub mods: WheelMods, + /// The puck's hue (degrees) and saturation (0..100) at the grab. + hue: f32, + sat: f32, + /// The pointer at the grab, as an offset from the wheel's centre. + origin: egui::Vec2, +} + +impl WheelGrab { + /// Grab a puck at `hue` / `sat` with the pointer `origin` from the centre of a wheel of + /// radius `rad`. The kept hue is the puck's own, never the angle the pointer happens to be + /// at. A puck at the centre (saturation under 1) has no hue yet, so there it is the angle of + /// the pointer at the grab; the same rule covers a non-finite hue. + pub(crate) fn new(mods: WheelMods, hue: f32, sat: f32, origin: egui::Vec2, rad: f32) -> Self { + let sat = if sat.is_finite() { sat.clamp(0.0, 100.0) } else { 0.0 }; + let hue = if hue.is_finite() && sat >= 1.0 { hue.rem_euclid(360.0) } else { wheel_pick(origin, rad, None).0 }; + Self { mods, hue, sat, origin } + } + + /// The constraint this grab holds, if any. + pub(crate) fn lock(&self) -> Option { + if self.mods.keep_hue { + Some(WheelLock::Hue(self.hue)) + } else if self.mods.keep_sat { + Some(WheelLock::Sat(self.sat)) + } else { + None + } + } + + /// The new hue and saturation for the pointer at offset `v` from the centre. + pub(crate) fn pick(&self, v: egui::Vec2, rad: f32) -> (f32, f32) { + let v = if self.mods.fine { + // the puck where it was, moved a tenth of the pointer's travel since the grab + let a = self.hue.to_radians(); + let puck = vec2(a.cos(), -a.sin()) * rad.max(1.0) * (self.sat / 100.0); + puck + (v - self.origin) * WHEEL_FINE + } else { + v + }; + wheel_pick(v, rad, self.lock()) + } +} + /// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. -/// `lock`: keep this hue and take the saturation from `v` projected onto its spoke (Shift-drag). -pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { +/// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the +/// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked +/// value, else 0). +pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { let rad = rad.max(1.0); + let v = if v.x.is_finite() && v.y.is_finite() { v } else { egui::Vec2::ZERO }; + let angle = || ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; match lock { - Some(h) => { + Some(WheelLock::Hue(h)) => { + let h = if h.is_finite() { h } else { 0.0 }; let a = h.to_radians(); let along = v.x * a.cos() - v.y * a.sin(); (h, (along / rad * 100.0).clamp(0.0, 100.0)) } - None => (((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0, (v.length() / rad * 100.0).min(100.0)), + Some(WheelLock::Sat(s)) => (angle(), if s.is_finite() { s.clamp(0.0, 100.0) } else { 0.0 }), + None => (angle(), (v.length() / rad * 100.0).min(100.0)), } } diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs index 8b6ec7c7b..6abce5460 100644 --- a/crates/ui-egui/src/tests_grading.rs +++ b/crates/ui-egui/src/tests_grading.rs @@ -104,18 +104,251 @@ fn hovering_a_three_way_wheel_shows_its_hue_and_saturation() { } #[test] -fn shift_drag_keeps_the_hue_and_changes_only_saturation() { - use crate::panels::edit::wheel_pick; +fn wheel_pick_reads_the_angle_and_distance_and_honours_a_lock() { + use crate::panels::edit::{WheelLock, wheel_pick}; let close = |a: f32, b: f32| (a - b).abs() < 0.01; // free: hue from the angle (screen y points down), saturation from the distance let (h, s) = wheel_pick(egui::vec2(0.0, -20.0), 40.0, None); assert!(close(h, 90.0) && close(s, 50.0), "{h} {s}"); - // locked at 0°: a rough drag up and right keeps the hue; only the part along the spoke counts - let (h, s) = wheel_pick(egui::vec2(30.0, -12.0), 40.0, Some(0.0)); + // hue locked at 0°: a rough drag up and right keeps the hue; only the part along the spoke counts + let (h, s) = wheel_pick(egui::vec2(30.0, -12.0), 40.0, Some(WheelLock::Hue(0.0))); assert!(close(h, 0.0) && close(s, 75.0), "{h} {s}"); // past the centre or the rim: clamped to 0..100 - assert_eq!(wheel_pick(egui::vec2(-10.0, 0.0), 40.0, Some(0.0)).1, 0.0); - assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, Some(0.0)).1, 100.0); + assert_eq!(wheel_pick(egui::vec2(-10.0, 0.0), 40.0, Some(WheelLock::Hue(0.0))).1, 0.0); + assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, Some(WheelLock::Hue(0.0))).1, 100.0); + assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, None).1, 100.0); + // saturation locked: the hue follows the pointer's angle whatever its distance + let (h, s) = wheel_pick(egui::vec2(0.0, -39.0), 40.0, Some(WheelLock::Sat(25.0))); + assert!(close(h, 90.0) && close(s, 25.0), "{h} {s}"); + let (h, s) = wheel_pick(egui::vec2(-3.0, 0.0), 40.0, Some(WheelLock::Sat(25.0))); + assert!(close(h, 180.0) && close(s, 25.0), "{h} {s}"); // a degenerate radius doesn't divide by zero assert!(wheel_pick(egui::vec2(1.0, 1.0), 0.0, None).1.is_finite()); } + +#[test] +fn wheel_pick_never_returns_nan_or_inf() { + use crate::panels::edit::{WheelLock, wheel_pick}; + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY]; + let locks = [None, Some(WheelLock::Hue(f32::NAN)), Some(WheelLock::Hue(400.0)), Some(WheelLock::Sat(f32::NAN)), Some(WheelLock::Sat(250.0))]; + for lock in locks { + for x in bad { + for rad in [0.0, -5.0, f32::NAN, 40.0] { + for v in [egui::vec2(x, 3.0), egui::vec2(3.0, x), egui::vec2(x, x), egui::vec2(0.0, 0.0)] { + let (h, s) = wheel_pick(v, rad, lock); + assert!(h.is_finite() && s.is_finite() && (0.0..=100.0).contains(&s), "{v:?} {rad} {lock:?} -> {h} {s}"); + } + } + } + } + // at the exact centre a saturation lock still answers + assert_eq!(wheel_pick(egui::Vec2::ZERO, 40.0, Some(WheelLock::Sat(30.0))).1, 30.0); +} + +#[test] +fn a_grab_keeps_the_pucks_own_hue_not_the_pointers_angle() { + use crate::panels::edit::{WheelGrab, WheelLock, WheelMods}; + let shift = WheelMods { keep_hue: true, ..Default::default() }; + // the puck is at 100° / 60 %, the pointer grabs it at 10°: the locked hue is 100° + let g = WheelGrab::new(shift, 100.0, 60.0, egui::vec2(30.0, -5.0), 40.0); + assert_eq!(g.lock(), Some(WheelLock::Hue(100.0))); + // a puck at the centre has no hue yet: the pointer's angle at the grab stands in for it + let g = WheelGrab::new(shift, 100.0, 0.0, egui::vec2(0.0, -20.0), 40.0); + match g.lock() { + Some(WheelLock::Hue(h)) => assert!((h - 90.0).abs() < 0.01, "{h}"), + other => panic!("{other:?}"), + } + // Cmd/Ctrl keeps the puck's saturation; Shift wins when both are down + let m = crate::panels::edit::WheelMods::from_modifiers(egui::Modifiers { shift: true, command: true, ..Default::default() }); + assert!(m.keep_hue && !m.keep_sat); + let m = crate::panels::edit::WheelMods::from_modifiers(egui::Modifiers { command: true, ..Default::default() }); + assert_eq!(WheelGrab::new(m, 100.0, 60.0, egui::Vec2::ZERO, 40.0).lock(), Some(WheelLock::Sat(60.0))); + // nothing held: free + assert_eq!(WheelGrab::new(WheelMods::default(), 100.0, 60.0, egui::Vec2::ZERO, 40.0).lock(), None); +} + +#[test] +fn a_fine_grab_moves_the_puck_a_tenth_as_far_as_the_pointer() { + use crate::panels::edit::{WheelGrab, WheelMods}; + let fine = WheelMods { fine: true, ..Default::default() }; + // the puck at 0° / 50 % on a wheel of radius 40 sits 20 right of the centre; the pointer + // grabs it there and moves 10 up: the puck moves 1 up (hue ≈ 2.9°), not 10 + let g = WheelGrab::new(fine, 0.0, 50.0, egui::vec2(20.0, 0.0), 40.0); + let (h, s) = g.pick(egui::vec2(20.0, -10.0), 40.0); + assert!((h - (1.0f32 / 20.0).atan().to_degrees()).abs() < 0.01 && (s - 50.0 * (1.0 + 0.0025f32).sqrt()).abs() < 0.1, "{h} {s}"); + // with no travel the puck stays put, wherever the pointer grabbed it + let g = WheelGrab::new(fine, 120.0, 30.0, egui::vec2(-5.0, 5.0), 40.0); + let (h, s) = g.pick(egui::vec2(-5.0, 5.0), 40.0); + assert!((h - 120.0).abs() < 0.01 && (s - 30.0).abs() < 0.01, "{h} {s}"); +} + +/// A wheel's size on screen: its radius. +fn wheel_radius(h: &Headless, key: &str) -> f32 { + widget(h, &format!("wheel:{key}")).unwrap().width() / 2.0 +} + +/// Where a puck at `hue` / `sat` sits, from the centre of a wheel of radius `rad`. +fn puck(hue: f64, sat: f64, rad: f32) -> egui::Vec2 { + let a = (hue as f32).to_radians(); + egui::vec2(a.cos(), -a.sin()) * rad * (sat as f32 / 100.0) +} + +fn wheel_value(h: &Headless, key: &str) -> (f64, f64) { + let id = h.app.session.active().unwrap(); + let g = h.app.session.catalog.photo(id).unwrap().develop.grading; + let w = match key { + "shadows" => g.shadows, + "midtones" => g.midtones, + _ => g.highlights, + }; + (w.hue, w.sat) +} + +fn set_wheel(h: &mut Headless, key: &str, hue: f64, sat: f64) { + h.app.session.execute("develop.set", &json!({"values": {format!("grading.{key}.hue"): hue, format!("grading.{key}.sat"): sat}})).unwrap(); + h.step(); + h.step(); +} + +/// Drag the midtones wheel from `from` (an offset from its centre) by `by`, holding `mods` +/// (`shift`, `cmd`, `alt`); the puck's new hue and saturation, and how many undo steps it took. +fn drag_wheel(h: &mut Headless, from: egui::Vec2, by: egui::Vec2, mods: &[&str]) -> ((f64, f64), usize) { + let w = widget(h, "wheel:midtones").unwrap().width(); + let mut req = json!({"id": "wheel:midtones", "fx": 0.5 + from.x / w, "fy": 0.5 + from.y / w, "dx": by.x, "dy": by.y, "steps": 12}); + for m in mods { + req[*m] = json!(true); + } + let undo = h.app.session.undo.len(); + let r = h.request("ui.dragWidget", req, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + (wheel_value(h, "midtones"), h.app.session.undo.len() - undo) +} + +/// The distance a puck moved, in points. +fn travelled(rad: f32, a: (f64, f64), b: (f64, f64)) -> f32 { + (puck(b.0, b.1, rad) - puck(a.0, a.1, rad)).length() +} + +#[test] +fn shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let a = 30f32.to_radians(); + let radial = egui::vec2(a.cos(), -a.sin()); + let tangent = egui::vec2(-a.sin(), -a.cos()); + // grab the puck and go 10 points outward and 15 round the wheel (rad 40: +25 % saturation) + let by = (radial * 10.0 + tangent * 15.0) * (rad / 40.0); + let ((hue, sat), undo) = drag_wheel(&mut h, puck(30.0, 50.0, rad), by, &["shift"]); + assert!((hue - 30.0).abs() <= 1.0, "hue stays: {hue}"); + assert!((sat - 75.0).abs() <= 1.5, "saturation follows the radial part: {sat}"); + assert_eq!(undo, 1, "one undo step per drag"); + // the same drag unconstrained does swing the hue round, so the lock is what held it + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((hue, _), undo) = drag_wheel(&mut h, puck(30.0, 50.0, rad), by, &[]); + assert!(hue > 50.0, "unlocked, the hue follows the pointer: {hue}"); + assert_eq!(undo, 1); +} + +#[test] +fn shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + // the puck is at 30°, the pointer grabs the wheel far off that angle (at 120°, and at 250°): + // the hue stays the puck's own, it doesn't take the angle under the pointer + for grab_angle in [120.0f64, 250.0] { + set_wheel(&mut h, "midtones", 30.0, 50.0); + let from = puck(grab_angle, 60.0, rad); + let ((hue, sat), undo) = drag_wheel(&mut h, from, egui::vec2(8.0, -6.0) * (rad / 40.0), &["shift"]); + assert!((hue - 30.0).abs() <= 1.0, "grabbed at {grab_angle}°, the hue is {hue}"); + let a = 30f32.to_radians(); + let end = from + egui::vec2(8.0, -6.0) * (rad / 40.0); + let along = (end.x * a.cos() - end.y * a.sin()) / rad * 100.0; + assert!((sat as f32 - along.clamp(0.0, 100.0)).abs() <= 1.5, "saturation is the pointer's part along the spoke: {sat} vs {along}"); + assert_eq!(undo, 1); + } +} + +#[test] +fn shift_drag_from_the_neutral_centre_locks_the_hue_where_the_pointer_grabbed() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + // no hue yet: the angle of the grab (90°, straight up) stands in for it + let ((hue, sat), undo) = drag_wheel(&mut h, puck(90.0, 50.0, rad), egui::vec2(6.0, -10.0) * (rad / 40.0), &["shift"]); + assert!((hue - 90.0).abs() <= 1.0, "{hue}"); + assert!((sat - 75.0).abs() <= 1.5, "{sat}"); + assert_eq!(undo, 1); +} + +#[test] +fn cmd_drag_keeps_the_saturation_and_changes_only_the_hue() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 30.0, 50.0); + // drag the puck out to 200° at 90 % of the radius: the hue follows, the saturation stays + let from = puck(30.0, 50.0, rad); + let to = puck(200.0, 90.0, rad); + let ((hue, sat), undo) = drag_wheel(&mut h, from, to - from, &["cmd"]); + assert!((hue - 200.0).abs() <= 1.0, "{hue}"); + assert_eq!(sat, 50.0, "the saturation is held"); + assert_eq!(undo, 1, "one undo step per drag"); + // and the same drag without Cmd takes the pointer's saturation too + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((_, sat), _) = drag_wheel(&mut h, from, to - from, &[]); + assert!((sat - 90.0).abs() <= 1.5, "{sat}"); +} + +#[test] +fn alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + let start = (30.0, 50.0); + let from = puck(start.0, start.1, rad); + let by = egui::vec2(20.0, 0.0) * (rad / 40.0); + set_wheel(&mut h, "midtones", start.0, start.1); + let (free, undo_free) = drag_wheel(&mut h, from, by, &[]); + set_wheel(&mut h, "midtones", start.0, start.1); + let (fine, undo_fine) = drag_wheel(&mut h, from, by, &["alt"]); + let (free_move, fine_move) = (travelled(rad, start, free), travelled(rad, start, fine)); + let expect = by.length() * 0.1; + assert!(free_move > by.length() * 0.7, "unmodified: the puck follows the pointer ({free_move})"); + assert!((fine_move - expect).abs() <= 1.5 * (rad / 40.0), "Alt: a tenth of {} is {expect}, moved {fine_move}", by.length()); + assert!(fine_move < free_move / 5.0); + assert_eq!((undo_free, undo_fine), (1, 1)); +} + +#[test] +fn alt_works_with_shift_and_cmd_constraints() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + let from = puck(30.0, 50.0, rad); + let a = 30f32.to_radians(); + // 20 points outward along the spoke, then 20 round the wheel + let radial = egui::vec2(a.cos(), -a.sin()) * 20.0 * (rad / 40.0); + let tangent = egui::vec2(-a.sin(), -a.cos()) * 20.0 * (rad / 40.0); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((hue, sat), undo) = drag_wheel(&mut h, from, radial, &["shift", "alt"]); + assert!((hue - 30.0).abs() <= 1.0, "{hue}"); + assert!((sat - 55.0).abs() <= 1.5, "20 points is 50 %, a tenth of it is 5: {sat}"); + assert_eq!(undo, 1); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((hue, sat), undo) = drag_wheel(&mut h, from, tangent, &["cmd", "alt"]); + assert_eq!(sat, 50.0); + // 2 points round a circle 20 points from the centre: 0.1 rad ≈ 5.7° + assert!((hue - 35.7).abs() <= 1.5, "{hue}"); + assert_eq!(undo, 1); +} + +#[test] +fn double_click_still_resets_the_wheel_with_a_modifier_held() { + let mut h = demo(); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let r = h.request("ui.clickWidget", json!({"id": "wheel:midtones", "count": 2, "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(wheel_value(&h, "midtones"), (0.0, 0.0)); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..2838a28b1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-EDIT-COLOR-GRADING: Cmd/Ctrl-drag keeps saturation, Alt/Option fine-adjusts (#1160); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -197,7 +197,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | | LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | ✅ | `ctl:bw.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; Shift-drag locks the hue and changes saturation only, showing the spoke as a guide; the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | From 9fc2e69328cdbeafa32f3eb366fbc312088e97d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E6=B4=8B=20=E9=99=88?= <205215139@qq.com> Date: Mon, 12 Oct 2026 13:18:21 +0800 Subject: [PATCH 067/142] gpu: read all mask overlay alphas in a single GPU->CPU readback For Overlay::AllMasks, the masks buffer already holds each visible mask's alpha back-to-back in the same order the single-mask overlay uses (cx.slice(all, mi*n, n)). Read the whole buffer once with cx.read instead of one synchronous read_plane per mask, then split it into per-mask Planes on the CPU. Same bytes, but the pipeline stalls once instead of N times. --- crates/gpu/src/render.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 12c7ec75e..828d2ba5a 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -712,14 +712,21 @@ pub fn render( _ => Err(m), } }); - // every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once) + // every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once). + // The masks buffer holds each visible mask's alpha back-to-back in the same `list` order the + // single-mask overlay uses, so read the whole buffer back in ONE GPU->CPU transfer and split it + // on the CPU, instead of one synchronous readback per mask (which stalls the pipeline N times). let all_overlay_alphas: Option> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) { masks.as_ref().map(|all| { let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); + if list.is_empty() { + return Vec::new(); + } + let raw: Vec = cx.read(all, n * list.len()); let mut alphas = Vec::with_capacity(list.len()); - for (mi, _) in list.iter().enumerate() { - let b = cx.slice(all, mi * n, n); - alphas.push(cx.read_plane(&b, w, h)); + for mi in 0..list.len() { + let s = mi * n; + alphas.push(Plane { width: w, height: h, data: raw[s..s + n].to_vec() }); } alphas }) From 92a2e721e24aa560517e8f90acb6c8fdc564481b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E6=B4=8B=20=E9=99=88?= <205215139@qq.com> Date: Mon, 12 Oct 2026 13:41:14 +0800 Subject: [PATCH 068/142] ui: fix stale Ctrl+O / Cmd+O in mask_overlay_all doc comment The shortcut moved to Ctrl+Shift+O / Cmd+Shift+O; this doc comment still advertised the old binding. --- crates/ui-egui/src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b46542c1f..897ef8052 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -542,7 +542,7 @@ pub struct UiState { pub mask_overlay_color: [u8; 3], pub mask_overlay_opacity: f32, pub mask_pins: bool, - /// Masking: show every visible mask's region as one overlay at once (Ctrl+O / Cmd+O), + /// Masking: show every visible mask's region as one overlay at once (Ctrl+Shift+O / Cmd+Shift+O), /// instead of just the selected mask's ([`mask_overlay`]). pub mask_overlay_all: bool, /// The overlay (on, mode) to go back to when Show Luminance Map is turned off. From 40b7b9b385259cc7cec5f45af8d84c27ac9002e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E6=B4=8B=20=E9=99=88?= <205215139@qq.com> Date: Mon, 12 Oct 2026 14:04:19 +0800 Subject: [PATCH 069/142] fmt: satisfy rustfmt on AllMasks test and detail panel --- crates/pipeline/src/visualize.rs | 14 ++++++++++++-- crates/ui-egui/src/panels/detail.rs | 5 +---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index dd87ce332..15c164bb1 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -413,8 +413,18 @@ mod tests { let left = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; let right = MaskShape::Radial { center: Point::new(0.75, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; let mut s = DevelopSettings::default(); - s.masks.push(Mask { id: 1, visible: true, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: left }], ..Default::default() }); - s.masks.push(Mask { id: 2, visible: true, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }], ..Default::default() }); + s.masks.push(Mask { + id: 1, + visible: true, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: left }], + ..Default::default() + }); + s.masks.push(Mask { + id: 2, + visible: true, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }], + ..Default::default() + }); let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }; let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 4c27ed8a0..de77369eb 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1127,10 +1127,7 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra } // Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay // so the shortcut is not shadowed by the always-on single-mask overlay. - if app.ui.right == RightPanel::Masking - && app.ui.mask_overlay_all - && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) - { + if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) { return Overlay::AllMasks { view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), color: app.ui.mask_overlay_color, From f1c023ed3deceb85ff0342a254ac815538ba3fba Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 09:24:29 +0200 Subject: [PATCH 070/142] LR-META-XMP: Read Metadata from File doesn't auto-write the sidecar it just read (#934) With XMP auto-write on, photo.readMetadataFromFile committed what it read and the command handler then wrote the sidecar back, adding lc:settings to a sidecar that had only another app's crs: settings. Later reads prefer lc:settings, so LRTimelapse's next crs: edits were ignored. The read now sets the existing one-command skip_auto_write switch (as photo.removeRegion does); edits made afterwards are still auto-written. Tests: a read leaves the sidecar byte for byte and a later develop edit is still written; the issue's crs:Exposure2012 0.5 -> 1.2 round trip is read. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/xmp.rs | 6 +++- crates/engine/src/tests_xmp.rs | 65 ++++++++++++++++++++++++++++++++++ docs/parity-checklist.md | 5 +-- docs/xmp-interop.md | 2 +- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index 317a87aca..6af62637f 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -49,6 +49,10 @@ fn read(s: &mut Session, p: &Value) -> Result { } if !ops.is_empty() { s.commit("Read Metadata from File", Op::Batch { ops })?; + // Don't auto-write the sidecars just read: that would add our `lc:settings` to one that had + // only another app's `crs:` settings, and later reads prefer `lc:settings`, so that app's + // next edits would be ignored (issue #934). The next edit, or Save Metadata, writes them. + s.skip_auto_write = true; } let mut result = json!({"read": read, "failed": failed}); if !warnings.is_empty() { @@ -90,7 +94,7 @@ pub fn specs() -> Vec { "Read Metadata from File", ["Photo"], None, - "{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}", + "{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}", has_selection, read ), diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..50223435e 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -175,6 +175,71 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() { let _ = std::fs::remove_dir_all(&src); } +/// A sidecar with only `crs:` develop settings, as LRTimelapse writes them (issue #934). +fn crs_exposure_sidecar(exposure: &str) -> String { + format!( + r#""# + ) +} + +/// Issue #934: with auto-write on, Read Metadata from File wrote what it had just read straight back +/// into the sidecar, adding our `lc:settings`. Reading leaves the file byte for byte; an edit made +/// after it is still written. +#[test] +fn reading_metadata_leaves_the_sidecar_alone_with_auto_write_on() { + let src = temp_dir("read-no-autowrite"); + write_png(&src.join("frame.png"), 6); + let sidecar = src.join("frame.xmp"); + std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = only(&s); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5); + // something for the read to change back (auto-write is still off, so the file stays as it is) + s.execute("develop.reset", &json!({"ids": [id.0]})).unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let before = std::fs::read(&sidecar).unwrap(); + let r = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5, "the sidecar was read"); + let now = std::fs::read(&sidecar).unwrap(); + assert!(now == before, "reading leaves the sidecar byte for byte, but it is now:\n{}", String::from_utf8_lossy(&now)); + // a develop edit afterwards is still written (only the read is exempt) + s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 0.9})).unwrap(); + let after = std::fs::read_to_string(&sidecar).unwrap(); + match crate::sidecar::parse_sidecar(&after, crate::crs::Target::Rendered).unwrap().develop { + Some(crate::sidecar::DevelopPatch::Full(d)) => assert_eq!(d.light.exposure, 0.9), + other => panic!("the edit wasn't auto-written: {other:?}\n{after}"), + } + assert!(after.contains(r#"crs:Exposure2012="0.5""#), "the other app's settings are kept: {after}"); + let _ = std::fs::remove_dir_all(&src); +} + +/// Issue #934, the round trip the write-back broke: after a read with auto-write on, another app +/// changes `crs:Exposure2012` from 0.5 to 1.2 (keeping the rest of the file), and the next read +/// takes 1.2. +#[test] +fn a_crs_change_after_a_read_is_taken_with_auto_write_on() { + let src = temp_dir("read-then-crs-change"); + write_png(&src.join("frame.png"), 7); + let sidecar = src.join("frame.xmp"); + std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = only(&s); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5); + // the other app edits the file as it is now + let text = std::fs::read_to_string(&sidecar).unwrap(); + let changed = text.replace(r#"crs:Exposure2012="0.5""#, r#"crs:Exposure2012="1.2""#); + assert_ne!(changed, text); + std::fs::write(&sidecar, changed).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 1.2, "the second read takes the other app's exposure"); + let _ = std::fs::remove_dir_all(&src); +} + /// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar /// and our own starting look stands in for them: importing one gives no camera-profile warning. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..c43686b1a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-META-XMP: Read Metadata from File no longer writes the sidecar back (#934); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -167,7 +167,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d1199..5e47b9157 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -10,7 +10,7 @@ other tools), and let LightCraft pick up edits made elsewhere. |---|---| | Name | `.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. With stem naming, files sharing a stem don't share a sidecar: see *Shared names* below. | | Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. An existing sidecar is **merged into, never replaced** (see *Saving into an existing sidecar*). Written atomically (temp file, fsync, rename). The result lists `written`, `merged` and `backups`. | -| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. | +| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). | | Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. | What we write (standard namespaces, so other tools can read the metadata): From 978312b5cc167331f5c3d6e14e98d1dc6a8fb7e4 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 21:32:45 +1100 Subject: [PATCH 071/142] =?UTF-8?q?Fix:=20import=20of=20metadata-less=20ex?= =?UTF-8?q?ports=20=E2=80=94=20file=20date=20as=20capture=20date,=20in-sou?= =?UTF-8?q?rce=20copies=20not=20"already=20in=20the=20library",=20Grid=20I?= =?UTF-8?q?nfo=20from=20Photo=20Grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Files with no capture date in their metadata or sidecar (e.g. exports saved without metadata) take the file's modification time, as Lightroom does, instead of all landing under "Unknown Date" (the loupe overlay showed the import date for them). The import review and its folder/rename templates use the same date. - The import review counted files with the same bytes as another file in the source as "already in the library" (8 on a brand-new, empty library); they are now shown apart as "identical copies". - View > Grid Info captions are drawn on Square Grid only; choosing one from Photo Grid now switches to Square Grid instead of seeming to do nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/files.rs | 39 ++++++++++++++++++++- crates/engine/src/import.rs | 6 ++++ crates/engine/src/media.rs | 4 +++ crates/engine/src/tests_import.rs | 3 ++ crates/engine/src/tests_import_move.rs | 3 ++ crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/headless.rs | 14 +++++++- crates/ui-egui/src/import.rs | 31 ++++++++++++++-- crates/ui-egui/src/menus.rs | 4 +++ crates/ui-egui/src/tests_masking.rs | 4 +++ docs/parity-checklist.md | 2 +- 28 files changed, 140 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..4d418bc46 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -176,6 +176,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { kind: MediaKind::Raw, file_size: bytes.len() as u64, captured, + file_modified: None, meta, as_shot_wb, content_hash, @@ -207,6 +208,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { kind: MediaKind::Image, file_size: bytes.len() as u64, captured, + file_modified: None, meta, as_shot_wb: None, content_hash, @@ -856,11 +858,21 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); let _permit = crate::memory::work_gate().acquire(len); let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - probe_bytes(path, &bytes) + let mut info = probe_bytes(path, &bytes)?; + info.file_modified = file_modified(path); + Ok(info) }); (loader, probe) } +/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the +/// filesystem doesn't say or the time is before 1970. +fn file_modified(path: &str) -> Option { + let t = std::fs::metadata(path).ok()?.modified().ok()?; + let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; + Some(crate::import::civil(secs)) +} + impl crate::Session { /// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos /// runner ([`crate::apple_photos::osascript`]; installed, not run). @@ -985,6 +997,31 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as + /// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. + #[test] + fn undated_files_take_the_file_date() { + let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rgb = vec![128u8; 16 * 16 * 3]; + let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let path = dir.join("export.jpg"); + std::fs::write(&path, &jpeg).unwrap(); + let path = path.to_string_lossy().to_string(); + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let want = crate::import::civil(mtime as i64); + let (_, probe) = fs_hooks(); + let info = probe(&path).unwrap(); + assert_eq!(info.captured, None, "the file itself has no date"); + assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); + let mut s = crate::Session::new().with_fs(); + let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); + let id = lightcraft_catalog::PhotoId(r.imported[0]); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); + let _ = std::fs::remove_dir_all(&dir); + } + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated /// file or one that isn't an image. diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 20d1983e1..e4abf6fc9 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -641,6 +641,8 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress Some(Ok(info)) => { (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); + // as the import will date it (a sidecar's date, when there is one, still wins there) + c.captured = c.captured.or_else(|| info.file_modified.clone()); c.preview_only = info.preview_only.clone(); if let Some(h) = &info.content_hash { match seen_hash.get(h) { @@ -974,6 +976,10 @@ impl ImportJob { if info.captured.is_none() { info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); } + // no date in the file or its sidecar: the file's own date, as Lightroom does + if info.captured.is_none() { + info.captured = info.file_modified.clone(); + } let stored = match (mode, &self.copy_root) { (ImportMode::Copy | ImportMode::Move, Some(root)) if !crate::import_move::inside(Path::new(&path), root) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..b1f3a2022 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1479,6 +1479,10 @@ pub struct ProbeInfo { pub kind: MediaKind, pub file_size: u64, pub captured: Option, + /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the + /// capture date of a file whose metadata has none, as Lightroom does (exports saved without + /// metadata would otherwise all land under "Unknown Date"). + pub file_modified: Option, pub meta: lightcraft_catalog::Meta, pub as_shot_wb: Option<(f64, f64)>, /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 185993494..e595fa9b7 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -21,6 +21,9 @@ fn write_png(path: &Path, seed: u8) { let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); + // undated files take the file's date: the tests' clock time (2026-01-14T05:58:48) + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); + std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); } fn ids(v: &Value, key: &str) -> usize { diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs index 6a19a5e5a..3e6e9208b 100644 --- a/crates/engine/src/tests_import_move.rs +++ b/crates/engine/src/tests_import_move.rs @@ -22,6 +22,9 @@ fn write_png(path: &Path, seed: u8) { let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); + // undated files take the file's date: the tests' clock time (2026-01-14T05:58:48) + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); + std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); } fn xmp(rating: u8) -> String { diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..22d707561 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "· {in_source} identical copies": "· {in_source} identische Kopien" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..769cfc79f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,6 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..24b28fbaf 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "· {in_source} identical copies": "· {in_source} copias idénticas" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..1e7ecd6d1 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..361613731 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "· {in_source} identical copies": "· {in_source} copies identiques" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..3676a692b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..af3a5560b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "· {in_source} identical copies": "・同一のコピー {in_source} 件" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..0d3e6e210 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,6 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..115d7ec8b 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "· {in_source} identical copies": "· {in_source} cópias idênticas" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..301475ef5 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..c15364d78 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "· {in_source} identical copies": "· идентичных копий: {in_source}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..02aea83d7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,6 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..a5c0e6e68 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "· {in_source} identical copies": "· ідентичних копій: {in_source}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..a50ad26e2 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,6 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..87741a934 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "· {in_source} identical copies": "· {in_source} 个相同副本" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..5fa024d57 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,6 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..0cca3a08d 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "· {in_source} identical copies": "· {in_source} 個相同副本" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..615abf87a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,6 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..5ceca0f0c 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3282,10 +3282,16 @@ mod tests { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join("IMG_01.png"), png).unwrap(); + // undated files are filed by the file's date (2026-01-14T05:58:48) + std::fs::File::options() + .write(true) + .open(src.join("IMG_01.png")) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); let dest_s = dest.to_string_lossy().to_string(); let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; let mut session = lightcraft_engine::Session::with_demo().with_fs(); - // undated files are filed by the import time session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); let mut app = LightcraftApp::new(session, services); app.ui.view = crate::state::ViewMode::PhotoGrid; @@ -3335,6 +3341,12 @@ mod tests { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); + std::fs::File::options() + .write(true) + .open(src.join(format!("IMG_0{i}.png"))) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); } let dest_s = dest.to_string_lossy().to_string(); let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..de9c1b833 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -911,19 +911,31 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) true } +/// Skipped candidates: (already in the library, identical copies within the source, in Recently +/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with +/// the same bytes as one earlier in the list (no library photo) is a copy in the source. +fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { + let trashed = d.trashed.iter().filter(|t| **t).count(); + let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); + (dups, in_source, trashed) +} + /// The dialog body: options, then the candidate grid. pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let t = Tokens::get(ui.ctx()); let n = d.candidates.len(); - let trashed = d.trashed.iter().filter(|t| **t).count(); - // files of photos in Recently Deleted are not "already in the library": they are counted apart - let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed); + let (dups, in_source, trashed) = dup_counts(d); let sel = d.selected_paths().len(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); if dups > 0 { ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); } + if in_source > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); + } if trashed > 0 { ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); } @@ -1524,6 +1536,19 @@ mod tests { ImportDialog::new(candidates) } + /// Identical copies inside the source folder are not "already in the library" (a fresh library + /// said "8 already in the library"). + #[test] + fn copies_within_the_source_are_not_counted_as_in_the_library() { + let mut d = dialog(); + assert_eq!(dup_counts(&d), (0, 1, 0), "same bytes as an earlier file, no library photo"); + d.candidates[4].duplicate = Some("content".into()); + d.candidates[4].existing = Some(7); + d.candidates[5].duplicate = Some("path".into()); + d.candidates[5].existing = Some(8); + assert_eq!(dup_counts(&d), (2, 1, 0)); + } + /// A toggle per file type: the raws only, without the JPEGs beside them (issue #344). #[test] fn file_types_check_and_uncheck_together() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..f68d6ed20 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -379,6 +379,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..b09b42763 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1122,6 +1122,10 @@ fn grid_info_cycles_caption() { exec(&mut h, "view.squareGrid", json!({})); exec(&mut h, "view.gridInfo", json!({"info": "exposure"})); h.settle(SETTLE); + // Photo Grid has no captions: picking one shows it in Square Grid + exec(&mut h, "view.photoGrid", json!({})); + exec(&mut h, "view.gridInfo", json!({"info": "date"})); + assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); } #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..5e38cf127 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -684,7 +684,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | -| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | +| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; picking one in Photo Grid switches to Square Grid); no people view | | LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | From a4716d9e4f1dc7aeb244471fa6ff9fd2ad3355eb Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 13:14:04 +0200 Subject: [PATCH 072/142] LR-IMP-FORMATS: transparent areas develop on white instead of black (#1212) The decoders keep the alpha plane, but to_working converted only the colour, so transparent pixels kept the black most encoders store under them: renders, previews, exports and contact sheets showed it. The working image is now flattened onto white, in linear light like the rest of the pipeline. --- crates/codecs/README.md | 2 +- crates/codecs/src/lib.rs | 20 ++++++++++++++++---- crates/codecs/tests/roundtrip.rs | 18 ++++++++++++++++++ crates/engine/src/files.rs | 11 +++++++++++ docs/parity-checklist.md | 5 +++-- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 4f9566b51..9ac54fca5 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -11,7 +11,7 @@ let fmt: Option = sniff(&bytes); // JPEG/PNG/TIFF/WebP/A let d: Decoded = decode(&bytes, DecodeOptions::default())?; // d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace) // d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs) -let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford) +let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford), alpha flattened onto white let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334a..76a4950ae 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -244,12 +244,24 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R } /// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space. +/// +/// The working space has no alpha: transparency is flattened onto white, in linear light like the rest +/// of the pipeline (issue #1212). pub fn to_working(d: &Decoded) -> Rgb32f { - if d.space.is_working() { - return d.image.clone(); + let mut img = if d.space.is_working() { + d.image.clone() + } else { + let m = d.space.to_working().to_f32(); + d.image.map(|p| mat_apply(&m, p)) + }; + // white is (1, 1, 1) in the working space too: the conversion is white-preserving + if let Some(a) = d.alpha.as_ref().filter(|a| a.width == img.width && a.height == img.height) { + for (p, &av) in img.data.iter_mut().zip(&a.data) { + let av = av.clamp(0.0, 1.0); + *p = p.map(|c| c * av + (1.0 - av)); + } } - let m = d.space.to_working().to_f32(); - d.image.map(|p| mat_apply(&m, p)) + img } /// Where a thumbnail came from. diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 121b83e60..af689ae5d 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -140,6 +140,24 @@ fn png_metadata_and_gray_alpha() { assert!((a - 128.0 / 255.0).abs() < 1e-6); } +/// The working image has no alpha: transparency is flattened onto white, in linear light (issue #1212: +/// transparent areas, stored black, rendered black). +#[test] +fn png_transparency_develops_on_white() { + // opaque red, transparent black (as most encoders store it), half-transparent red + let data = [200, 40, 40, 255, 0, 0, 0, 0, 200, 40, 40, 128]; + let bytes = encode_png(&EncodeImage::new(3, 1, 4, Samples::U8(&data)), &EncodeMeta::default()).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(d.has_alpha); + let opaque = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&[200, 40, 40])), &EncodeMeta::default()).unwrap(); + let red = decode(&opaque, DecodeOptions::default()).unwrap().to_working().get(0, 0); + let w = d.to_working(); + let a = 128.0 / 255.0; + for (got, want) in [(w.get(0, 0), red), (w.get(1, 0), [1.0; 3]), (w.get(2, 0), red.map(|c| a * c + (1.0 - a)))] { + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}"); + } +} + #[test] fn p3_icc_to_working() { let icc = write_named(NamedSpace::DisplayP3); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..09c97aff5 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1016,6 +1016,17 @@ mod tests { assert!(probe_bytes("x.jpg", b"not an image").is_err()); } + /// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets + /// start from), not on the black its transparent pixels store. + #[test] + fn transparent_png_develops_on_white() { + let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; + let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); + let (img, _) = load_bytes(&png, 64).unwrap(); + assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); + assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); + } + /// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample /// of a block on a grey surface must not become green speckles (the block's green used to take the /// clip level, which highlight reconstruction then kept). diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..57b948500 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-FORMATS: transparent images develop on white; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: transparent areas are flattened onto white in linear light instead of rendering black (issue #1212); status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | From 208aa023b0bbc3a8415cc9881987fd4ad67dab71 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 13:26:08 +0200 Subject: [PATCH 073/142] M5.6: the Detail photo's original stays over the memory budget (64 MP raws re-decoded on every pan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A decoded 64 MP original (Sony A7R VI: 9984×6656 linear float, 797 MB) is bigger than the default cache share (half of 1.5 GiB). MediaCache::enforce_budget evicted it as soon as the render handed it over, so every pan of a 1:1 view and every slider move while zoomed decoded the raw again (and lost the shared GPU copy, keyed by the CPU Arc). The frontend now tells the cache which photo Detail shows (MediaCache::set_on_screen, set per frame in panels::compare::prepare); that photo's original is not evicted for the budget and is let go as soon as another photo or view is shown. Keying it on "no newer preview of another photo" instead does not work: the neighbour prefetch stores previews after the original. --- crates/engine/src/media.rs | 39 +++++++++++++++++++++-- crates/ui-egui/src/panels/compare.rs | 3 ++ crates/ui-egui/src/tests_preview_limit.rs | 15 +++++++++ docs/whats-new.md | 8 +++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..b0ff1b265 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -314,6 +314,8 @@ pub struct MediaCache { full: Vec<(PhotoId, DecodedSource, u64)>, /// How many full-resolution originals to keep (LRU, at least one). full_capacity: usize, + /// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget. + on_screen: Option, /// How many previews to keep (LRU). pub preview_capacity: usize, /// Bytes all decoded sources and rendered previews in memory may take together (the cache @@ -391,6 +393,7 @@ impl Default for MediaCache { previews: Vec::new(), full: Vec::new(), full_capacity: 1, + on_screen: None, preview_capacity: 0, budget, file_loader: None, @@ -447,6 +450,16 @@ impl MediaCache { self.trim_full(); } + /// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays + /// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it + /// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once. + pub fn set_on_screen(&mut self, id: Option) { + if self.on_screen != id { + self.on_screen = id; + self.enforce_budget(); + } + } + fn trim_full(&mut self) { while self.full.len() > self.full_capacity { let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); @@ -475,7 +488,8 @@ impl MediaCache { /// Evict least recently used entries across the source caches and the rendered previews /// until they fit the budget. The newest preview-level source (the photo on screen, or the - /// one just decoded) is never evicted here. + /// one just decoded) is never evicted here, nor is the original of the photo on screen + /// ([`Self::set_on_screen`]). pub fn enforce_budget(&mut self) { let mut held = self.held(); while held > self.budget { @@ -483,7 +497,7 @@ impl MediaCache { let candidates = [ self.thumbs.oldest_tick().map(|t| (t, 0)), self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), - self.full.iter().map(|e| e.2).min().map(|t| (t, 2)), + self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)), self.rendered.oldest_tick().map(|t| (t, 3)), ]; let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; @@ -1793,6 +1807,27 @@ mod tests { assert!(m.has_source(PhotoId(3), SourceLevel::Full)); } + // A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): + // evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo + // on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. + #[test] + fn the_original_on_screen_stays_even_over_budget() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + m.insert(PhotoId(3), SourceLevel::Thumb, img(100)); + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.set_on_screen(Some(PhotoId(2))); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full)); + assert!(m.held() <= 20 * mb, "{}", m.held()); + } + #[test] fn work_gate_waits_for_room() { use crate::memory::WorkGate; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..3b5ac08d4 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -377,7 +377,10 @@ fn draw_window(p: &egui::Painter, app: &LightcraftApp, pane: u8, id: PhotoId, lo /// Per frame, before the central view is shown: Compare and Reference keep two photos' originals /// decoded (their windows are cut from both); other views keep one and free the panes' windows. +/// The Detail view's photo keeps its original even over the memory budget (a zoomed 64 MP raw). pub fn prepare(app: &mut LightcraftApp) { + let shown = if app.ui.view == ViewMode::Detail { app.session.active() } else { None }; + app.session.media.set_on_screen(shown); if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { app.session.media.set_full_capacity(WINDOW_PANES as usize); return; diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..8efdebe3a 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -596,6 +596,21 @@ mod in_the_loupe { assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on"); } + // Given an original bigger than the cache budget (a 64 MP raw's 797 MB exceeds the default + // share), zoomed to 1:1 it stays decoded, so a pan does not decode the raw again; back in the + // grid it is let go + #[test] + fn a_zoomed_original_over_the_budget_stays_until_the_view_changes() { + let (mut h, _) = detail(); + h.app.session.media.set_budget(16 << 20); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(h.app.session.memory_report().full_source.bytes > 16 << 20, "the original stayed over budget"); + h.request("ui.set", json!({"view": "photoGrid"}), T); + h.step(); + assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "the grid needs no original"); + } + /// The meshes the last frame drew with `slot`'s texture: (paint order, clip rect, bounds). fn drawn(h: &Headless, slot: Slot) -> Vec<(usize, egui::Rect, egui::Rect)> { let Some(tex) = h.app.renderer.textures.get(&slot) else { return vec![] }; diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..994a5a055 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-12 + +### Zoomed 64 MP raws stay decoded +- Panning a photo zoomed to 1:1, or moving a slider while zoomed, no longer decodes the whole raw again each time on + high-resolution cameras such as the Sony A7R VI (64 MP). Their decoded original (~800 MB) is bigger than the + default cache share (768 MB), so it was evicted as soon as it was stored. The original of the photo shown in Detail + now stays, like its preview, until another photo or view is shown. + ## 2026-10-11 ### JPEG XL export From 33accc389287bccaee1be4d8c1cf8e91f6c3cf77 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 13:48:05 +0200 Subject: [PATCH 074/142] M5.6: a zoomed window waits for the original another window is decoding (decoded 2-3 times) The Before window waited for the original only when the After's window job was already pending, but side by side the Before is asked for first in the frame, so both decoded the 64 MP raw at once; and a window asked for again while the first was still decoding (a pan, the layout settling at startup) started another full decode beside it (three decodes seen at startup). Any window now waits while a window job is pending and the original isn't held, and follows from the decoded original. --- crates/ui-egui/src/panels/detail.rs | 7 ++++--- crates/ui-egui/src/region.rs | 25 ++++++++++++----------- crates/ui-egui/src/tests_preview_limit.rs | 22 ++++++++++++++++++++ docs/whats-new.md | 3 +++ 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..83b7b8b48 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -309,10 +309,11 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt to_px * (v.visible.bottom() - v.target.top()), ); let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; - // the Before side waits for the original the After is decoding (see `defer_before_window`) + // a window waits for the original another window job is decoding (see `defer_window`) let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); - if crate::region::defer_before_window(v.before, app.renderer.is_pending(Slot::Region), original_held) { - return app.region_before_view.filter(|w| w.photo == c.id); + let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore); + if crate::region::defer_window(window_pending, original_held) { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); } let job = if v.before { app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..184a8319a 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -174,11 +174,13 @@ pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egu clip.intersect(canvas) } -/// Whether the Before window waits for the After's. Both are cut from the same decoded original; -/// asked for together while it isn't held yet, each worker decodes its own copy (two full-size -/// decodes, two uploads, one thrown away). The Before follows once the original is in. -pub fn defer_before_window(before_side: bool, after_pending: bool, original_held: bool) -> bool { - before_side && after_pending && !original_held +/// Whether a window waits for the one already being rendered. All windows are cut from the same +/// decoded original; asked for while it isn't held yet (the Before and After together, in either +/// order, or a new window while the first is still decoding), each worker decodes its own copy +/// (two or three full-size decodes and uploads, all but one thrown away). The waiting window +/// follows once the original is in. +pub fn defer_window(window_pending: bool, original_held: bool) -> bool { + window_pending && !original_held } /// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel @@ -427,14 +429,13 @@ mod tests { assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); } - // Given Before and After windows asked for at once at first open, only the After's goes: the - // Before's waits for the decoded original instead of decoding another copy + // Given a window asked for while another is rendering and the original isn't decoded yet, it + // waits for the decoded original instead of decoding another copy #[test] - fn the_before_window_waits_for_the_original_the_after_is_decoding() { - assert!(defer_before_window(true, true, false)); - assert!(!defer_before_window(true, true, true), "the original is in"); - assert!(!defer_before_window(true, false, false), "nothing is decoding it"); - assert!(!defer_before_window(false, true, false), "the After never waits"); + fn a_window_waits_for_the_original_another_is_decoding() { + assert!(defer_window(true, false)); + assert!(!defer_window(true, true), "the original is in"); + assert!(!defer_window(false, false), "nothing is decoding it"); } // Hostile numbers give a plan, never a panic diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 8efdebe3a..a8b547387 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -452,6 +452,28 @@ mod in_the_loupe { assert_eq!(main.size, before_main.size, "and the same whole-frame size under them"); } + // Given Before/After side by side, when I zoom to 1:1, then one window job decodes the original + // and the other side waits for it (the Before, asked for first, used to start a second decode + // beside the After's); both windows still arrive + #[test] + fn side_by_side_at_one_to_one_decodes_the_original_once() { + let (mut h, _) = detail(); + let id = h.app.session.active().expect("a photo is open"); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + let pending = |h: &Headless| [Slot::Region, Slot::RegionBefore].into_iter().filter(|s| h.app.renderer.is_pending(*s)).count(); + for _ in 0..100 { + h.step(); + if pending(&h) > 0 { + break; + } + } + assert!(!h.app.session.media.has_source(id, lightcraft_engine::SourceLevel::Full), "the original is still decoding"); + assert_eq!(pending(&h), 1, "one window job decodes it"); + h.settle(SETTLE); + assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some(), "both windows arrived"); + } + // Given the Before view alone (\), only the Before gets a window #[test] fn the_original_view_windows_only_the_before() { diff --git a/docs/whats-new.md b/docs/whats-new.md index 994a5a055..0d4300dbc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,6 +7,9 @@ high-resolution cameras such as the Sony A7R VI (64 MP). Their decoded original (~800 MB) is bigger than the default cache share (768 MB), so it was evicted as soon as it was stored. The original of the photo shown in Detail now stays, like its preview, until another photo or view is shown. +- Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy + beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling) + started yet another decode. ## 2026-10-11 From 63bcaa147fe006fc4fc846d7df621401abd7307e Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Mon, 12 Oct 2026 13:57:05 +0200 Subject: [PATCH 075/142] M5.6: a zoomed view keeps the GPU copy of its original while idle (re-uploaded after 3 s) The idle trim (3 s without renders) released the shared device copy of the original a zoom window is cut from, so pausing between pans made the next pan upload it again: 800 MB for a 64 MP raw, ~1.4 s of "gpu sample" measured (5 ms when it is still there). While a Detail window texture is held the renderer is not idle; once the windows are gone (zoomed out, another photo, or another view, which now frees the Detail view's windows) the trim runs as before. --- crates/ui-egui/src/panels/compare.rs | 10 ++++++++- crates/ui-egui/src/render.rs | 25 +++++++++++++++++++++-- crates/ui-egui/src/tests_preview_limit.rs | 15 ++++++++++++++ docs/gpu-pipeline.md | 2 +- docs/whats-new.md | 3 +++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 3b5ac08d4..fd6d69063 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -377,10 +377,18 @@ fn draw_window(p: &egui::Painter, app: &LightcraftApp, pane: u8, id: PhotoId, lo /// Per frame, before the central view is shown: Compare and Reference keep two photos' originals /// decoded (their windows are cut from both); other views keep one and free the panes' windows. -/// The Detail view's photo keeps its original even over the memory budget (a zoomed 64 MP raw). +/// The Detail view's photo keeps its original even over the memory budget (a zoomed 64 MP raw); +/// other views free the Detail view's windows (while one is shown the GPU copy of its original stays). pub fn prepare(app: &mut LightcraftApp) { let shown = if app.ui.view == ViewMode::Detail { app.session.active() } else { None }; app.session.media.set_on_screen(shown); + if app.ui.view != ViewMode::Detail { + app.region_view = None; + app.region_before_view = None; + app.region_tiles.clear(); + app.renderer.release(Slot::Region); + app.renderer.release(Slot::RegionBefore); + } if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { app.session.media.set_full_capacity(WINDOW_PANES as usize); return; diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..6afecbc3b 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -767,10 +767,12 @@ impl Renderer { } /// Free the GPU renderer's recycled buffers once nothing has rendered for [`IDLE_TRIM`] (they - /// are reallocated by the next render; while working, renders reuse them). + /// are reallocated by the next render; while working, renders reuse them). A zoomed view + /// showing its windows is not idle: the device copy of the original they are cut from stays for + /// the next pan (a 64 MP raw is 800 MB to upload again, over a second). #[cfg(not(target_arch = "wasm32"))] fn trim_when_idle(&mut self, ctx: &egui::Context) { - if !self.pending.is_empty() { + if !self.pending.is_empty() || self.textures.contains_key(&Slot::Region) || self.textures.contains_key(&Slot::RegionBefore) { self.idle = None; return; } @@ -1291,6 +1293,25 @@ mod stage_budget_tests { const MB: usize = 1 << 20; + // Given a zoomed view showing a window, the app idling does not free the GPU copy of the + // original the next window is cut from; once the windows are gone the idle trim runs again + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_zoomed_view_is_not_trimmed_while_idle() { + let ctx = egui::Context::default(); + let mut r = Renderer::default(); + let tex = ctx.load_texture("window", egui::ColorImage::new([1, 1], vec![egui::Color32::BLACK]), Default::default()); + r.textures.insert(Slot::Region, Tex { key: 1, photo: PhotoId(1), tex, size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None }); + let long_ago = std::time::Instant::now().checked_sub(IDLE_TRIM * 2).unwrap(); + r.idle = Some((long_ago, false)); + r.trim_when_idle(&ctx); + assert_eq!(r.idle, None, "a zoomed view is not idle"); + r.release(Slot::Region); + r.idle = Some((long_ago, false)); + r.trim_when_idle(&ctx); + assert_eq!(r.idle, Some((long_ago, true)), "trimmed once the window is gone"); + } + // Given stage caches within the budget, nothing is cleared #[test] fn nothing_is_trimmed_within_the_budget() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index a8b547387..7ebbd9b2a 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -335,6 +335,21 @@ mod in_the_loupe { assert_eq!(h.app.region_view, None); } + // Given a zoomed view with a window render, when I go to the grid, then its windows are freed + // too (while one is held the app never counts as idle and keeps the GPU copy of the original) + #[test] + fn leaving_a_zoomed_detail_view_frees_its_windows() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some()); + h.request("ui.set", json!({"view": "photoGrid"}), T); + h.step(); + assert_eq!((tile(&h, Slot::Region), tile(&h, Slot::RegionBefore)), (None, None)); + assert_eq!((h.app.region_view, h.app.region_before_view), (None, None)); + } + // Given a zoomed view, when the photo is edited, then the old window (made with the old look) // is no longer current and is replaced by one for the new look #[test] diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 883c3a0bf..cc9f19f57 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -267,7 +267,7 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo developed image in ~50 ms. ## Memory (M5.6) -A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles (issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). +A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles with no zoom window on screen (a zoomed view keeps it for its next pan: a 64 MP original is 800 MB to upload again; issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). - `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) diff --git a/docs/whats-new.md b/docs/whats-new.md index 0d4300dbc..191360f58 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,6 +10,9 @@ - Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling) started yet another decode. +- A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no + longer frees it, which cost a re-upload of about a second on the next pan (800 MB for a 64 MP raw). It is freed + once you zoom out or move on. ## 2026-10-11 From 3c50fd58e64ae46ac95511344fa9cb22adbd19f5 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:02:30 +0700 Subject: [PATCH 076/142] Clippy 1.99: as_chunks for the Fill spot's completion (chunks_exact_to_as_chunks) Rust 1.99's clippy rejects `chunks_exact(3)` with a constant size (`chunks_exact_to_as_chunks`, -D warnings), which failed `cargo xtask ci` on main. Same behaviour: the completed pixels are copied as [f32; 3]. Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/spots.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } From 1dc86d752c82ba28512c17352a75ace777704d2a Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:18:42 +0700 Subject: [PATCH 077/142] LR-VIEW-HISTOGRAM: the HDR histogram follows slider drags live A drag renders drafts, which have no HDR histogram, so an HDR edit's histogram dropped to the SDR layout until the drag ended. Now, during a drag of an HDR edit, a small full-quality render (Slot::HdrHistogram, 192 px, its own stage cache) runs beside the drafts and feeds the panel at its own pace: the drafts don't wait for it. The panel also keeps an HDR edit's last HDR histogram when a render without HDR bins arrives (a draft, a cached thumbnail or stand-in), so it never swaps to the SDR layout mid-drag. Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/lib.rs | 4 ++ crates/ui-egui/src/panels/edit.rs | 69 +++++++++++++++++++++++++++---- crates/ui-egui/src/render.rs | 42 ++++++++++++++++++- docs/parity-checklist.md | 2 +- 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 6fbb156e8..27d5d0543 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -471,6 +471,10 @@ pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::bor /// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures. pub const HDR_HISTOGRAM_EDGE: usize = 384; +/// Long edge of the render that gives a slider drag its live HDR histogram (drafts have none: a +/// second render per tick would slow them down), run beside the drafts by the UI. +pub const HDR_HISTOGRAM_DRAG_EDGE: usize = 192; + /// The histogram of an HDR edit's HDR rendition for a preview request `req`: a small float HDR /// render of the same framing ([`HDR_HISTOGRAM_EDGE`]), binned with SDR on the left half and the /// whole HDR range ([`Hdr::MAX_EV_LIMIT`](lightcraft_develop::Hdr::MAX_EV_LIMIT) stops above SDR diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..ecd9662de 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -6,11 +6,13 @@ use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId; use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls}; use lightcraft_geom::Point; +use lightcraft_raster::Histogram; use serde_json::{Value, json}; use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::render::Slot; +use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register, section_header, slider, text_button}; @@ -521,17 +523,44 @@ fn section( // ------------------------------------------------------------------------------ histogram +/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's +/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to +/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so +/// the histogram still follows the edit live; an SDR edit shows what it gets. +fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option) -> Option { + let key = egui::Id::new(("hdrHistogram", id)); + match hist { + Some(h) if h.hdr.is_some() => { + ctx.data_mut(|d| d.insert_temp(key, h.clone())); + Some(h) + } + h if hdr_edit => ctx.data(|d| d.get_temp::(key)).or(h), + h => h, + } +} + fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); - // the loupe render of this photo, else its thumbnail (grid view before the loupe has rendered it) - let hist = app - .renderer - .textures - .get(&Slot::Main) - .filter(|t| t.photo == id) - .and_then(|t| t.histogram.clone()) + let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); + // a drag of an HDR edit: its drafts have no HDR histogram, so a small render beside them + // gives one (at its own pace: the drafts don't wait for it) + let interacting = app.session.interaction.is_some(); + if interacting && hdr_edit { + let edge = lightcraft_pipeline::HDR_HISTOGRAM_DRAG_EDGE; + if let Some(job) = app.session.render_job(id, edge, edge, false, app.ui.right != RightPanel::Crop) + && app.renderer.live_hdr_histogram.as_ref().is_none_or(|l| l.1 != job.key) + { + app.renderer.request(Slot::HdrHistogram, job, 95); + } + } + let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); + // else the loupe render of this photo, else its thumbnail (grid view before the loupe has + // rendered it) + let hist = live + .or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id).and_then(|t| t.histogram.clone())) .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())); + let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist); // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label // row under the plot let hdr = hist.as_ref().and_then(|h| h.hdr); @@ -1435,3 +1464,29 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { }); }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { + let ctx = egui::Context::default(); + let id = PhotoId(7); + let sdr = Histogram::of_hdr(&[], 5.0); + let sdr = Histogram { hdr: None, ..sdr }; + let hdr = |v: f32| Histogram::of_hdr(&[[v, v, v]], 5.0); + // nothing kept yet: what arrives + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(2.0))), Some(hdr(2.0))); + // a cached thumbnail's 8-bit histogram (or none) doesn't swap the panel to SDR + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(2.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, None), Some(hdr(2.0))); + // the next HDR render replaces it: the histogram follows the slider + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(4.0))), Some(hdr(4.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0))); + // HDR turned off: the SDR histogram at once; other photos keep their own + assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..4f08e985a 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -55,6 +55,9 @@ pub enum Slot { Import(u32), /// The second window's view of the active photo. Second, + /// A slider drag's live HDR histogram: a small full-quality render of an HDR edit, beside the + /// drafts (which have no HDR histogram), with no texture ([`Renderer::live_hdr_histogram`]). + HdrHistogram, } pub struct Tex { @@ -187,6 +190,8 @@ pub struct Renderer { /// Slot → (key, priority) of the request in flight (queued or running). pending: HashMap, pub textures: HashMap, + /// The last [`Slot::HdrHistogram`] result: (photo, job key, histogram). + pub live_hdr_histogram: Option<(PhotoId, u64, Histogram)>, pub last_main_ms: f64, /// Jobs finished since start (for inspect/perf). pub completed: u64, @@ -231,7 +236,7 @@ impl Slot { /// An interactive view of the open photo: slider drags redo only the stages they feed, and a /// draft that finishes late still replaces older pixels. pub fn is_view(self) -> bool { - matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover) + matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover | Slot::HdrHistogram) } } @@ -262,6 +267,7 @@ impl Renderer { seq: 0, pending: HashMap::new(), textures: HashMap::new(), + live_hdr_histogram: None, last_main_ms: 0.0, completed: 0, keep_pixels: false, @@ -708,6 +714,13 @@ impl Renderer { if matches!(slot, Slot::Prefetch(_)) { continue; } + if slot == Slot::HdrHistogram { + if rendered.histogram.hdr.is_some() { + self.live_hdr_histogram = Some((r.photo, r.key, rendered.histogram)); + changed = true; + } + continue; + } if let Slot::ThumbQuick(id) = slot { if self.textures.contains_key(&Slot::Thumb(id)) { continue; // the real thumbnail won the race @@ -1161,6 +1174,33 @@ mod thumbnail_tests { assert!(!app.renderer.is_pending(Slot::Main)); } + #[test] + fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { + let (mut app, work, ctx) = fixture(); + let mut jobs = Vec::new(); + for exposure in [1.0, 2.0] { + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.hdr.enabled = true; + edit.light.exposure = exposure; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); + let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); + app.renderer.request(Slot::HdrHistogram, job, 95); + jobs.push(take_job(&work).1); + } + let keys: Vec = jobs.iter().map(|j| j.key).collect(); + let mut jobs = jobs.into_iter(); + let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); + // superseded while the drag goes on, it still lands: the histogram keeps moving + finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); + let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); + assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); + assert!(live.2.hdr.is_some(), "HDR bins"); + finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); + assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); + // only the histogram is kept: no texture + assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); + } + #[test] fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { let (mut app, work, ctx) = fixture(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..5aa003041 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -138,7 +138,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag; no drag-to-adjust on the histogram | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | ## D. Search & filter (FILT) From cf085041ed9d5ad10d5d0b5440aeec3873e14cee Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Mon, 12 Oct 2026 15:39:58 +0200 Subject: [PATCH 078/142] fix: deleting a photo asks first, also from the right-click menu (#1239) Cause: Settings > General > "Confirm before moving photos to Recently Deleted" defaulted to off, so Delete never asked unless the person found the setting; and the grid/filmstrip photo menu's Delete Photo ran photo.delete directly, skipping menus::confirm_delete even with the setting on. Fix: the setting is on by default; the photo menu's Delete Photo goes through menus::confirm_delete like the key and the menu bar. The setting is now saved as `askBeforeDelete`: every earlier version wrote `confirmDelete: false` for people who never opened Settings, so that key is no longer read and those installs ask too. Turning it off still works and survives a restart. ui.menu.invoke / engine.execute (agents) still run the command directly. Test: tests_panels::deleting_a_photo_asks_first_from_the_key_the_menu_and_ the_right_click_menu (failed: off by default, then the right-click menu deleted without asking) and tests_appearance::delete_asks_first_by_default_ also_for_settings_saved_by_earlier_versions (failed: default off). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 5 ++- crates/ui-egui/src/panels/grid.rs | 9 ++++- crates/ui-egui/src/state.rs | 7 +++- crates/ui-egui/src/tests_appearance.rs | 18 ++++++++- crates/ui-egui/src/tests_panels.rs | 52 ++++++++++++++++++++++++++ docs/parity-checklist.md | 4 +- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..2bb980c4e 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3604,7 +3604,10 @@ mod tests { assert_eq!(r["ok"], true, "{tab}: {r}"); assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); } - // General: confirm before delete, startup view + // General: confirm before delete (on by default), startup view + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(!h.app.ui.settings.confirm_delete); h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); assert!(h.app.ui.settings.confirm_delete); h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..e008e043a 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1137,8 +1137,13 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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!({})); + } else { + let delete = ui.button(crate::i18n::tr("Delete Photo")); + register(ui.ctx(), "button:contextDeletePhoto", delete.rect); + // asks first like the key and the menu bar (Settings ▸ General) + if delete.clicked() && !crate::menus::confirm_delete(app) { + let _ = app.run("photo.delete", json!({})); + } } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..b1d51b7cb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -234,7 +234,10 @@ pub struct AppSettings { /// [`RECENT_LIBRARIES`]). pub recent_libraries: Vec, pub startup_view: StartupView, - /// Ask before moving photos to Recently Deleted (keyboard and menu). + /// Ask before moving photos to Recently Deleted (keyboard, menu bar and a photo's menu); on by + /// default. Saved as `askBeforeDelete`: earlier versions saved `confirmDelete: false` for + /// everyone who never opened Settings, so that key is no longer read (#1239). + #[serde(rename = "askBeforeDelete")] pub confirm_delete: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, @@ -281,7 +284,7 @@ impl Default for AppSettings { library_path: String::new(), recent_libraries: Vec::new(), startup_view: StartupView::Last, - confirm_delete: false, + confirm_delete: true, gpu: true, preview_limit: 0, external_editor: String::new(), diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs index 947e6cf9f..78a48194f 100644 --- a/crates/ui-egui/src/tests_appearance.rs +++ b/crates/ui-egui/src/tests_appearance.rs @@ -27,15 +27,29 @@ fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { assert_eq!((s.appearance_mode, s.dark_theme, s.light_theme), (AppearanceMode::Dark, DarkTheme::Charcoal, LightTheme::Silver)); assert_eq!(s.theme(Some(egui::Theme::Light)), ThemeKind::Charcoal, "Auto is opt-in"); // a ui.json saved before appearance modes existed - let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "confirmDelete": true}}"#).unwrap(); + let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "startupView": "detail"}}"#).unwrap(); assert_eq!(old.settings.appearance_mode, AppearanceMode::Dark); assert_eq!(old.settings.dark_theme, DarkTheme::Charcoal); assert_eq!(old.settings.light_theme, LightTheme::Silver); - assert!(old.settings.confirm_delete); + assert_eq!(old.settings.startup_view, crate::state::StartupView::Detail); // and the Charcoal tokens are the ones LightCraft always had assert_eq!(crate::theme::Tokens::for_kind(ThemeKind::Charcoal).chrome, crate::theme::Tokens::default().chrome); } +/// Delete asks first by default (#1239). Earlier versions saved `"confirmDelete": false` for +/// everyone who never opened Settings, so that key is not read any more: those installs ask too. +/// A choice made from now on survives a restart. +#[test] +fn delete_asks_first_by_default_also_for_settings_saved_by_earlier_versions() { + assert!(AppSettings::default().confirm_delete); + let old: UiState = serde_json::from_str(r#"{"settings": {"confirmDelete": false}}"#).unwrap(); + assert!(old.settings.confirm_delete, "the old default does not turn the question off"); + let mut ui = UiState::default(); + ui.settings.confirm_delete = false; + let back: UiState = serde_json::from_str(&serde_json::to_string(&ui).unwrap()).unwrap(); + assert!(!back.settings.confirm_delete, "a choice made now is kept"); +} + #[test] fn choices_validate_and_survive_a_restart() { let mut a = app(); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bfa..dd6e509e6 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2141,3 +2141,55 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } + +/// Delete asks before photos go to Recently Deleted, by default and from every way in: the key, the +/// Photo menu and a photo's right-click menu (#1239). With the setting off, it deletes at once. +#[test] +fn deleting_a_photo_asks_first_from_the_key_the_menu_and_the_right_click_menu() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid"})); + assert!(h.app.ui.settings.confirm_delete, "on by default"); + let photo = h.app.session.visible_cloned()[0]; + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let deleted = |h: &Headless| h.app.session.catalog.photo(photo).unwrap().deleted; + + // ⌫ and Photo ▸ Delete Photo ask; Cancel keeps the photo + h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(h.app.ui.dialog, Some(Dialog::ConfirmDelete { count: 1 }), "the key asks"); + h.request("ui.dialog.cancel", json!({}), T); + assert!(!deleted(&h)); + // (what a click on the menu bar's item runs; `ui.menu.invoke` is the agents' engine.execute) + crate::menubar::run_item(&mut h.app, "photo.delete", json!({})).unwrap(); + assert_eq!(h.app.ui.dialog, Some(Dialog::ConfirmDelete { count: 1 }), "the menu asks"); + h.request("ui.dialog.cancel", json!({}), T); + assert!(!deleted(&h)); + + // the right-click menu's Delete Photo asks too; OK deletes + let right_click_delete = |h: &mut Headless| { + let thumb = widget(h, &format!("thumb:{}", photo.0)); + let r = h.request("ui.click", json!({"x": thumb.center().x, "y": thumb.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:contextDeletePhoto"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + }; + right_click_delete(&mut h); + assert_eq!(h.app.ui.dialog, Some(Dialog::ConfirmDelete { count: 1 }), "the right-click menu asks"); + assert!(!deleted(&h), "nothing deleted before the answer"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(deleted(&h)); + + // turned off, the right-click menu deletes at once + h.request("engine.execute", json!({"command": "edit.undo"}), T); + assert!(!deleted(&h)); + h.app.ui.settings.confirm_delete = false; + h.settle(SETTLE); + right_click_delete(&mut h); + assert_eq!(h.app.ui.dialog, None); + assert!(deleted(&h)); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..3de0655db 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -107,7 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; 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); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; 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); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | @@ -563,7 +563,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-RENAME | Rename N photos… | P1 | ✅ | `cmd:dialog.rename` | live “Rename N Photos…” label | | MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ✅ | `cmd:dialog.captureTime` | | | MENU-PHOTO-COVER | Set as album cover | P2 | ✅ | `cmd:album.setCover` | | -| MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | no confirmation; static label | +| MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | asks first (Settings ▸ General, on by default, #1239); static label | | MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | | | | MENU-WINDOW-MINIMIZE | Minimize / zoom | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Minimize (⌘M) and Zoom items at the top of Window | | MENU-WINDOW-PANELS | Panel switches | P0 | ✅ | `cmd:panel.edit`, `cmd:panel.crop`, `cmd:panel.remove`, `cmd:panel.masking`, `cmd:panel.presets`, `cmd:panel.versions` | | From 139e9633cf56ec83403a19ad2f8a3676312dbaaa Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 00:58:05 +1100 Subject: [PATCH 079/142] =?UTF-8?q?LRC-DEV-HISTORY:=20History=20cursor=20?= =?UTF-8?q?=E2=80=94=20going=20to=20a=20step=20adds=20no=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go to History Step (and new Previous/Next History Step commands) loads the step's settings and moves a per-photo cursor (`Photo.history_cursor`, `Op::SetHistoryCursor`, catalog format v8; older catalogs open at the latest step) instead of appending "History: …". Later steps stay listed, greyed in the History panel, and can be returned to; a new edit made behind the end drops them first. Each move is one undo step; undoing an edit restores the dropped steps and the cursor. `history.list` returns `cursor`. Also: spots.rs uses as_chunks (clippy chunks_exact_to_as_chunks). Sources: own design (Lightroom Classic / Photoshop History panel behaviour, observed). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/catalog/src/journal.rs | 3 +- crates/catalog/src/lib.rs | 21 ++++- crates/catalog/src/local.rs | 1 + crates/catalog/src/model.rs | 12 +++ crates/catalog/src/tests_format_version.rs | 3 +- crates/engine/src/cmd/develop.rs | 75 ++++++++++++++-- crates/engine/src/cmd/query.rs | 3 +- crates/engine/src/lib.rs | 5 +- crates/engine/src/tests_history_cursor.rs | 100 +++++++++++++++++++++ crates/pipeline/src/spots.rs | 4 +- crates/ui-egui/src/panels/right.rs | 38 +++++++- docs/parity-checklist.md | 7 +- 12 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/tests_history_cursor.rs diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 697ddf010..dd92b5e2d 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -50,6 +50,7 @@ //! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` | //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | +//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -74,7 +75,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 7; +pub const VERSION: u32 = 8; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index cc24266f2..ecde973ba 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -125,6 +125,12 @@ pub enum Op { id: PhotoId, step: HistoryStep, }, + /// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps. + SetHistoryCursor { + id: PhotoId, + #[serde(default)] + cursor: Option, + }, AddAlbum { album: Album, }, @@ -545,12 +551,25 @@ impl Catalog { Op::PushHistory { id, step } => { let p = self.photo_mut(id)?; let old = p.history.clone(); + let old_cursor = p.history_cursor.take(); + // a new edit made from an earlier step drops the steps after it + if let Some(c) = old_cursor { + p.history.truncate(c.saturating_add(1)); + } p.history.push(step); if p.history.len() > HISTORY_LIMIT { let n = p.history.len() - HISTORY_LIMIT; p.history.drain(..n); } - Op::SetHistory { id, history: old } + if old_cursor.is_none() { + Op::SetHistory { id, history: old } + } else { + Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] } + } + } + Op::SetHistoryCursor { id, cursor } => { + let p = self.photo_mut(id)?; + Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) } } Op::AddAlbum { album } => { if self.albums.contains_key(&album.id) { diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index ff83a179d..9cfc14f35 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -279,6 +279,7 @@ impl Op { | Op::SetVersions { id, .. } | Op::SetHistory { id, .. } | Op::PushHistory { id, .. } + | Op::SetHistoryCursor { id, .. } | Op::SetCaptured { id, .. } | Op::SetAnalysis { id, .. } | Op::SetFile { id, .. } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index abeaadad4..a4216a37e 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -247,6 +247,11 @@ pub struct Photo { pub versions: Vec, #[serde(default)] pub history: Vec, + /// The History step the photo's settings are at, when it isn't the latest one (Go to History + /// Step moved back): later steps stay listed and can be returned to until a new edit drops + /// them. `None` (and catalogs written before the cursor existed) = the latest step. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_cursor: Option, /// In "Recently Deleted". #[serde(default)] pub deleted: bool, @@ -327,6 +332,12 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool { } impl Photo { + /// The History step the settings are at (the cursor, clamped to the list; `None` = no history). + pub fn history_index(&self) -> Option { + let last = self.history.len().checked_sub(1)?; + Some(self.history_cursor.map_or(last, |c| c.min(last))) + } + pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { Photo { id, @@ -347,6 +358,7 @@ impl Photo { edited: None, versions: Vec::new(), history: Vec::new(), + history_cursor: None, deleted: false, local: false, duration: None, diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index 37bd639e8..e2461781a 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -211,8 +211,9 @@ fn op_variants_are_versioned() { Op::SetKeyword { .. } => 5, Op::SetFolderRecord { .. } => 6, Op::SetAsShotWb { .. } => 7, + Op::SetHistoryCursor { .. } => 8, } } - let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None }); + let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 42f59fe72..1ae72e48e 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -1079,15 +1079,47 @@ pub fn specs() -> Vec { let id = active(s, "history.clear")?; let d = s.develop_of(id).unwrap_or_default(); let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }]; - s.commit("Clear History", Op::SetHistory { id, history })?; + s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?; ok() }), - cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| { - let id = active(s, "history.restore")?; - let i = f64_req(p, "index", "history.restore")? as usize; - let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?; - s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?; - ok() + cmd!( + "history.restore", + "Go to History Step", + [], + None, + "{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit", + has_active, + |s, p| { + let c = "history.restore"; + let id = active(s, c)?; + let i = f64_req(p, "index", c)?; + if !(i.is_finite() && i >= 0.0) { + return Err(bad(c, "no such step")); + } + go_to_history_step(s, c, id, i as usize) + } + ), + cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| { + let c = "history.previous"; + let id = active(s, c)?; + let i = s + .catalog + .photo(id) + .and_then(|ph| ph.history_index()) + .and_then(|i| i.checked_sub(1)) + .ok_or_else(|| bad(c, "at the first History step"))?; + go_to_history_step(s, c, id, i) + }), + cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| { + let c = "history.next"; + let id = active(s, c)?; + let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; + let i = ph + .history_index() + .map(|i| i.saturating_add(1)) + .filter(|i| *i < ph.history.len()) + .ok_or_else(|| bad(c, "at the latest History step"))?; + go_to_history_step(s, c, id, i) }), // ---- interactions (slider drags, brush strokes) cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| { @@ -1104,3 +1136,32 @@ pub fn specs() -> Vec { }), ] } + +/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later +/// steps stay listed (one undo step). +fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result { + let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; + let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?; + let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i); + let label = format!("History: {}", st.label); + let edited = Some((s.clock)()); + s.commit( + &label, + Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] }, + )?; + ok() +} + +fn has_history_before(s: &Session) -> std::result::Result<(), String> { + let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index()); + if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) } +} + +fn has_history_after(s: &Session) -> std::result::Result<(), String> { + let photo = s.active().and_then(|id| s.catalog.photo(id)); + if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) { + Ok(()) + } else { + Err("at the latest History step".into()) + } +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 6c232860d..2feb9dbb3 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -204,11 +204,12 @@ pub fn specs() -> Vec { )) }), cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())), - cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| { + cmd!(query "history.list", "List History", [], None, "{id?} — returns {history: labels oldest first, cursor: index of the step the settings are at (later steps can be returned to), versions}", has_active, |s, p| { let id = photo_arg(s, p, "history.list")?; let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?; Ok(json!({ "history": ph.history.iter().map(|h| h.label.clone()).collect::>(), + "cursor": ph.history_index(), "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..f873c115d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -130,7 +130,8 @@ fn single_photo(op: &Op, depth: usize) -> Option { | Op::SetMeta { id, .. } | Op::SetVersions { id, .. } | Op::SetHistory { id, .. } - | Op::PushHistory { id, .. } => Some(*id), + | Op::PushHistory { id, .. } + | Op::SetHistoryCursor { id, .. } => Some(*id), Op::Batch { ops } if depth < 8 => { let mut ids = ops.iter().map(|o| single_photo(o, depth + 1)); let first = ids.next()??; @@ -999,6 +1000,8 @@ mod tests_folders; #[cfg(test)] mod tests_forget_local; #[cfg(test)] +mod tests_history_cursor; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs new file mode 100644 index 000000000..55c265403 --- /dev/null +++ b/crates/engine/src/tests_history_cursor.rs @@ -0,0 +1,100 @@ +//! Feature: the History panel has a cursor. Going to a step loads its settings without adding a +//! step; later steps stay listed until a new edit drops them; undo/redo stay coherent. + +use serde_json::json; + +use crate::Session; + +fn exposure(s: &Session) -> f64 { + s.develop_of(s.active().unwrap()).unwrap().light.exposure +} + +fn list(s: &mut Session) -> (usize, Option) { + let h = s.execute("history.list", &json!({})).unwrap(); + (h["history"].as_array().unwrap().len(), h["cursor"].as_u64().map(|c| c as usize)) +} + +/// A demo session with three exposure edits (1, 2, 3) as its latest History steps. +fn three_edits() -> (Session, usize) { + let mut s = Session::with_demo(); + for v in [1.0, 2.0, 3.0] { + s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap(); + } + let (n, cursor) = list(&mut s); + assert_eq!(cursor, Some(n - 1), "the cursor starts at the latest step"); + (s, n) +} + +#[test] +fn going_back_adds_no_step_and_forward_returns() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + assert_eq!(exposure(&s), 1.0); + assert_eq!(list(&mut s), (n, Some(n - 3)), "no new step; later steps kept"); + s.execute("history.next", &json!({})).unwrap(); + assert_eq!(exposure(&s), 2.0); + s.execute("history.restore", &json!({"index": n - 1})).unwrap(); + assert_eq!(exposure(&s), 3.0); + assert_eq!(list(&mut s), (n, Some(n - 1))); + assert!(s.execute("history.next", &json!({})).is_err(), "nothing after the latest step"); + s.execute("history.previous", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (2.0, (n, Some(n - 2)))); + assert!(s.execute("history.restore", &json!({"index": n})).is_err()); + assert!(s.execute("history.restore", &json!({"index": -1})).is_err()); +} + +#[test] +fn a_new_edit_behind_the_end_drops_the_later_steps() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap(); + assert_eq!(list(&mut s), (n - 1, Some(n - 2)), "steps 2 and 3 dropped, the new one appended"); + assert_eq!(exposure(&s), -1.0); + // undo brings the dropped steps and the cursor back + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (-1.0, (n - 1, Some(n - 2)))); +} + +#[test] +fn undo_and_redo_move_the_cursor_with_the_settings() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (3.0, (n, Some(n - 1))), "undoing a step move"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); +} + +#[test] +fn clearing_history_resets_the_cursor() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 2})).unwrap(); + s.execute("history.clear", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (2.0, (1, Some(0)))); + assert_eq!(s.catalog.photo(s.active().unwrap()).unwrap().history_cursor, None); +} + +#[test] +fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() { + let mut s = Session::with_demo(); + let snap = s.catalog.to_snapshot(); + let _ = s.drain_log(); + for v in [1.0, 2.0] { + s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap(); + } + let (n, _) = list(&mut s); + s.execute("history.restore", &json!({"index": n - 2})).unwrap(); + let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect(); + let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap(); + c.replay(&log).unwrap(); + assert_eq!(c.to_snapshot(), s.catalog.to_snapshot()); + let id = s.active().unwrap(); + assert_eq!(c.photo(id).unwrap().history_cursor, Some(n - 2)); + // a photo written before the cursor existed has no `history_cursor` key: at the latest step + let mut v = serde_json::to_value(c.photo(id).unwrap()).unwrap(); + v.as_object_mut().unwrap().remove("history_cursor"); + let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap(); + assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1))); +} diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 501a78c5e..a46ca332b 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1105,6 +1105,23 @@ fn short_time(iso: &str) -> String { } } +/// How a History row is drawn: the step the settings are at is highlighted, the steps after it +/// (still there to return to until a new edit) are greyed out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HistoryRow { + Done, + Current, + Undone, +} + +fn history_row(i: usize, cursor: Option) -> HistoryRow { + match cursor { + Some(c) if i == c => HistoryRow::Current, + Some(c) if i > c => HistoryRow::Undone, + _ => HistoryRow::Done, + } +} + fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; header(ui, "History"); @@ -1113,19 +1130,24 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if p.history.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); } + let cursor = p.history_index(); for (i, h) in p.history.iter().enumerate().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); register(ui.ctx(), format!("history:{i}"), r); - if resp.hovered() { + let row = history_row(i, cursor); + if row == HistoryRow::Current { + ui.painter().rect_filled(r, 3.0, t.pressed); + } else if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon); + let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon); ui.painter().text( pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::history_label(&h.label, &app.session.presets), t.font(12.5), - t.text_label, + text, ); if resp.clicked() { let _ = app.run("history.restore", json!({"index": i})); @@ -1149,6 +1171,16 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { #[cfg(test)] mod tests { + // Feature: the History panel greys out the steps after the cursor + #[test] + fn history_rows_after_the_cursor_are_greyed() { + use super::{HistoryRow, history_row}; + let rows: Vec<_> = (0..4).map(|i| history_row(i, Some(1))).collect(); + assert_eq!(rows, [HistoryRow::Done, HistoryRow::Current, HistoryRow::Undone, HistoryRow::Undone]); + assert_eq!(history_row(3, Some(3)), HistoryRow::Current, "at the latest step nothing is greyed"); + assert_eq!(history_row(0, None), HistoryRow::Done); + } + // Feature: the aspect button tells what is locked #[test] fn aspect_label_names_presets_in_either_orientation() { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..ae30c8d18 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor — going to a step adds no step, later steps stay until a new edit; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -329,7 +329,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | | LR-VER-PANEL | Versions panel | P1 | ✅ | `cmd:panel.versions`, `cmd:version.create`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update`, `crates/ui-egui/src/panels/right.rs` (`versions`) | Named / Auto tabs, a thumbnail of each version's look, name and date, current marker; resting on a version previews it in the loupe; click restores, double-click renames; row menu (update, rename, set as before, delete). No automatic versions are created yet (LR-VER-AUTO) | | LR-VER-AUTO | Automatic versions | P2 | ✅ | `crates/engine/src/lib.rs` (`auto_version`) | leaving an edited photo keeps its settings as an Auto version (when they differ from the latest version; 20 kept; not an undo step); Versions panel ▸ Auto | -| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | +| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore`, `cmd:history.previous`, `cmd:history.next` | going to a step moves a per-photo cursor without adding a step; later steps stay listed (greyed) until a new edit drops them; `history.list` returns `cursor` | | LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | ## N. Copy / paste / sync (SYNC) @@ -712,7 +712,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | -| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | +| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.previous`, `cmd:history.next`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history; clicking a step loads it without adding one (current step highlighted, later steps greyed and returnable until a new edit truncates them; one undo step; cursor saved in the catalog, old catalogs open at the latest step) | | LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings (the display's gamut is the monitor profile's, `cmd:app.displayProfile`, else sRGB), Create Proof Copy (a named virtual copy). printer / paper ICC profiles can be loaded as the proof profile (size-capped, the last few kept), with rendering intent (Perceptual / Relative Colorimetric), Simulate Paper & Ink and a device-gamut warning (`crates/codecs/src/proof.rs`, `crates/engine/src/proof.rs`) | | LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | `cmd:develop.sync`, `cmd:develop.autoSync`, `cmd:develop.pastePrevious` | Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner | | LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | `cmd:develop.matchExposure` | Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | From 7ccff3b96ea6a9d20995114d87764d2de9dd6ed9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 16:14:39 +0200 Subject: [PATCH 080/142] fix(toast): draw the toast as widgets so the message can be selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast was painted straight onto the layer with painter.galley(), and painted text is never hit-tested: nothing in it could be selected or copied. A toast often carries the path or the command the user needs elsewhere ("Exported frame to /path"), and egui's selectable labels — on by default — only work on real widgets. Draw it as an Area with a Frame and a wrapping Label instead, keeping the same centring, 6 px radius, 20/11 padding and the 0.25 s fade-out. The new tests assert the toast has an area of its own, that the pointer reaches the text (egui offers the text cursor over it), and that a long message still wraps within the canvas. All three fail when the toast is painted, which is what they are for. --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/mod.rs | 28 ++++++++++----- crates/ui-egui/src/tests_toast.rs | 58 +++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tests_toast.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..b2b60f183 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -114,6 +114,8 @@ mod tests_sync; #[cfg(test)] mod tests_titlebar; #[cfg(test)] +mod tests_toast; +#[cfg(test)] mod tests_unsaved; #[cfg(test)] mod tests_wb_selector; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..7a90d5a10 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -69,6 +69,9 @@ pub fn resizable_side( } /// The HUD toast at the bottom centre of the canvas. +/// +/// Drawn as widgets rather than a painted galley so the message can be selected and copied: an +/// error here often carries a path or a command the user wants to paste somewhere else. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); let Some((text, until, label)) = app.ui.toast.clone() else { return }; @@ -79,16 +82,25 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(canvas) = app.canvas_rect else { return }; let t = Tokens::get(ctx); let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32; - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast"))); + let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); + let background = background.gamma_multiply(fade); + let foreground = foreground.gamma_multiply(fade); // long messages (a save warning, the web build's notices) wrap within the canvas let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0); - let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); - let foreground = foreground.gamma_multiply(fade); - let galley = painter.layout(text, t.font(14.0), foreground, wrap); - let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0)); - let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size); - painter.rect_filled(r, 6.0, background.gamma_multiply(fade)); - painter.galley(r.center() - galley.size() / 2.0, galley, foreground); + egui::Area::new(egui::Id::new("toast")) + .order(egui::Order::Foreground) + .movable(false) + .pivot(Align2::CENTER_CENTER) + .fixed_pos(pos2(canvas.center().x, canvas.bottom() - 60.0)) + .show(ctx, |ui| { + ui.set_max_width(wrap); + egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show( + ui, + |ui| { + ui.add(egui::Label::new(egui::RichText::new(text).font(t.font(14.0)).color(foreground)).wrap()); + }, + ); + }); ctx.request_repaint_after(std::time::Duration::from_millis(30)); } diff --git a/crates/ui-egui/src/tests_toast.rs b/crates/ui-egui/src/tests_toast.rs new file mode 100644 index 000000000..cf671d242 --- /dev/null +++ b/crates/ui-egui/src/tests_toast.rs @@ -0,0 +1,58 @@ +//! The toast is drawn as widgets, not as paint. That is what makes its message selectable: a toast +//! often carries a path or a command the user wants somewhere else, and painted text cannot be +//! picked up by the pointer at all. + +use std::time::Duration; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // let the first renders finish: a render still running when the test binary exits can crash in the + // GPU driver's teardown (the preview pool doesn't join its workers) + h.settle(T); + h +} + +/// One event per frame: [`LightcraftApp::raw_input_hook`] takes pointer events singly. +fn send(h: &mut Headless, e: egui::Event) { + h.app.synthetic.push(e); + h.step(); +} + +fn show_toast(h: &mut Headless, msg: &str) -> egui::Rect { + h.app.ui.toast = Some((msg.to_owned(), f64::MAX, None)); + h.step(); + h.step(); + h.view.ctx.memory(|m| m.area_rect(egui::Id::new("toast"))).expect("the toast is laid out as a widget, not paint") +} + +#[test] +fn the_toast_is_a_widget_the_pointer_reaches() { + let mut h = demo(); + let msg = "Exported frame to /tmp/scene-0001.png"; + let area = show_toast(&mut h, msg); + + assert!(area.is_positive(), "the toast has a rect of its own: {area:?}"); + assert!(h.painted_text().iter().any(|t| t.contains("Exported frame")), "the message is still painted"); + + // The pointer reaches the message: egui offers the text cursor over it. Painted text is never + // hit-tested, so this is the difference that makes selection possible at all. + send(&mut h, egui::Event::PointerMoved(area.center())); + assert_eq!(h.last_cursor, egui::CursorIcon::Text, "the toast text is a text target"); +} + +#[test] +fn the_toast_still_wraps_a_long_message_inside_the_canvas() { + let mut h = demo(); + let msg = "The SAM 3 download failed: the file is 4.1 GB and the connection dropped at 98 %, so nothing was installed and the masks are still unavailable"; + let area = show_toast(&mut h, msg); + + let canvas = h.app.canvas_rect.expect("the demo has a canvas"); + assert!(area.width() <= canvas.width() - 80.0, "the toast wraps within the canvas: {area:?}"); + assert!(h.painted_text().iter().any(|t| t.contains("SAM 3 download failed")), "the whole message is painted"); +} From 77ebac813a3ad0010db92042c986832c0d98f7a0 Mon Sep 17 00:00:00 2001 From: Markus Friedel <20045158+MarkusFriedel@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:15:08 +0200 Subject: [PATCH 081/142] LR-LIB-ALBUM: "Include selected photos" option in New Album (#1249) New Album always put the selection into the new album. Like Lightroom's Create Collection, the dialog now has an "Include selected photos" checkbox: ticked by default (today's behaviour), shown only when something is selected and never for a folder. dialog.newAlbum takes includeSelected; album.create already had addSelected. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 1 + crates/ui-egui/src/headless.rs | 2 +- crates/ui-egui/src/menus.rs | 17 +++++++--- crates/ui-egui/src/panels/dialogs.rs | 17 ++++++++-- crates/ui-egui/src/panels/left.rs | 4 +-- crates/ui-egui/src/state.rs | 5 ++- crates/ui-egui/src/tests_panels.rs | 51 +++++++++++++++++++++++++++- docs/parity-checklist.md | 2 +- 8 files changed, 86 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..475f27e0a 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -703,6 +703,7 @@ "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", "In use": "In Verwendung", "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", + "Include selected photos": "Ausgewählte Fotos einschließen", "Include subfolders": "Unterordner einbeziehen", "Increase Brush Feather": "Weiche Pinselkante vergrößern", "Increase Brush Size": "Pinselgröße vergrößern", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..9c1b6c01a 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1678,7 +1678,7 @@ mod tests { } assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); }; - confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None }, "Weddings"); + confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings"); assert!(album(&h, "Weddings").is_some(), "New Album"); confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..0cb49d83a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1069,13 +1069,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = - Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true, parent: parent_param(p) }); + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: true, + parent: parent_param(p), + include_selected: false, + }); Ok(Value::Null) } "dialog.newAlbum" => { - app.ui.dialog = - Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false, parent: parent_param(p) }); + // {name?, parent?, includeSelected? (true)} + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: false, + parent: parent_param(p), + include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true), + }); Ok(Value::Null) } "dialog.autoStack" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..9456dc2b6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -608,7 +608,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } } - Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { + Dialog::NewAlbum { name, folder, include_selected, .. } => { + if dialog_field(ui, "field:albumName", name, "Name") { + confirm = true; + } + // as in Lightroom, the option is only there when something is selected + if !*folder && !app.session.targets(&json!({})).is_empty() { + ui.add_space(6.0); + let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); + crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect); + } + } + Dialog::RenameAlbum { name, .. } => { if dialog_field(ui, "field:albumName", name, "Name") { confirm = true; } @@ -1520,8 +1531,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder, "parent": parent})) + Dialog::NewAlbum { name, folder, parent, include_selected } => { + created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent})) } Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), Dialog::TextPrompt { value, command, params, key, .. } => { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..108a57db0 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -533,7 +533,7 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option, + #[serde(default = "yes")] + include_selected: bool, }, RenameAlbum { id: u64, diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bfa..4d8634ccb 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2129,7 +2129,7 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { h.step(); h.step(); match &mut h.app.ui.dialog { - Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None }) => *name = "Trip".into(), + Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None, .. }) => *name = "Trip".into(), other => panic!("New Album dialog expected, got {other:?}"), } h.step(); @@ -2141,3 +2141,52 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } + +/// New Album's "Include selected photos" (as in Lightroom): on by default; unticked, the new album +/// starts empty; with nothing selected the option isn't shown, and a folder never has it. +#[test] +fn new_album_can_leave_the_selected_photos_out() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let photo = lightcraft_catalog::PhotoId(h.app.session.visible_cloned()[1].0); + h.app.session.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let create = |h: &mut Headless, command: &str, name: &str| { + h.app.run(command, json!({"name": name})).unwrap(); + h.step(); + h.step(); + }; + let confirm = |h: &mut Headless| { + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + }; + let photos = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).expect("the album was created").photos.clone(); + + // ticked by default: the selection goes in, as before + create(&mut h, "dialog.newAlbum", "With"); + assert!(has(&h, "check:albumIncludeSelected"), "the option is offered with a selection"); + confirm(&mut h); + assert_eq!(photos(&h, "With"), vec![photo]); + + // unticked: an empty album + create(&mut h, "dialog.newAlbum", "Without"); + let r = h.request("ui.clickWidget", json!({"id": "check:albumIncludeSelected"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + confirm(&mut h); + assert!(photos(&h, "Without").is_empty(), "nothing was added"); + + // a folder never offers it + create(&mut h, "dialog.newFolder", "Folder"); + assert!(!has(&h, "check:albumIncludeSelected"), "folders hold no photos"); + confirm(&mut h); + + // nothing selected: no option, an empty album + h.app.session.selection = lightcraft_engine::Selection { ids: vec![], active: None }; + create(&mut h, "dialog.newAlbum", "Nothing"); + assert!(!has(&h, "check:albumIncludeSelected"), "no selection, no option"); + confirm(&mut h); + assert!(photos(&h, "Nothing").is_empty()); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..23e31d0d9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -94,7 +94,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | From fb725ce33fa92764861d93c42937479339a9c90c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:23:51 +1100 Subject: [PATCH 082/142] Scroll the filmstrip when the same photo is shown from another source. --- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/panels/grid.rs | 32 ++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..53f8df736 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2145,7 +2145,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) child.style_mut().always_scroll_the_only_direction = true; // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` - let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active); + let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source); egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); app.film_scroll = Some(vp.left()); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..2d6207258 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -229,7 +229,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let active = app.session.selection.active; // bring the active photo into view when it changes (keyboard, click, command) or the grid // comes back on screen; otherwise the scroll position is the user's - let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active) { + let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active, app.session.source) { active.and_then(|a| ids.iter().position(|x| *x == a)).and_then(|i| lay.cells.get(i).copied()) } else { None @@ -295,16 +295,29 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// the strip was not drawn on the previous pass (view switch, panel shown again). Otherwise the /// scroll position is the user's: time passing, finished renders, edits to the photo or a released /// scroll bar never move it (issue #11). -pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option) -> bool { +pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option, source: lightcraft_engine::LibrarySource) -> bool { let pass = ctx.cumulative_pass_nr(); - let last: Option<(Option, u64)> = ctx.data(|d| d.get_temp(key)); - ctx.data_mut(|d| d.insert_temp(key, (active, pass))); + let last: Option<(Option, lightcraft_engine::LibrarySource, u64)> = ctx.data(|d| d.get_temp(key)); + ctx.data_mut(|d| d.insert_temp(key, (active, source, pass))); match last { - Some((was, drawn)) => was != active || drawn + 1 < pass, + Some((was, where_, drawn)) => follow_changed(was, where_, drawn, active, source, pass), None => true, } } +/// Scroll when the active photo changes, the strip was not drawn last pass, or the same photo +/// sits in a different library source (another album) (#1142). +pub(crate) fn follow_changed( + was: Option, + where_was: lightcraft_engine::LibrarySource, + drawn: u64, + active: Option, + source: lightcraft_engine::LibrarySource, + pass: u64, +) -> bool { + was != active || where_was != source || drawn + 1 < pass +} + /// Height of a date header row (points). pub const HEADER_H: f32 = 40.0; @@ -1146,6 +1159,15 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { mod tests { use super::*; + #[test] + fn the_same_photo_in_another_source_scrolls_into_view() { + let id = PhotoId(3); + let all = lightcraft_engine::LibrarySource::All; + let album = lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(9)); + assert!(!follow_changed(Some(id), all, 4, Some(id), all, 5)); + assert!(follow_changed(Some(id), all, 4, Some(id), album, 5)); + } + #[test] fn ungrouped_justified_rows_fill_the_width() { let aspects = [1.5f32, 1.5, 0.66, 1.0, 1.5, 1.5, 1.5]; From 994c820becbc2006b89aa52c86310449e352bcee Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:23:57 +1100 Subject: [PATCH 083/142] Include HEIC, HEIF, and NRW when a folder is imported. --- crates/mcp/src/headless.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 8678b1f7e..10a512faa 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -13,7 +13,7 @@ use crate::backend::{Backend, ProgressHook}; /// File extensions recognised as photos when expanding folders. pub const PHOTO_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl", - "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "gif", "bmp", "avif", "heic", "heif", "nrw", // avif is preview-only; heic/heif/nrw match the engine list (#1080) "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", ]; @@ -193,6 +193,29 @@ pub fn expand_paths(paths: &[String]) -> Vec { out } +#[cfg(test)] +mod expand_tests { + use super::expand_paths; + + #[test] + fn a_folder_import_includes_heic_heif_and_nrw() { + let dir = std::env::temp_dir().join(format!("lightcraft-ext-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for name in ["a.heic", "b.HEIF", "c.nrw", "d.png", "note.txt"] { + std::fs::write(dir.join(name), b"x").unwrap(); + } + let got = expand_paths(&[dir.to_string_lossy().to_string()]); + let names: Vec = got.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_lowercase()).collect(); + assert!(names.contains(&"a.heic".into()), "{names:?}"); + assert!(names.contains(&"b.heif".into()), "{names:?}"); + assert!(names.contains(&"c.nrw".into()), "{names:?}"); + assert!(names.contains(&"d.png".into()), "{names:?}"); + assert!(!names.iter().any(|n| n == "note.txt"), "{names:?}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + /// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless), /// `.avif`, `.jxl` — via the shared export encoder (embeds an sRGB profile). pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result, String> { From fbfa87d6141b9752f5f7c525a22721583bf46e8c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:24:02 +1100 Subject: [PATCH 084/142] Treat a numeric --connect value as a loopback port. --- apps/lightcraft-cli/src/main.rs | 55 +++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 63a4deb76..e9d8575f6 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -303,16 +303,16 @@ fn mcp(args: &[String]) -> Result<(), String> { while i < args.len() { match args[i].as_str() { "--connect" => { - // Optional address argument. - match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) { - Some(a) => { - connect = Some(a.clone()); - i += 1; - } - None => connect = Some(DEFAULT_ADDR.to_string()), + let (addr, took) = connect_value(args.get(i + 1).map(String::as_str)); + connect = Some(addr); + if took { + i += 1; } } - a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + a if a.starts_with("--connect=") => { + let (addr, _) = connect_value(Some(&a["--connect=".len()..])); + connect = Some(addr); + } "--headless" => connect = None, "--demo" => demo = true, "--compact" => compact = true, @@ -525,14 +525,17 @@ fn run(args: &[String]) -> Result<(), String> { i += 1; break; } - "--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) { - Some(a) => { - connect = Some(a.clone()); + "--connect" => { + let (addr, took) = connect_value(args.get(i + 1).map(String::as_str)); + connect = Some(addr); + if took { i += 1; } - None => connect = Some(DEFAULT_ADDR.to_string()), }, - a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + a if a.starts_with("--connect=") => { + let (addr, _) = connect_value(Some(&a["--connect=".len()..])); + connect = Some(addr); + } "--demo" => demo = true, "--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()), "--import" => imports.push(take_value(args, &mut i, "--import")?.to_string()), @@ -627,6 +630,18 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s /// 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. +/// `--connect` takes an address, or a bare port (`7980` → `127.0.0.1:7980`). A following +/// command id is left alone (#1178). +fn connect_value(next: Option<&str>) -> (String, bool) { + match next { + Some(a) if !a.starts_with('-') && a.contains(':') && !a.contains('=') => (a.to_string(), true), + Some(a) if !a.is_empty() && a.bytes().all(|c| c.is_ascii_digit()) && a.parse::().ok().is_some_and(|p| p > 0) => { + (format!("127.0.0.1:{a}"), true) + } + _ => (DEFAULT_ADDR.to_string(), false), + } +} + fn connect_port(addr: &str) -> &str { addr.rsplit_once(':').map_or(addr, |(_, port)| port) } @@ -965,6 +980,20 @@ fn denoise_serve(args: &[String]) -> Result<(), String> { serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}"))) } +#[cfg(test)] +mod connect_tests { + use super::connect_value; + + #[test] + fn a_bare_port_is_loopback_and_a_command_is_not_consumed() { + assert_eq!(connect_value(Some("7980")), ("127.0.0.1:7980".into(), true)); + assert_eq!(connect_value(Some("127.0.0.1:7980")), ("127.0.0.1:7980".into(), true)); + assert_eq!(connect_value(Some("photo.rate")), ("127.0.0.1:7980".into(), false)); + assert_eq!(connect_value(Some("0")), ("127.0.0.1:7980".into(), false)); + assert_eq!(connect_value(None), ("127.0.0.1:7980".into(), false)); + } +} + #[cfg(not(feature = "denoise"))] fn denoise_serve(_: &[String]) -> Result<(), String> { Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into()) From 7b6b25e6ab2b6739dcd4fc1eaa9cf69bd3e899bd Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 01:24:15 +1100 Subject: [PATCH 085/142] Composite straight alpha onto white before develop. --- crates/codecs/src/lib.rs | 56 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334a..1ef6f7908 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -245,11 +245,25 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R /// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space. pub fn to_working(d: &Decoded) -> Rgb32f { - if d.space.is_working() { - return d.image.clone(); + let mut image = if d.space.is_working() { + d.image.clone() + } else { + let m = d.space.to_working().to_f32(); + d.image.map(|p| mat_apply(&m, p)) + }; + // Straight alpha onto white. JPEG and contact sheets have no alpha, and a transparent + // pixel is usually stored as black (#1212). + if let Some(alpha) = &d.alpha { + let w = image.width; + lightcraft_raster::par_rows(&mut image.data, w, |y, row| { + let a = alpha.row(y); + for (p, &alpha) in row.iter_mut().zip(a) { + let a = alpha.clamp(0.0, 1.0); + *p = p.map(|c| c * a + (1.0 - a)); + } + }); } - let m = d.space.to_working().to_f32(); - d.image.map(|p| mat_apply(&m, p)) + image } /// Where a thumbnail came from. @@ -323,3 +337,37 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Date: Tue, 13 Oct 2026 01:24:35 +1100 Subject: [PATCH 086/142] Let the deep-zoom preview margin cover wide local filters. --- crates/ui-egui/src/region.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..d8daaaf93 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -11,7 +11,9 @@ use lightcraft_engine::pipeline::PixelWindow; /// Windows start and end on multiples of this many pixels of the zoomed frame. pub const SNAP: usize = 256; /// The most context rendered beyond the visible pixels (a multiple of [`SNAP`]). -pub const MAX_MARGIN: usize = 768; +/// 4.5 % of an 11:1 view of a 24 MP frame, snapped to [`SNAP`]. The old 768 px cap left Dehaze +/// tens of codes off the full-frame render from 4:1 up (#1146). +pub const MAX_MARGIN: usize = 3072; /// The most a window may span on either axis: a bound on its texture whatever the window size. pub const MAX_SPAN: usize = if cfg!(target_arch = "wasm32") { 3072 } else { 6144 }; @@ -484,7 +486,8 @@ mod tests { fn the_margin_grows_with_the_zoom_up_to_a_cap() { assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); assert!(margin_for(8000) > margin_for(3000)); - assert_eq!(margin_for(48_000), MAX_MARGIN); + assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); + assert_eq!(margin_for(80_000), MAX_MARGIN); assert_eq!(margin_for(0), SNAP); assert!(margin_for(usize::MAX) <= MAX_MARGIN); for l in [100, 3000, 8000, 48_000, 400_000] { From 031537ec0291721403b47fd4b3321ef297f2f1bc Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 16:29:35 +0200 Subject: [PATCH 087/142] contributors: add @switch87's names, as in the sibling craft apps --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 79f20c726..e153870f4 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -27,3 +27,8 @@ sync_github_name = true [people.sanyamsingh001] display_name = "sanyamsingh001" + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false From 03a3bbd561bd1b41e4b7f4f3eb17485589b020a5 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 01:39:01 +1100 Subject: [PATCH 088/142] Up / Down move through the grid's rows (#1171) library.up / library.down (Up / Down) go to the cell above / below in the grids (nearest by position in justified rows; the top / bottom row stays put), and step one photo in the filmstrip. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/library.rs | 24 +++++++++++++++++ crates/engine/src/tests_spots.rs | 17 ++++++++++++ crates/ui-egui/src/headless.rs | 45 +++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/grid.rs | 30 +++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 13 +++++++++ docs/parity-checklist.md | 2 +- 6 files changed, 130 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 2d06d73f6..3a5575ea8 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -219,6 +219,12 @@ fn step(s: &mut Session, d: isize) -> Result { Ok(json!({"active": vis[next].0})) } +/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input +/// can't overflow). +fn row_step(p: &Value) -> isize { + p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize +} + fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { let targets = s.targets(p); let mut ops = Vec::new(); @@ -641,6 +647,24 @@ pub fn specs() -> Vec { }), cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)), cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)), + cmd!( + "library.down", + "Photo Below", + [], + Some("Down"), + "{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}", + always, + |s, p| { step(s, row_step(p)) } + ), + cmd!( + "library.up", + "Photo Above", + [], + Some("Up"), + "{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}", + always, + |s, p| { step(s, -row_step(p)) } + ), // ---- rating / flags / labels cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| { let r = super::f64_req(p, "rating", "photo.rate")? as i64; diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs index 25fd482b7..988e7aac1 100644 --- a/crates/engine/src/tests_spots.rs +++ b/crates/engine/src/tests_spots.rs @@ -4,6 +4,23 @@ use serde_json::json; use crate::Session; +/// `library.up` / `library.down` (#1171): move by the given number of photos, clamped at the ends. +#[test] +fn up_and_down_move_by_rows_and_stop_at_the_ends() { + let mut s = Session::with_demo(); + let vis = s.visible_cloned(); + assert!(vis.len() >= 6); + s.selection = crate::Selection::single(vis[0]); + s.execute("library.down", &json!({"rows": 4})).unwrap(); + assert_eq!(s.selection.active, Some(vis[4])); + s.execute("library.up", &json!({})).unwrap(); + assert_eq!(s.selection.active, Some(vis[3]), "default is one photo (the filmstrip)"); + s.execute("library.up", &json!({"rows": 99})).unwrap(); + assert_eq!(s.selection.active, Some(vis[0]), "clamped at the first photo"); + s.execute("library.down", &json!({"rows": u64::MAX})).unwrap(); + assert_eq!(s.selection.active, vis.last().copied(), "hostile counts clamp, never overflow"); +} + fn spots(s: &Session) -> Vec { s.develop_of(s.active().unwrap()).unwrap().spots.clone() } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..45414f29c 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -750,6 +750,51 @@ mod tests { assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); } + /// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo. + #[test] + fn up_and_down_move_through_the_grid_rows() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "grid"}), t); + h.settle(SETTLE); + let first = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first.0]}}), t); + h.settle(SETTLE); + let rect = |h: &Headless| { + let a = h.app.session.selection.active.unwrap(); + h.app.widgets.iter().find(|(w, _)| *w == format!("thumb:{}", a.0)).map(|(_, r)| *r).expect("grid cell") + }; + let top = rect(&h); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + let below = rect(&h); + let pos = { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + } + .unwrap(); + assert!(below.top() > top.bottom(), "the next row, at {pos}: {top:?} -> {below:?}"); + assert!(below.left() < top.right() && below.right() > top.left(), "overlapping horizontally"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "back up"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "the top row stays put"); + // the filmstrip (one row): Down is the next photo + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + assert_eq!( + { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + }, + Some(1) + ); + } + /// The search field is a shared text field: right-click ▸ Paste puts the clipboard's text in /// and filters by it. #[test] diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..1f61a51e1 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -319,6 +319,36 @@ pub struct GridLayout { pub height: f32, } +/// The cell straight above (`down` false) or below cell `i`: in the adjacent row, the one whose +/// centre is nearest horizontally (justified rows don't line up in columns). None in the first / +/// last row or for an index outside the layout. +pub fn vertical_neighbor(lay: &GridLayout, i: usize, down: bool) -> Option { + let row = lay.rows.iter().position(|r| (r.start..r.end).contains(&i))?; + let next = if down { row.checked_add(1)? } else { row.checked_sub(1)? }; + let target = lay.rows.get(next)?; + let x = lay.cells.get(i)?.center().x; + (target.start..target.end).filter_map(|k| Some((k, (lay.cells.get(k)?.center().x - x).abs()))).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(k, _)| k) +} + +/// Up / Down in the grids: how many photos `library.up` / `library.down` move to reach the cell +/// above / below the active one in the grid as last laid out; None when the grid isn't laid out +/// for the current photos (the filmstrip then steps one photo). +pub fn row_distance(app: &mut LightcraftApp, down: bool) -> Option { + if !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + return None; + } + let active = app.session.selection.active?; + let (generation, ids) = app.session.visible_shared(); + let i = ids.iter().position(|x| *x == active)?; + let (key, lay) = app.caches.grid.layout.as_ref()?; + if key.0.0 != generation { + return None; + } + // at the top / bottom row there's no cell to go to: stay put rather than step sideways + let j = vertical_neighbor(lay, i, down).unwrap_or(i); + Some(j.abs_diff(i) as u64) +} + /// One row of cells: `cells[start..end]`, between `top` and `bottom`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct GridRow { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9e..3c7cea58c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -532,6 +532,19 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { continue; } } + // Up / Down in the grids go to the cell above / below (the filmstrip steps one photo) + if matches!(f.as_str(), "library.up" | "library.down") { + match crate::panels::grid::row_distance(app, f == "library.down") { + Some(0) => {} + Some(rows) => { + let _ = app.run(&f, json!({"rows": rows})); + } + None => { + let _ = app.run(&f, json!({})); + } + } + continue; + } // an export that can't start (e.g. no folder) says why instead of doing nothing if let Err(e) = app.run(&f, json!({})) && matches!(f.as_str(), "app.export" | "app.exportPrevious") diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..0134017b2 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -651,7 +651,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-ERASE | Erase while held — ⌥ | P0 | ✅ | `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/panels/masking.rs` | ⌥ while painting flips Add ↔ Erase for that stroke; Add/Erase buttons too | | KEY-RATEADVANCE | Rate and advance — ⇧0…5 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` (`advance`) | | | KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | ✅ | `cmd:photo.flag` (`advance`), `crates/ui-egui/src/shortcuts.rs` | Shift+Z pick, Shift+X reject, Shift+U clear; Shift+P also picks and advances in the library grids; exactly one step with Auto Advance enabled | -| KEY-NEXTPREV | Next / previous — → / ← | P0 | ✅ | `cmd:library.next`, `cmd:library.previous` | | +| KEY-NEXTPREV | Next / previous — → / ←; above / below — ↑ / ↓ | P0 | ✅ | `cmd:library.next`, `cmd:library.previous`, `cmd:library.up`, `cmd:library.down` | ↑ / ↓ go to the cell above / below in the grids (nearest by position in justified rows; the top / bottom row stays put), one photo in the filmstrip (issue #1171); no vim keys by default (H, J, K, L are taken), but they can be assigned in the keyboard shortcuts editor | | KEY-BA-CYCLE | Before/after — Y | P0 | ✅ | `cmd:view.beforeAfter` | toggles side by side (no cycling) | | KEY-BA-TOPBOTTOM | Before/after top/bottom — ⌥Y | P1 | ✅ | `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | ⌥Y, ⇧⌥Y | | KEY-BA-SPLIT | Split before/after — ⇧Y | P0 | ✅ | `cmd:view.beforeAfterSplit` | | From edb3cbfd8a7907044b63504e1ad692c5010535e6 Mon Sep 17 00:00:00 2001 From: Markus Friedel <20045158+MarkusFriedel@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:45:14 +0200 Subject: [PATCH 089/142] =?UTF-8?q?MENU-CTX-ALBUM:=20Import=20=E2=96=B8=20?= =?UTF-8?q?submenu=20on=20an=20album=20opens=20the=20review=20with=20that?= =?UTF-8?q?=20album=20chosen=20(#1267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An album's context menu gets Import ▸ with File ▸ Import's ways in (Import Photos…, Import from Folder…, Import from Device). file.addPhotos, file.addFolder and file.addFromDevice take {album}; the scan task carries it and the review chooses it when the scan is done, if it still offers that album (not deleted, not a folder or smart album). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/import.rs | 8 +++- crates/ui-egui/src/menus.rs | 22 +++++++-- crates/ui-egui/src/panels/left.rs | 18 ++++++++ crates/ui-egui/src/tests_panels.rs | 72 ++++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..6fa311e52 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -410,6 +410,8 @@ pub struct ScanTask { rx: std::sync::mpsc::Receiver, /// Open the review with "copy into the library" checked (a camera / card). pub copy: bool, + /// Open the review with this album chosen (an album's context menu ▸ Import…). + pub album: Option, /// Browsing a folder (Local): the photos are read in place instead of opening the review. browse: bool, /// What is being scanned (the review's source). @@ -448,7 +450,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> #[cfg(target_arch = "wasm32")] job(); let guard = scan_guard(app, &progress, true); - app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new(), guard }); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); app.renderer.forget_imports(); Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) } @@ -572,6 +574,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { .collect(); d.set_trashed(trashed); d.copy = task.copy; + // only an album the review offers (it may have been deleted while the scan ran) + d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); d.sources = task.sources; app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..07545fe02 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -265,6 +265,18 @@ fn parent_param(p: &Value) -> Option { p.get("parent").and_then(Value::as_u64) } +/// `file.addPhotos` / `file.addFolder` / `file.addFromDevice` `{album?}`: the review of the scan +/// just started opens with that album chosen (an album's context menu ▸ Import ▸ …). An error +/// means another scan is running. +fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result) -> Result { + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.album = p.get("album").and_then(Value::as_u64); + } + r +} + /// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { if let Some(s) = v.as_str() { @@ -1307,7 +1319,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { @@ -1476,7 +1489,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { // a folder (searched recursively) into the import review @@ -1490,7 +1503,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::import::open(app, vec![path]), + Some(path) => { + let r = crate::import::open(app, vec![path]); + import_into_album(app, p, r) + } None => Ok(Value::Null), } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..144fcc1cd 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1527,6 +1527,24 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { Err(e) => app.toast(ui.ctx(), e), } } + // File ▸ Import's ways in; the import review opens with this album chosen + let menu = ui.menu_button(crate::i18n::tr("Import"), |ui| { + for (label, command, widget) in [ + ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), + ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), + ("Import from Device", "file.addFromDevice", "button:albumImportDevice"), + ] { + let r = ui.button(crate::i18n::tr(label)); + crate::widgets::register(ui.ctx(), widget, r.rect); + if r.clicked() { + if let Err(e) = app.run(command, json!({"album": a.id.0})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + } + }); + crate::widgets::register(ui.ctx(), "button:albumImport", menu.response.rect); } if !a.folder && !a.is_smart() { let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bfa..2fa9c10c4 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2141,3 +2141,75 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } + +/// An album's context menu ▸ Import ▸ Import from Folder… (and Import Photos…, Import from Device) +/// opens the import review with that album chosen; a folder row doesn't offer it, and an `album` +/// the review can't offer (a folder, or an album deleted while the scan ran) leaves its album empty. +#[test] +fn an_album_menu_imports_into_that_album() { + let dir = std::env::temp_dir().join(format!("lc-ui-album-import-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let (w, h) = (40usize, 30usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 90, (k / w * 8) as u8, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("card{i}.png")), png).unwrap(); + } + let picked = dir.to_string_lossy().to_string(); + let services = Services { png: None, pick_folder: Some(Box::new(move || Some(picked.clone()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + // the demo's top-level album and album folder, both shown in the sidebar + let id_of = |name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0).expect(name); + let (album, folder) = (id_of("Garden"), id_of("Travel 2026")); + let right_click = |h: &mut Headless, id: &str| { + let row = widget(h, id); + let r = h.request("ui.click", json!({"x": row.center().x, "y": row.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + let review_album = |h: &mut Headless| { + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; + let chosen = opts.album; + h.app.ui.dialog = None; + chosen + }; + + // a folder row has no Import + right_click(&mut h, &format!("source:folder:{folder}")); + assert!(!has(&h, "button:albumImport"), "folders hold no photos"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + + // an album's menu: Import ▸ Import from Folder… → the (stubbed) folder dialog → the review, album chosen + right_click(&mut h, &format!("source:album:{album}")); + let r = h.request("ui.hoverWidget", json!({"id": "button:albumImport"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + for way in ["button:albumImportPhotos", "button:albumImportFolder", "button:albumImportDevice"] { + assert!(has(&h, way), "{way} is offered"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:albumImportFolder"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(review_album(&mut h), Some(album)); + + // Import Photos… passes the album the same way + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()], "album": album}}), T); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(review_album(&mut h), Some(album)); + + // an album the review can't offer (here a folder) is not chosen + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()], "album": folder}}), T); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(review_album(&mut h), None); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..838431bf4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -577,7 +577,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | -| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export`, `cmd:file.addPhotos`, `cmd:file.addFolder`, `cmd:file.addFromDevice` | add selected, Import ▸ Import Photos… / Import from Folder… / Import from Device (the import review opens with that album chosen: `{album}` on those commands; `button:albumImport`), export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | | 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) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first | From a234a2087652420f6bd7935197c318b820cd6925 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 01:50:53 +1100 Subject: [PATCH 090/142] fix(i18n): translate Photo Merge projection choices, merge toasts and panorama summary The projection radio labels, the merge kind inserted into the merge toasts and the panorama preview summary were shown without tr(); the catalogs already had them. Refs #911 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/merge.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index b1d066a7b..a603692fa 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -260,13 +260,13 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { match t.rx.try_recv() { Ok(r) => { let Some(t) = app.merge.final_task.take() else { return }; - let what = if t.options.command == "merge.panorama" { + let what = crate::i18n::tr(if t.options.command == "merge.panorama" { "Panorama" } else if t.options.command == "merge.hdrPanorama" { "HDR panorama" } else { "HDR" - }; + }); match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) { Ok(v) => { app.merge.last_result = Some(v); @@ -327,7 +327,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) let info = app.merge.preview.as_ref().map(|p| p.3.clone()); if let Some(info) = info { let txt = if opts.is_pano() { - format!( + crate::i18n::tr_format!( "{} photos · {} · {:.0}° × {:.0}°", info["used"].as_array().map_or(0, Vec::len), info["projection"].as_str().unwrap_or(""), @@ -349,7 +349,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) if opts.is_pano() { ui.label(egui::RichText::new(crate::i18n::tr("Projection")).color(t.text_label)); for (v, l) in [("auto", "Auto Select"), ("spherical", "Spherical"), ("cylindrical", "Cylindrical"), ("perspective", "Perspective")] { - if ui.radio(opts.projection == v, l).clicked() { + if ui.radio(opts.projection == v, crate::i18n::tr(l)).clicked() { opts.projection = v.to_string(); } } From a940fae1931671cde5ae405662883577900492ec Mon Sep 17 00:00:00 2001 From: Markus Friedel <20045158+MarkusFriedel@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:52:06 +0200 Subject: [PATCH 091/142] =?UTF-8?q?MENU-CTX-ALBUM:=20an=20album's=20Import?= =?UTF-8?q?=20=E2=96=B8=20Import=20from=20Device=20lists=20the=20devices?= =?UTF-8?q?=20found=20(#1267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a path, file.addFromDevice took the first device found, so with two cards connected the album menu imported from whichever came first. Like File ▸ Import from Device, the album's Import from Device is now a submenu with one entry per camera or card (or "No Camera or Card Found"), each passing its path along with the album. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/left.rs | 28 +++++++++++++++++++++------- docs/parity-checklist.md | 2 +- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 144fcc1cd..5cbee69d5 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1529,20 +1529,34 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } // File ▸ Import's ways in; the import review opens with this album chosen let menu = ui.menu_button(crate::i18n::tr("Import"), |ui| { - for (label, command, widget) in [ - ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), - ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), - ("Import from Device", "file.addFromDevice", "button:albumImportDevice"), - ] { - let r = ui.button(crate::i18n::tr(label)); + let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { + let r = ui.button(label); crate::widgets::register(ui.ctx(), widget, r.rect); if r.clicked() { - if let Err(e) = app.run(command, json!({"album": a.id.0})) { + if let Err(e) = app.run(command, params) { app.toast(ui.ctx(), e); } ui.close(); } + }; + for (label, command, widget) in [ + ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), + ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), + ] { + entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); } + // like File ▸ Import from Device: one entry per camera or card found + let device = ui.menu_button(crate::i18n::tr("Import from Device"), |ui| { + let devices = lightcraft_engine::devices::devices(); + if devices.is_empty() { + ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); + } + for (i, d) in devices.into_iter().enumerate() { + let params = json!({"path": d.path, "album": a.id.0}); + entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params); + } + }); + crate::widgets::register(ui.ctx(), "button:albumImportDevice", device.response.rect); }); crate::widgets::register(ui.ctx(), "button:albumImport", menu.response.rect); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 838431bf4..bd79ca98d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -577,7 +577,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | -| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export`, `cmd:file.addPhotos`, `cmd:file.addFolder`, `cmd:file.addFromDevice` | add selected, Import ▸ Import Photos… / Import from Folder… / Import from Device (the import review opens with that album chosen: `{album}` on those commands; `button:albumImport`), export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export`, `cmd:file.addPhotos`, `cmd:file.addFolder`, `cmd:file.addFromDevice` | add selected, Import ▸ Import Photos… / Import from Folder… / Import from Device ▸ (one entry per camera or card, as in File ▸ Import from Device; the import review opens with that album chosen: `{album}` on those commands; `button:albumImport`), export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | | 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) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first | From 8a4fec438ab3f0dd7803df5c6a98098ca8ab4e88 Mon Sep 17 00:00:00 2001 From: Christos Sidiropoulos Date: Mon, 12 Oct 2026 17:15:54 +0200 Subject: [PATCH 092/142] Fix #1194: locate missing files by folder tree Co-Authored-By: Codex GPT-5.6 Sol Signed-off-by: Christos Sidiropoulos --- crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/menus.rs | 14 ++++++- crates/ui-egui/src/panels/left.rs | 15 ++++++++ crates/ui-egui/src/tests_panels.rs | 59 ++++++++++++++++++++++++++++- docs/parity-checklist.md | 5 ++- 13 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..c4236e17e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -803,6 +803,7 @@ "Local folders": "Lokale Ordner", "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", "Locate Missing File…": "Fehlende Datei suchen…", + "Locate Missing Files…": "Fehlende Dateien suchen…", "Locate Photo": "Foto suchen", "Location": "Ort", "Lock": "Sperren", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..4dab1be22 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -764,6 +764,7 @@ "Local folders": "Carpetas locales", "Local library — no cloud account needed": "Biblioteca local: no requiere cuenta en la nube", "Locate Missing File…": "Localizar archivo que falta…", + "Locate Missing Files…": "Localizar archivos que faltan…", "Locate Photo": "Localizar foto", "Location": "Ubicación", "Lock": "Bloquear", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..23ed61194 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -761,6 +761,7 @@ "Local folders": "Dossiers locaux", "Local library — no cloud account needed": "Bibliothèque locale : aucun compte cloud nécessaire", "Locate Missing File…": "Localiser le fichier manquant…", + "Locate Missing Files…": "Localiser les fichiers manquants…", "Locate Photo": "Localiser la photo", "Location": "Lieu", "Lock": "Verrouiller", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..a2e5993b5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -866,6 +866,7 @@ "Local folders": "ローカルフォルダ", "Local library — no cloud account needed": "ローカルのライブラリ(クラウドアカウント不要)", "Locate Missing File…": "見つからないファイルを指定…", + "Locate Missing Files…": "見つからないファイルを指定…", "Location": "保存場所", "Long Edge": "長辺", "Looking for photos…": "写真を検索中…", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..57947eed4 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -772,6 +772,7 @@ "Local folders": "Pastas locais", "Local library — no cloud account needed": "Biblioteca local — não precisa de conta na nuvem", "Locate Missing File…": "Localizar arquivo ausente…", + "Locate Missing Files…": "Localizar arquivos ausentes…", "Locate Photo": "Localizar foto", "Location": "Local", "Lock": "Travar", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..91db07707 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -637,6 +637,7 @@ "Local folders": "Локальные папки", "Local library — no cloud account needed": "Локальная библиотека — учётная запись в облаке не нужна", "Locate Missing File…": "Указать отсутствующий файл…", + "Locate Missing Files…": "Указать отсутствующие файлы…", "Locate Photo": "Найти фото", "Location": "Местоположение", "Long Edge": "Длинная сторона", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..c0d174c0e 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1024,6 +1024,7 @@ "Local folders": "Локальні теки", "Local library — no cloud account needed": "Локальна бібліотека — хмарний обліковий запис не потрібен", "Locate Missing File…": "Знайти відсутній файл…", + "Locate Missing Files…": "Знайти відсутні файли…", "Long Edge": "Довгий край", "Looking for photos…": "Пошук фото…", "Luminance Range": "Діапазон яскравості", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..55ea8a0b9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -858,6 +858,7 @@ "Local folders": "本地文件夹", "Local library — no cloud account needed": "本地图库 — 无需云账户", "Locate Missing File…": "定位缺失的文件…", + "Locate Missing Files…": "定位缺失的文件…", "Locate Photo": "定位照片", "Location": "位置", "Long Edge": "长边", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..5c6eb6816 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -858,6 +858,7 @@ "Local folders": "本機資料夾", "Local library — no cloud account needed": "本機照片庫,無需雲端帳號", "Locate Missing File…": "尋找遺失的檔案…", + "Locate Missing Files…": "尋找遺失的檔案…", "Locate Photo": "尋找照片", "Location": "位置", "Long Edge": "長邊", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..86dad8067 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1336,9 +1336,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let within = match p.get("within") { + None | Some(Value::Null) => None, + Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), + Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), + Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), + }; + let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; let folder = match p.get("folder").and_then(Value::as_str) { Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| { + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) }) { Picked::Now(v) => v.into_iter().next(), @@ -1352,7 +1359,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option| { // relinked under the session as it is now: photos relinked meanwhile and files diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..fda17cbcf 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1707,6 +1707,7 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } else if n.path != "/" { resp.context_menu(|ui| { folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); synchronize_item(app, ui, n, &n.name); ui.separator(); if ui @@ -1721,6 +1722,19 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } } +/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing +/// background finder relink only this folder's photos (including the folders inside it). +fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let r = ui.button(crate::i18n::tr("Locate Missing Files…")); + register(ui.ctx(), "folderLocateMissing", r.rect); + if r.clicked() { + if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + /// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which /// scans the folder in the background. fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { @@ -1812,6 +1826,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F ui.close(); } folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); synchronize_item(app, ui, n, &label); ui.separator(); if ui diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bfa..d99716adf 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1487,6 +1487,10 @@ fn folders_app(paths: &[&str]) -> Headless { } fn folders_app_sized(paths: &[&str], size: [f32; 2]) -> Headless { + folders_app_with_services(paths, size, Services { png: None, ..Default::default() }) +} + +fn folders_app_with_services(paths: &[&str], size: [f32; 2], services: Services) -> Headless { use lightcraft_catalog::{Op, Photo, Source}; let mut session = lightcraft_engine::Session::new(); for path in paths { @@ -1494,7 +1498,7 @@ fn folders_app_sized(paths: &[&str], size: [f32; 2]) -> Headless { let p = Photo::new(id, Source::File { path: (*path).into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); } - let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let app = LightcraftApp::new(session, services); let mut h = Headless::new(app, size, 1.0); let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); assert_eq!(r["ok"], true, "{r}"); @@ -1699,6 +1703,59 @@ fn right_click_opens_a_folders_menu_from_the_triangle_too() { assert!(!popup_open(&h), "the startup disk offers nothing"); } +/// Issue #1194: a folder's menu chooses the folder's new location, then the bulk finder relinks +/// only the missing photos from that folder and the folders inside it. +#[test] +fn a_folder_menu_locates_its_missing_tree_only() { + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let base = std::env::temp_dir().join(format!("lc-ui-locate-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let _cleanup = Cleanup(base.clone()); + let new_trip = base.join("new/trip"); + std::fs::create_dir_all(new_trip.join("day2")).unwrap(); + for path in [new_trip.join("a.jpg"), new_trip.join("day2/b.jpg"), new_trip.join("c.jpg")] { + std::fs::write(path, b"photo").unwrap(); + } + + let root = base.to_string_lossy().replace('\\', "/"); + let old = format!("{root}/old"); + let old_trip = format!("{old}/trip"); + let paths = [format!("{old_trip}/a.jpg"), format!("{old_trip}/day2/b.jpg"), format!("{old}/other/c.jpg")]; + let picked = new_trip.to_string_lossy().to_string(); + let services = Services { pick_folder: Some(Box::new(move || Some(picked.clone()))), png: None, ..Default::default() }; + let refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut h = folders_app_with_services(&refs, [1400.0, 900.0], services); + + click(&mut h, &format!("libraryFolderToggle:{old}")); + right_click(&mut h, &format!("source:libfolder:{old_trip}")); + assert!(has(&h, "folderLocateMissing"), "the folder menu offers the action"); + click(&mut h, "folderLocateMissing"); + assert!(h.step_until(T, |h| h.app.ui.last_find_missing.is_some()), "the background search finished"); + let result = h.app.ui.last_find_missing.as_ref().unwrap(); + assert_eq!(result["found"].as_array().map(Vec::len), Some(2), "{result}"); + assert_eq!(result["missing"], 0, "{result}"); + + let sources: Vec = h + .app + .session + .catalog + .photos() + .filter_map(|photo| match &photo.source { + lightcraft_catalog::Source::File { path } => Some(path.clone()), + _ => None, + }) + .collect(); + assert!(sources.contains(&new_trip.join("a.jpg").to_string_lossy().to_string()), "{sources:?}"); + assert!(sources.contains(&new_trip.join("day2/b.jpg").to_string_lossy().to_string()), "{sources:?}"); + assert!(sources.contains(&format!("{old}/other/c.jpg")), "another missing folder stays untouched: {sources:?}"); +} + /// Right-click a folder ▸ Set Color Label ▸ a colour: the folder's row shows the label's dot (a /// row without a label shows none), and None takes it off again. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..5214f9a48 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (folder-tree Locate Missing Files for issue #1194) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -707,7 +707,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | -| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | +| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-LIB-MISSING: folder and disk context menus locate a moved tree recursively without relinking missing photos outside it (issue #1194) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | From af269b1104c4d7c31b34c7731e9f535507eaf76c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 17:15:41 +0200 Subject: [PATCH 093/142] LR-IMP-FORMATS: CR2 white level from the active area, not the saturated masked rows above it (fixes #1272) --- crates/raw/src/vendor/cr2.rs | 54 ++++++++++++++++++++++++++++-------- crates/raw/src/vendor/mod.rs | 7 +++++ crates/raw/src/vendor/rw2.rs | 8 +----- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 7f52888f4..05b628889 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -23,7 +23,7 @@ mod sraw; -use super::{black_from_columns, white_from_data}; +use super::{active_rows, black_from_columns, white_from_data}; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; @@ -189,7 +189,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); - let white = white_from_data(&data, prec as u32); + let white = white_from_data(&active_rows(&data, width, active), prec as u32); let black = match masked_columns(&data, width, active, white) { cols if cols.len() >= 2 => black_from_columns(&data, width, cols, active.y..active.y + active.height, active), _ => BlackLevel::uniform(0.0), @@ -233,6 +233,11 @@ pub(crate) mod tests { /// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored). fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> (Vec, Vec) { let img: Vec = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect(); + (synthetic_cr2_of(&img, w, h, slices, note), img) + } + + /// A synthetic CR2 whose sensor samples are `img` (`w`×`h`, 14-bit), with `note` as the Exif maker note. + fn synthetic_cr2_of(img: &[u16], w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> Vec { let widths: Vec = match slices { Some([n, sw, last]) => { let mut v = vec![sw as usize; n as usize]; @@ -269,10 +274,9 @@ pub(crate) mod tests { } ifd3.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![enc] }); // (real files also carry a "CR\x02\0" signature at offset 8; probe recognises the structure without it) - let bytes = TiffWriter::new(ByteOrder::Little, false) + TiffWriter::new(ByteOrder::Little, false) .write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), IfdBuilder::new().with(1, Value::Short(vec![0])), ifd3]) - .unwrap(); - (bytes, img) + .unwrap() } #[test] @@ -352,13 +356,13 @@ pub(crate) mod tests { assert!(masked_columns(&[], w, active, 15000.0).is_empty()); } - /// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain - /// IFD whose value offset is relative to the TIFF header (patched once the note's position is known). - fn note_with_color_data(ty: u16, data: &[u8]) -> Vec { + /// A maker note holding only `tag` as `ty` (3 = SHORT, 7 = UNDEFINED) with `data`, a plain IFD whose value + /// offset is relative to the TIFF header (patched by [`patch_note`] once the note's position is known). + fn note_with(tag: u16, ty: u16, data: &[u8]) -> Vec { let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32; let mut note = Vec::new(); note.extend_from_slice(&1u16.to_le_bytes()); - note.extend_from_slice(&COLOR_BALANCE.to_le_bytes()); + note.extend_from_slice(&tag.to_le_bytes()); note.extend_from_slice(&ty.to_le_bytes()); note.extend_from_slice(&count.to_le_bytes()); note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below @@ -367,12 +371,16 @@ pub(crate) mod tests { note } - fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage { - let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with_color_data(ty, data))); - let tiff = Tiff::parse(&bytes).unwrap(); + fn patch_note(bytes: &mut [u8]) { + let tiff = Tiff::parse(bytes).unwrap(); let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap(); let off = e.offset as usize; bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes()); + } + + fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage { + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with(COLOR_BALANCE, ty, data))); + patch_note(&mut bytes); crate::decode(&bytes).unwrap() } @@ -388,4 +396,26 @@ pub(crate) mod tests { assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}"); } } + + /// Masked rows above the image full of samples at 16383 (EOS 50D, issue #1272): the white level is the image's + /// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0. + #[test] + fn white_from_active_area_only() { + let (w, h, top, left) = (64, 40, 4, 8); + let img: Vec = (0..w * h) + .map(|i| match (i % w, i / w) { + (_, y) if y < top => 16383, + (x, _) if x < left => 1024, + _ if i % 7 == 0 => 13433, + _ => 1024 + ((i * 7919) % 11000) as u16, + }) + .collect(); + // SensorInfo: width, height, three unused words, then the inclusive left/top/right/bottom borders + let si: Vec = [w, h, 0, 0, 0, left, top, w - 1, h - 1].iter().flat_map(|&v| (v as u16).to_le_bytes()).collect(); + let mut bytes = synthetic_cr2_of(&img, w, h, None, Some(note_with(SENSOR_INFO, 3, &si))); + patch_note(&mut bytes); + let r = crate::decode(&bytes).unwrap(); + assert_eq!(r.active_area, Rect::new(left, top, w - left, h - top)); + assert!((13300.0..=13433.0).contains(&r.white[0]), "{:?}", r.white); + } } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index b7bef1471..46c401cd5 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -63,6 +63,13 @@ pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range, BlackLevel { repeat_rows: 2, repeat_cols: 2, values: (0..4).map(|i| (sum[i] / n[i] as f64) as f32).collect(), delta_h: vec![], delta_v: vec![] } } +/// Samples of up to 512 evenly spaced rows of the active area, for [`white_from_data`]: the borders can hold +/// their own saturated runs (0xffff junk on RW2, masked rows at 16383 on some Canon CR2s). +pub(crate) fn active_rows(data: &[u16], w: usize, a: Rect) -> Vec { + let step = (a.height / 512).max(1); + (a.y..a.y + a.height).step_by(step).filter_map(|y| data.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() +} + /// White level estimate: the saturation plateau if a noticeable number of samples sit at the maximum value, /// otherwise the full `bits` range. A few stray samples above the plateau (hot pixels, e.g. on a Canon EOS 70D /// CR2) don't hide it. diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 0a64c9e44..b856fbd52 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -99,7 +99,7 @@ //! 1 and 3, and bit depths other than the ones above (no samples of any of them). use super::pef::{Bits, Huffman, diff}; -use super::white_from_data; +use super::{active_rows, white_from_data}; use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, Opcode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, tags as t}; @@ -497,12 +497,6 @@ fn fill_defects(data: &mut [u16], w: usize, area: Rect) { } } -/// Samples of up to 512 evenly spaced rows of the active area (the borders hold junk such as 0xffff runs). -fn active_rows(data: &[u16], w: usize, a: Rect) -> Vec { - let step = (a.height / 512).max(1); - (a.y..a.y + a.height).step_by(step).filter_map(|y| data.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() -} - fn cfa(code: u16) -> Cfa { Cfa::bayer_static(match code { 2 => "GRBG", From 6c033ea5dcd5277037cce32fa590b21870d5c2be Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 17:21:13 +0200 Subject: [PATCH 094/142] PEF: masked rows and columns at the black level are trimmed without crop tags (fixes #1271) --- crates/raw/src/vendor/pef.rs | 95 +++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index d065fc4f5..868d99771 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -167,25 +167,32 @@ pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits Ok(out) } -/// The image area without all-dark leading/trailing columns (masked or empty), kept at even offsets. -fn dark_trimmed(d: &[u16], w: usize, h: usize) -> Rect { - if w < 256 || h == 0 { +/// The image area without all-dark leading/trailing columns and rows (masked or empty), kept at even offsets. A +/// line is dark when its mean is less than 5% of the interior's above `black`: masked lines sit at the black level. +fn dark_trimmed(d: &[u16], w: usize, h: usize, black: f64) -> Rect { + if w < 256 || h < 256 { return Rect::new(0, 0, w, h); } - let step = (h / 256).max(1); - let col_mean = |x: usize| (0..h).step_by(step).map(|y| d[y * w + x] as f64).sum::() / h.div_ceil(step) as f64; + let (xstep, ystep) = (w / 256, h / 256); + let col_mean = |x: usize| (0..h).step_by(ystep).map(|y| d[y * w + x] as f64).sum::() / h.div_ceil(ystep) as f64; + let row_mean = |y: usize| (0..w).step_by(xstep).map(|x| d[y * w + x] as f64).sum::() / w.div_ceil(xstep) as f64; let interior = (w / 4..w * 3 / 4).step_by(w / 64).map(col_mean).sum::() / 32.0; - let dark = interior * 0.05; - let mut left = 0; - while left < 64 && col_mean(left) < dark { - left += 1; - } - let mut right = w; - while right > w - 64 && col_mean(right - 1) < dark { - right -= 1; - } - let left = left.next_multiple_of(2); - Rect::new(left, 0, (right - left) & !1, h) + let dark = black + (interior - black) * 0.05; + let edges = |n: usize, mean: &dyn Fn(usize) -> f64| { + let mut lo = 0; + while lo < 64 && mean(lo) < dark { + lo += 1; + } + let mut hi = n; + while hi > n - 64 && mean(hi - 1) < dark { + hi -= 1; + } + let lo = lo.next_multiple_of(2); + (lo, (hi - lo) & !1) + }; + let (left, width) = edges(w, &col_mean); + let (top, height) = edges(h, &row_mean); + Rect::new(left, top, width, height) } /// Whether every strip or tile of a one-sample-per-pixel image holds exactly the bytes that its rows need when @@ -249,9 +256,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; + let black_point = mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).filter(|v| v.len() == 4); let active = match tagged { Some(r) => r, - None => dark_trimmed(samples, w, hgt), + None => dark_trimmed(samples, w, hgt, black_point.as_deref().map_or(0.0, |v| v.iter().sum::() / 4.0)), }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, @@ -261,7 +269,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // tag keep BGGR. _ => cfa_from_exif(&tiff).unwrap_or_else(|| Cfa::bayer_static("BGGR")), }; - let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).as_deref() { + let black = match black_point.as_deref() { Some(v @ [_, _, _, _]) => { // RGGB-ordered levels → per CFA position at the active area origin let a = cfa.shifted(active.x, active.y); @@ -488,4 +496,55 @@ mod tests { let img = decode(&tiny_pef(4, 2, 1, 16, &[0; 16]), Mode::Full).unwrap(); assert_eq!(img.cfa, Some(Cfa::bayer_static("BGGR"))); } + + /// A 512 x 384 big-endian PEF of plain 16-bit words without area tags, whose maker note has only a BlackPoint + /// of `black`, with `px(x, y)` as the samples. + fn pef_with_black_point(black: u16, px: impl Fn(usize, usize) -> u16) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let (w, h) = (512usize, 384usize); + let strip: Vec = (0..w * h).flat_map(|i| px(i % w, i / w).to_be_bytes()).collect(); + let build = |levels_at: u32| { + let mut ifd = IfdBuilder::new(); + ifd.set(0x0100, Value::Long(vec![w as u32])); + ifd.set(0x0101, Value::Long(vec![h as u32])); + ifd.set(0x0102, Value::Short(vec![14])); + ifd.set(0x0103, Value::Short(vec![1])); + ifd.set(0x0106, Value::Short(vec![32803])); + ifd.set(0x010f, Value::Ascii("PENTAX Corporation".into())); + ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![strip.clone()] }); + // AOC\0 + "MM", one entry (BlackPoint, four shorts stored after the IFD), next-IFD 0, the four levels + let mut note = b"AOC\0MM\0\x01".to_vec(); + note.extend_from_slice(&[0x02, 0x00, 0, 3, 0, 0, 0, 4]); + note.extend_from_slice(&levels_at.to_be_bytes()); + note.extend_from_slice(&0u32.to_be_bytes()); + (0..4).for_each(|_| note.extend_from_slice(&black.to_be_bytes())); + let mut exif = IfdBuilder::new(); + exif.set(0x927c, Value::Undefined(note)); + ifd.set_child(0x8769, exif); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap() + }; + let at = Tiff::parse(&build(0)).unwrap().exif().unwrap().get(0x927c).unwrap().offset; + build((at + 6 + 2 + 12 + 4) as u32) + } + + /// The K-3 Mark III / KP / KF layout (issue #1271): masked columns and rows at the black level, an empty band + /// at zero, around an interior less than 20 times as bright as the black level. + #[test] + fn masked_rows_and_columns_at_the_black_level_are_trimmed_without_crop_tags() { + let (w, h) = (512, 384); + let file = pef_with_black_point(256, |x, y| { + if x >= w - 8 { + 0 + } else if y >= h - 14 { + 15 + } else if x < 24 || x >= w - 16 || y < 28 { + 256 + } else { + 1256 + ((x * 7 + y * 13) % 64) as u16 + } + }); + let img = decode(&file, Mode::Full).unwrap(); + assert_eq!(img.black.values, vec![256.0; 4]); + assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); + } } From e79027f728fa0a59eaf4d6195c2b11ac78d4ca23 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 02:21:55 +1100 Subject: [PATCH 095/142] fix(i18n): translate Compare and Reference view captions Refs #911 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels/compare.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..61c89b0d3 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -265,7 +265,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let pt = ui.painter(); let mut x = cap.left() + 4.0; if !label.is_empty() { - let g = pt.layout_no_wrap(label.to_string(), t.semibold(12.0), if active { t.text } else { t.text_label }); + let g = pt.layout_no_wrap(crate::i18n::tr(label).to_string(), t.semibold(12.0), if active { t.text } else { t.text_label }); pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g.clone(), t.text); x += g.size().x + 10.0; } From fbfc1b02960865c1206050b3e4b4364c8b37f0dd Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 02:22:03 +1100 Subject: [PATCH 096/142] fix(i18n): translate the curve right-click menu Refs #911 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels/edit.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..48811cd4f 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1028,8 +1028,11 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) /// Right-click menu of the curve graph. fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { - let label = - if ch == "parametric" { "Reset Parametric Curve".to_string() } else { crate::i18n::tr_format!("Reset {} Channel", channel_label(ch)) }; + let label = if ch == "parametric" { + crate::i18n::tr("Reset Parametric Curve").to_string() + } else { + crate::i18n::tr_format!("Reset {} Channel", crate::i18n::tr(channel_label(ch))) + }; let r = ui.button(label); register(ui.ctx(), "curveMenu:resetChannel", r.rect); if r.clicked() { From c56fa85a982f930f2f8b541dde575f77c3540c78 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 02:22:07 +1100 Subject: [PATCH 097/142] fix(i18n): translate Keep in Local / Add to Local Refs #911 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels/left.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..1b6db24fb 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1026,7 +1026,7 @@ fn folder_tree( resp.context_menu(|ui| { if (transient || indent > 0.0) && ui - .button(if transient { "Keep in Local" } else { "Add to Local" }) + .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) .clicked() { From fa28792363165472cae0d62378d115041eca06ef Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 13 Oct 2026 02:22:07 +1100 Subject: [PATCH 098/142] fix(i18n): translate Remove panel spot mode and dust toast Refs #911 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels/right.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 501a78c5e..7a476d51f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -362,7 +362,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.toast( ui.ctx(), if n == 0 { - "No dust spots found".to_string() + crate::i18n::tr("No dust spots found").to_string() } else { crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n) }, @@ -385,7 +385,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { lightcraft_develop::SpotMode::Remove => "Remove", lightcraft_develop::SpotMode::Fill => "Content-Aware", }; - super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = mode)); + super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = crate::i18n::tr(mode))); } let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { id, From a9812d0cb41593ff4da22155a7ed1171dab7ca2d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 17:34:31 +0200 Subject: [PATCH 099/142] PEF: the dark-border tolerance is relative to the black level, not the interior, so dark real content stays (#1271) --- crates/raw/src/vendor/pef.rs | 39 ++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 868d99771..60b34d95b 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -167,9 +167,11 @@ pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits Ok(out) } -/// The image area without all-dark leading/trailing columns and rows (masked or empty), kept at even offsets. A -/// line is dark when its mean is less than 5% of the interior's above `black`: masked lines sit at the black level. -fn dark_trimmed(d: &[u16], w: usize, h: usize, black: f64) -> Rect { +/// The image area without all-dark leading/trailing columns and rows (masked or empty), kept at even offsets. With +/// the maker-note `black` level, a line is dark when its mean is at most 1/256 of the remaining range (at least 16) +/// above it: masked lines sit at the black level plus noise, while dark real content (a night sky) is well above. +/// The tolerance is the one `cr2::masked_columns` uses. Without it, below 5% of the interior's mean. +fn dark_trimmed(d: &[u16], w: usize, h: usize, black: Option, white: f32) -> Rect { if w < 256 || h < 256 { return Rect::new(0, 0, w, h); } @@ -177,7 +179,10 @@ fn dark_trimmed(d: &[u16], w: usize, h: usize, black: f64) -> Rect { let col_mean = |x: usize| (0..h).step_by(ystep).map(|y| d[y * w + x] as f64).sum::() / h.div_ceil(ystep) as f64; let row_mean = |y: usize| (0..w).step_by(xstep).map(|x| d[y * w + x] as f64).sum::() / w.div_ceil(xstep) as f64; let interior = (w / 4..w * 3 / 4).step_by(w / 64).map(col_mean).sum::() / 32.0; - let dark = black + (interior - black) * 0.05; + let dark = match black { + Some(b) => b + ((f64::from(white) - b) / 256.0).max(16.0), + None => interior * 0.05, + }; let edges = |n: usize, mean: &dyn Fn(usize) -> f64| { let mut lo = 0; while lo < 64 && mean(lo) < dark { @@ -256,10 +261,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; + let white = white_from_data(samples, bits); let black_point = mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).filter(|v| v.len() == 4); let active = match tagged { Some(r) => r, - None => dark_trimmed(samples, w, hgt, black_point.as_deref().map_or(0.0, |v| v.iter().sum::() / 4.0)), + None => dark_trimmed(samples, w, hgt, black_point.as_deref().map(|v| v.iter().sum::() / 4.0), white), }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, @@ -283,7 +289,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let g = (v[1] + v[2]) / 2.0; [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] }); - let white = white_from_data(samples, bits); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(active.width as u32); metadata.height = Some(active.height as u32); @@ -547,4 +552,26 @@ mod tests { assert_eq!(img.black.values, vec![256.0; 4]); assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); } + + /// A night frame (bright centre, dark sky): 20 lines of real content 100 DN above the black level inside the + /// masked border stay in the picture, whatever the interior's brightness. + #[test] + fn dark_real_content_next_to_the_masked_border_is_kept() { + let (w, h) = (512, 384); + let file = pef_with_black_point(256, |x, y| { + if x >= w - 8 { + 0 + } else if y >= h - 14 { + 15 + } else if x < 24 || x >= w - 16 || y < 28 { + 256 + ((x + y) % 3) as u16 + } else if x < 44 || x >= w - 36 || y < 48 || y >= h - 34 { + 356 + ((x * 7 + y * 13) % 16) as u16 + } else { + 3256 + ((x * 7 + y * 13) % 64) as u16 + } + }); + let img = decode(&file, Mode::Full).unwrap(); + assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); + } } From 978132fdcaf60e9f2ce2514d0aaf04e44d4b0925 Mon Sep 17 00:00:00 2001 From: Markus Friedel <20045158+MarkusFriedel@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:37:42 +0200 Subject: [PATCH 100/142] LR-EDIT-COLOR-TEMP: the Kelvin Temp slider has Lightroom's scale, 7000 K in the middle (#1295) Co-Authored-By: Claude Opus 5.5 --- crates/develop/src/controls.rs | 79 +++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_temp_slider.rs | 125 ++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 54 +++++++++- docs/parity-checklist.md | 2 +- 5 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/tests_temp_slider.rs diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index 83b26403d..715e8ac33 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -99,8 +99,45 @@ impl ControlSpec { let s = format!("{:.*}", self.decimals as usize, v); if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s } } + /// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin + /// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and + /// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the + /// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays + /// linear. + pub fn position(&self, v: f64) -> f64 { + let v = self.clamp(v); + let p = match self.kelvin_scale() { + Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right, + Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min), + None => (v - self.min) / (self.max - self.min).max(1e-9), + }; + if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 } + } + /// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps. + pub fn value_at(&self, p: f64) -> f64 { + let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }; + let v = match self.kelvin_scale() { + Some(_) if p >= 1.0 => self.max, + Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0), + Some((centre, _)) => self.min + 2.0 * p * (centre - self.min), + None => self.min + p * (self.max - self.min), + }; + self.clamp(v) + } + /// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]); + /// `None` for every other control, or a range the centre isn't inside. + fn kelvin_scale(&self) -> Option<(f64, f64)> { + let centre = KELVIN_CENTRE; + (self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln())) + } } +/// The Kelvin temperature at the middle of its track, as in Lightroom. +const KELVIN_CENTRE: f64 = 7000.0; +/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope +/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it. +const KELVIN_KNEE: f64 = 1000.0; + macro_rules! controls { ($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => { /// All numeric develop controls, in panel order. @@ -508,4 +545,46 @@ mod tests { assert_eq!(e.format(-1.0), "-1.00"); assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500"); } + + /// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against + /// the linear Tint slider; the model is within 2 % of them); every other control stays linear. + #[test] + fn the_kelvin_track_is_lightrooms_and_others_stay_linear() { + let temp = find("wb.temp").unwrap(); + for (k, lightroom, model) in [ + (2000.0, 0.0, 0.0), + (3650.0, 0.167, 0.165), + (5400.0, 0.333, 0.34), + (7000.0, 0.5, 0.5), + (15000.0, 0.79, 0.79), + (20000.0, 0.853, 0.849), + (30000.0, 0.90, 0.92), + (50000.0, 1.0, 1.0), + ] { + let p = temp.position(k); + assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}"); + assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}"); + } + // every 50 K step survives value → position → value, and the track never runs backwards + let mut last = -1.0; + for k in (2000..=50000).step_by(50).map(f64::from) { + let p = temp.position(k); + assert!(p > last, "{k} K"); + last = p; + let back = (temp.value_at(p) / temp.step).round() * temp.step; + assert_eq!(back, k, "{k} K → {p} → {back}"); + } + assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0)); + assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default))); + let exposure = find("light.exposure").unwrap(); + for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] { + assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}"); + } + // the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale + let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp }; + assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0)); + // a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero + let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp }; + assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0)); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..87572e1be 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -112,6 +112,8 @@ mod tests_switch_library; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_temp_slider; +#[cfg(test)] mod tests_titlebar; #[cfg(test)] mod tests_unsaved; diff --git a/crates/ui-egui/src/tests_temp_slider.rs b/crates/ui-egui/src/tests_temp_slider.rs new file mode 100644 index 000000000..c8e4e0d4c --- /dev/null +++ b/crates/ui-egui/src/tests_temp_slider.rs @@ -0,0 +1,125 @@ +//! Headless tests of the Temp slider's scale: the Kelvin temperature of a raw follows Lightroom's track +//! (2000–7000 K on the left half, logarithmic up to 50000 K on the right); the relative temperature of a +//! rendered photo and every other slider stay linear. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The demo library in Detail with the Edit panel's Light and Color sections open, on a raw photo (Kelvin +/// white balance) when `raw`, else on a demo JPEG (relative white balance). +fn edit_panel(raw: bool) -> Headless { + use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = if raw { + // a DNG raw photo (its file need not exist: the sliders only read its settings) + let id = PhotoId(h.app.session.catalog.photos().map(|p| p.id.0).max().unwrap_or(0) + 1); + let path = std::env::temp_dir().join("lc-temp-slider").join("IMG_1.dng"); + let mut photo = + Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, "IMG_1.dng", "DNG", 6000, 4000, "2026-10-01T00:00:00"); + photo.kind = MediaKind::Raw; + h.app.session.commit("setup", Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + id.0 + } else { + h.app.session.visible_cloned()[0].0 + }; + assert_eq!(h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T)["ok"], true); + assert_eq!(h.request("ui.set", json!({"view": "detail", "right": "edit"}), T)["ok"], true); + h.app.ui.open_sections = vec!["light".to_string(), "color".to_string()]; + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Where the slider's thumb (the hollow ring on the track's centre line) is drawn, from 0 to 1 along the track. +fn thumb(h: &Headless, control: &str) -> f32 { + let track = widget(h, &format!("slider:{control}")); + let rings: Vec = h + .view + .painted_shapes() + .iter() + .filter_map(|s| match s { + egui::epaint::Shape::Circle(c) if c.radius == 7.0 && (c.center.y - track.center().y).abs() < 0.5 => Some(c.center.x), + _ => None, + }) + .filter(|x| (track.left() - 0.5..=track.right() + 0.5).contains(x)) + .collect(); + assert!(!rings.is_empty() && rings.iter().all(|x| (x - rings[0]).abs() < 0.01), "one thumb on {control}: {rings:?}"); + (rings[0] - track.left()) / track.width() +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.selection.active.unwrap(); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +fn set(h: &mut Headless, control: &str, value: f64) { + let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": control, "value": value}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// The thumb sits where Lightroom puts it (within 1 % of the model fitted to Lightroom's positions), clicking or +/// dragging to the middle of the track gives 7000 K, and the far end is still 50000 K. +#[test] +fn the_kelvin_temp_slider_has_lightrooms_scale() { + let mut h = edit_panel(true); + for (kelvin, model) in [(3650.0, 0.165), (5400.0, 0.34), (7000.0, 0.5), (15000.0, 0.79), (20000.0, 0.849)] { + set(&mut h, "wb.temp", kelvin); + assert_eq!(develop(&h).wb.temp, kelvin); + let at = thumb(&h, "wb.temp"); + assert!((at - model).abs() < 0.01, "{kelvin} K drawn at {at}, the model says {model}"); + } + set(&mut h, "wb.temp", 4850.0); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:wb.temp", "fx": 0.5}), T)["ok"], true); + h.settle(SETTLE); + assert_eq!(develop(&h).wb.temp, 7000.0, "a click in the middle of the track"); + // drag from As Shot on a Z 6II NEF (about 4850 K, 28 % along the track) to the middle + set(&mut h, "wb.temp", 4850.0); + let track = widget(&h, "slider:wb.temp"); + let from = track.left() + thumb(&h, "wb.temp") * track.width(); + let r = h.request("ui.drag", json!({"x": from, "y": track.center().y, "toX": track.center().x, "toY": track.center().y, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let k = develop(&h).wb.temp; + assert!((k - 7000.0).abs() <= 50.0, "dragged to the middle: {k} K"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:wb.temp", "fx": 1.0}), T)["ok"], true); + h.settle(SETTLE); + assert_eq!(develop(&h).wb.temp, 50000.0); +} + +/// Exposure (like every slider but the Kelvin temperature) keeps its linear track. +#[test] +fn other_sliders_stay_linear() { + let mut h = edit_panel(true); + set(&mut h, "light.exposure", 1.0); + assert!((thumb(&h, "light.exposure") - 0.6).abs() < 0.005, "+1.00 of −5…+5 at 60 %"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:light.exposure", "fx": 0.75}), T)["ok"], true); + h.settle(SETTLE); + assert!((develop(&h).light.exposure - 2.5).abs() < 0.011, "{}", develop(&h).light.exposure); +} + +/// The relative temperature of a rendered photo (−100…+100, 0 = as shot, in the middle) is unchanged. +#[test] +fn the_relative_temp_slider_stays_linear() { + let mut h = edit_panel(false); + assert!(h.app.widgets.iter().all(|(w, _)| w != "slider:wb.temp"), "a JPEG has the relative scale"); + assert!((thumb(&h, "wb.tempRel") - 0.5).abs() < 0.005, "as shot in the middle"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:wb.tempRel", "fx": 0.75}), T)["ok"], true); + h.settle(SETTLE); + assert!((thumb(&h, "wb.tempRel") - 0.75).abs() < 0.005, "the thumb where it was clicked"); + // +50 on the relative scale: 40 mired warmer than 6500 K + let k = develop(&h).wb.temp; + let rel = (1e6 / 6500.0 - 1e6 / k) / 0.8; + assert!((rel - 50.0).abs() < 0.6, "{k} K is {rel} on the relative scale"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0872b2ec4..0260a6e2e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -216,9 +216,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); } let mut out = SliderOut::default(); - let span = (spec.max - spec.min).max(1e-9); - let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width(); - let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span; + // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) + let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); + let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); let mut v = value; if let Some((mut text, frames, pressed)) = typing.take() { // Keep the owned opening release focused without restarting its draft or selection. @@ -267,13 +267,19 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ && let Some(p) = resp.interact_pointer_pos() { let fine = ui.input(|i| i.modifiers.shift); + let step = spec.step.max(1e-9); let nv = if fine { + // a tenth of the pointer's movement along the track; the unrounded position is kept, so a + // slow drag adds up instead of rounding back to the same step every frame + let fine_id = id.with("fine-pos"); let dx = ui.input(|i| i.pointer.delta().x); - value + dx as f64 * span / track_rect.width() as f64 * 0.1 + let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((spec.value_at(p) / step).round() * step - value).abs() < step / 2.0); + let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_rect.width()) * 0.1).clamp(0.0, 1.0); + ui.data_mut(|m| m.insert_temp(fine_id, pos)); + spec.value_at(pos) } else { from_x(p.x) }; - let step = spec.step.max(1e-9); let nv = ((nv / step).round() * step).clamp(spec.min, spec.max); if (nv - value).abs() > 1e-12 { out.value = Some(nv); @@ -634,6 +640,44 @@ mod tests { assert_eq!(remainder, 0.0); } + /// Shift-drag moves a tenth of the pointer's way along the track, also on the Kelvin track (where a pixel of + /// fine drag near the centre is about 4 K): a pointer creeping a pixel a frame still adds up to steps. + #[test] + fn a_slow_shift_drag_adds_up() { + let ctx = egui::Context::default(); + let temp = lightcraft_develop::controls::find("wb.temp").unwrap(); + let mut value = 7000.0; + let frame = |time: f64, events: Vec, value: f64| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 120.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = SliderOut::default(); + ctx.run_ui(input, |ui| out = slider(ui, temp, value, true, None)).textures_delta.clear(); + let track = take_registry(&ctx).into_iter().find(|(id, _)| id == "slider:wb.temp").unwrap().1; + (out, track) + }; + // Shift is held from the start (the modifiers stay until they change) + let (_, track) = frame(0.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)], value); + let mut at = pos2(track.left() + temp.position(value) as f32 * track.width(), track.center().y); + let press = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT }; + frame(0.1, vec![egui::Event::PointerMoved(at), press(at, true)], value); + let moves = 60; + for i in 0..moves { + at.x += 1.0; + let (out, _) = frame(0.2 + f64::from(i) * 0.02, vec![egui::Event::PointerMoved(at)], value); + value = out.value.unwrap_or(value); + } + frame(2.0, vec![press(at, false)], value); + // the drag starts once the pointer has moved a few pixels; every pixel after that counts + let lo = temp.value_at(temp.position(7000.0) + f64::from(moves - 10) / f64::from(track.width()) * 0.1); + let hi = temp.value_at(temp.position(7000.0) + f64::from(moves) / f64::from(track.width()) * 0.1); + assert!(value >= lo - 50.0 && value <= hi + 50.0 && value > 7000.0, "{value} K, expected {lo:.0}–{hi:.0} K"); + } + /// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range. #[test] fn typed_values_are_stepped_and_clamped() { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..e5921ad9d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -190,7 +190,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | From 73a10f5b6dac59d5a36b3bf1dfee05c2b9e932d6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 17:41:40 +0200 Subject: [PATCH 101/142] ORF: as-shot white balance from maker-note 0x1017/0x1018 on the Olympus E-10 (part of #1273) --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/orf.rs | 55 +++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// 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/orf.rs b/crates/raw/src/vendor/orf.rs index 6d77925a0..a83d03684 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -19,7 +19,8 @@ //! six CC0 files (C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ, SP-570UZ). Their Exif `CFAPattern` states the //! layout (it matches the embedded thumbnails), the maker note's entries `0x1017`/`0x1018` hold the red and //! blue gain (first value, 256 = 1.0; checked against neutral areas of the thumbnail), and nothing states a -//! black level or an active area. +//! black level or an active area. The E-10 (16-bit words) has no ImageProcessing sub-directory either and keeps +//! its gains in the same two entries. //! - 12-bit blocks (E-300, E-500, E-330; the E-M5 Mark II and PEN-F high-resolution files): one strip of 12.8 bits //! per pixel, `width * 8 / 5` bytes per row, made of 16-byte blocks. A block is 15 sample bytes followed by one //! pad byte that is always zero; the 15 bytes are a little-endian 120-bit string holding ten 12-bit samples, sample @@ -195,7 +196,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let chunks = info.chunks(bytes.len() as u64); let total: u64 = chunks.iter().map(|c| c.len).sum(); let stated = cfa_from_exif(&tiff); - let mut two_field = false; let (mut data, bits) = if info.compression != 1 { return Err(RawError::Unsupported(format!("ORF compression {}", info.compression))); } else if total >= (n as u64) * 2 { @@ -232,7 +232,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .then(|| field_rows(&info.offsets.iter().copied().zip(info.byte_counts.iter().copied()).collect::>(), w, h, bytes.len())) .flatten() { - two_field = true; let d = if mode == Mode::Full || stated.is_none() { let (rb, h0) = (w * 3 / 2, h.div_ceil(2)); let mut d = vec![0u16; n]; @@ -290,8 +289,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .filter(|v| v.len() >= 2 && v[0] > 0.0 && v[1] > 0.0) .map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32]) .or_else(|| { - // two-field bodies: the red and blue gains lead the entries of the main maker note - let m = mn.as_ref().filter(|_| two_field)?; + // two-field bodies and the E-10: the red and blue gains lead the entries of the main maker note + let m = mn.as_ref()?; let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 0.0); Some([(g(MN_WB_RED)? / 256.0) as f32, 1.0, (g(MN_WB_BLUE)? / 256.0) as f32]) }); @@ -344,6 +343,11 @@ mod tests { /// A minimal ORF with one strip in IFD0 and, optionally, an Exif `CFAPattern`. fn orf_with(w: u32, h: u32, bits: u16, strip: Vec, cfa_pattern: Option<&[u8]>) -> Vec { + orf_with_note(w, h, bits, strip, cfa_pattern, None) + } + + /// [`orf_with`] plus an optional maker note blob in the Exif IFD. + fn orf_with_note(w: u32, h: u32, bits: u16, strip: Vec, cfa_pattern: Option<&[u8]>, note: Option>) -> Vec { let mut ifd = IfdBuilder::new(); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); @@ -352,9 +356,14 @@ mod tests { ifd.set(t::PHOTOMETRIC, Value::Short(vec![1])); ifd.set(t::MAKE, Value::Ascii("OLYMPUS IMAGING CORP.".into())); ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); - if let Some(p) = cfa_pattern { + if cfa_pattern.is_some() || note.is_some() { let mut exif = IfdBuilder::new(); - exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec())); + if let Some(p) = cfa_pattern { + exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec())); + } + if let Some(n) = note { + exif.set(t::MAKER_NOTE, Value::Undefined(n)); + } ifd.set_child(t::EXIF_IFD, exif); } let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); @@ -363,6 +372,38 @@ mod tests { b } + /// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) holding two-short + /// entries, whose values fit inline. + fn olymp_note(entries: &[(u16, [u16; 2])]) -> Vec { + let mut b = b"OLYMP\0\x01\0".to_vec(); + b.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, [v0, v1]) in entries { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&3u16.to_le_bytes()); + b.extend_from_slice(&2u32.to_le_bytes()); + b.extend_from_slice(&v0.to_le_bytes()); + b.extend_from_slice(&v1.to_le_bytes()); + } + b.extend_from_slice(&0u32.to_le_bytes()); + b + } + + /// Issue #1273: the E-10 (16-bit words, no ImageProcessing sub-directory) keeps its red and blue gains in + /// maker-note entries `0x1017`/`0x1018`, as the two-field compacts do. + #[test] + fn as_shot_white_balance_from_the_main_maker_note() { + let (w, h) = (16usize, 4usize); + let words: Vec = (0..w * h).flat_map(|i| (((100 + i * 37) % 4096) as u16).to_le_bytes()).collect(); + let wb = |entries: &[(u16, [u16; 2])]| { + crate::decode(&orf_with_note(w as u32, h as u32, 16, words.clone(), Some(RGGB), Some(olymp_note(entries)))).unwrap().wb_multipliers + }; + assert_eq!(wb(&[(MN_WB_RED, [384, 64]), (MN_WB_BLUE, [406, 64])]), Some([1.5, 1.0, 406.0 / 256.0])); + // both gains are needed, and a zero gain is none + assert_eq!(wb(&[(MN_WB_RED, [384, 64])]), None); + assert_eq!(wb(&[(MN_WB_RED, [0, 64]), (MN_WB_BLUE, [406, 64])]), None); + assert_eq!(wb(&[]), None); + } + /// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy). fn mosaic(w: usize, h: usize, site: impl Fn(usize, usize) -> [u16; 4]) -> Vec { (0..w * h).flat_map(|i| site(i % w / 2, i / w / 2)[((i / w) & 1) * 2 + ((i % w) & 1)].to_le_bytes()).collect() From e343b7aa6d53a14cbe7db4e331c9e4226b4571b8 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Mon, 12 Oct 2026 17:41:55 +0200 Subject: [PATCH 102/142] fix: right-click Delete Photo goes through the menu bar's run_item (#1239) Simplify review: the photo menu's Delete Photo now calls menubar::run_item, the one place a user's menu click is run (it already asks before photo.delete), instead of a second copy of the check. The test uses the file's right_click / click / exec helpers and drops a redundant settle; shorter doc comment on the setting. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/grid.rs | 5 ++--- crates/ui-egui/src/state.rs | 5 ++--- crates/ui-egui/src/tests_appearance.rs | 1 - crates/ui-egui/src/tests_panels.rs | 24 +++++++----------------- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index e008e043a..e40c5dc7a 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1140,9 +1140,8 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } else { let delete = ui.button(crate::i18n::tr("Delete Photo")); register(ui.ctx(), "button:contextDeletePhoto", delete.rect); - // asks first like the key and the menu bar (Settings ▸ General) - if delete.clicked() && !crate::menus::confirm_delete(app) { - let _ = app.run("photo.delete", json!({})); + if delete.clicked() { + let _ = crate::menubar::run_item(app, "photo.delete", json!({})); } } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b1d51b7cb..61573f879 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -234,9 +234,8 @@ pub struct AppSettings { /// [`RECENT_LIBRARIES`]). pub recent_libraries: Vec, pub startup_view: StartupView, - /// Ask before moving photos to Recently Deleted (keyboard, menu bar and a photo's menu); on by - /// default. Saved as `askBeforeDelete`: earlier versions saved `confirmDelete: false` for - /// everyone who never opened Settings, so that key is no longer read (#1239). + /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved + /// `confirmDelete: false` by default (#1239). #[serde(rename = "askBeforeDelete")] pub confirm_delete: bool, /// GPU rendering allowed (`app.gpu`). diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs index 78a48194f..3703f0219 100644 --- a/crates/ui-egui/src/tests_appearance.rs +++ b/crates/ui-egui/src/tests_appearance.rs @@ -41,7 +41,6 @@ fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { /// A choice made from now on survives a restart. #[test] fn delete_asks_first_by_default_also_for_settings_saved_by_earlier_versions() { - assert!(AppSettings::default().confirm_delete); let old: UiState = serde_json::from_str(r#"{"settings": {"confirmDelete": false}}"#).unwrap(); assert!(old.settings.confirm_delete, "the old default does not turn the question off"); let mut ui = UiState::default(); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index dd6e509e6..0fa5a2d45 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2150,9 +2150,7 @@ fn deleting_a_photo_asks_first_from_the_key_the_menu_and_the_right_click_menu() let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid"})); assert!(h.app.ui.settings.confirm_delete, "on by default"); let photo = h.app.session.visible_cloned()[0]; - let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), T); - assert_eq!(r["ok"], true, "{r}"); - h.settle(SETTLE); + exec(&mut h, "library.select", json!({"ids": [photo.0]})); let deleted = |h: &Headless| h.app.session.catalog.photo(photo).unwrap().deleted; // ⌫ and Photo ▸ Delete Photo ask; Cancel keeps the photo @@ -2167,17 +2165,9 @@ fn deleting_a_photo_asks_first_from_the_key_the_menu_and_the_right_click_menu() assert!(!deleted(&h)); // the right-click menu's Delete Photo asks too; OK deletes - let right_click_delete = |h: &mut Headless| { - let thumb = widget(h, &format!("thumb:{}", photo.0)); - let r = h.request("ui.click", json!({"x": thumb.center().x, "y": thumb.center().y, "button": "right"}), T); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - h.step(); - let r = h.request("ui.clickWidget", json!({"id": "button:contextDeletePhoto"}), T); - assert_eq!(r["ok"], true, "{r}"); - h.step(); - }; - right_click_delete(&mut h); + let thumb = format!("thumb:{}", photo.0); + right_click(&mut h, &thumb); + click(&mut h, "button:contextDeletePhoto"); assert_eq!(h.app.ui.dialog, Some(Dialog::ConfirmDelete { count: 1 }), "the right-click menu asks"); assert!(!deleted(&h), "nothing deleted before the answer"); let r = h.request("ui.dialog.confirm", json!({}), T); @@ -2185,11 +2175,11 @@ fn deleting_a_photo_asks_first_from_the_key_the_menu_and_the_right_click_menu() assert!(deleted(&h)); // turned off, the right-click menu deletes at once - h.request("engine.execute", json!({"command": "edit.undo"}), T); + exec(&mut h, "edit.undo", json!({})); assert!(!deleted(&h)); h.app.ui.settings.confirm_delete = false; - h.settle(SETTLE); - right_click_delete(&mut h); + right_click(&mut h, &thumb); + click(&mut h, "button:contextDeletePhoto"); assert_eq!(h.app.ui.dialog, None); assert!(deleted(&h)); } From acbd08eb7355f6fe9dc6d3c99a9478f79478ea8e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:14:05 +0200 Subject: [PATCH 103/142] LR-LIB-DELETE: a file in Recently Deleted can be checked by hand in the import review (fixes #1285) With "Leave them", the review refused every click on a file whose photo is in Recently Deleted, and its "In Recently Deleted" badge was drawn over the checkbox. Leave them now only keeps such files out of the default selection (Check All and the file-type toggles skip them); a click checks one and it is restored, as the import does by default. The badge goes under the checkbox when it doesn't fit beside it. --- crates/ui-egui/src/headless.rs | 64 +++++++++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 69 ++++++++++++++++++++++++++++++---- docs/parity-checklist.md | 2 +- 3 files changed, 127 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..6083c4575 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3076,6 +3076,70 @@ mod tests { } } + /// With "Leave them", a file in Recently Deleted is left unchecked but a click on its checkbox + /// (or on its "In Recently Deleted" badge) still checks it, and importing it restores the + /// photo (issue #1285). + #[test] + fn import_review_checks_a_recently_deleted_file_by_hand() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-click-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().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); + let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); + let id = r["result"]["imported"][0].as_u64().unwrap(); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; + let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).expect("a file in Recently Deleted"); + opts.set_on_deleted(""); + h.settle(SETTLE); + let checked = |h: &Headless| matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Import { opts }) if opts.checked[trashed]); + assert!(!checked(&h), "Leave them: unchecked"); + let cell = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({"filter": format!("import:{trashed}")}), t); + let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == format!("import:{trashed}"))).cloned().expect("cell on screen"); + [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) + }; + // the checkbox: 16 pt, 6 pt in from the cell's top-left corner + let [x, y, ..] = cell(&mut h); + h.request("ui.click", json!({"x": x + 14.0, "y": y + 14.0}), t); + h.settle(SETTLE); + assert!(checked(&h), "a click on the checkbox checks the file"); + // the badges ("Will be restored" at the top right, "In Recently Deleted" under the + // checkbox) don't swallow the click either + let [x, y, w, _] = cell(&mut h); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 12.0}), t); + h.settle(SETTLE); + assert!(!checked(&h), "a click on the badge unchecks it again"); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 34.0}), t); + h.settle(SETTLE); + assert!(checked(&h)); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + 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(), "finished"); + let photos = &h.app.session.catalog; + assert!(photos.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); + assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2); + assert_eq!(photos.len(), 2, "restored, not imported a second time"); + let _ = std::fs::remove_dir_all(&dir); + } + /// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its /// corner: the photo grid takes the new height, and the window then keeps its size (issue #337). #[test] diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..89fe2efe7 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -8,7 +8,7 @@ //! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is //! one undo step. -use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; use lightcraft_engine::import::{ ImportCandidate, ImportUprightMode, ImportWarning, ImportWarningCode, ScanInput, ScanOutput, ScanProgress, scan_with, }; @@ -123,14 +123,21 @@ impl ImportDialog { if !self.is_trashed(i) { continue; } - let on = self.importable(i); + let on = self.checked_by_default(i); if let Some(c) = self.checked.get_mut(i) { *c = on; } } } + /// Candidate `i` can be checked: a readable file the library doesn't have, or one whose photo + /// is in Recently Deleted — even with "Leave them", a click picks it (issue #1285). pub fn importable(&self, i: usize) -> bool { - self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty())) + self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i))) + } + /// Candidate `i` is checked by Check All and the file type toggles: with "Leave them", a file in + /// Recently Deleted is only checked by hand. + pub fn checked_by_default(&self, i: usize) -> bool { + self.importable(i) && !(self.is_trashed(i) && self.on_deleted.is_empty()) } /// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each. pub fn file_types(&self) -> Vec<(String, usize)> { @@ -149,7 +156,7 @@ impl ImportDialog { pub fn check_type(&mut self, ty: &str, on: bool) { for i in 0..self.candidates.len() { let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty); - let importable = self.importable(i); + let importable = self.checked_by_default(i); if same && let Some(c) = self.checked.get_mut(i) { *c = on && importable; } @@ -933,7 +940,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { for i in 0..n { - d.checked[i] = d.importable(i); + d.checked[i] = d.checked_by_default(i); } } }); @@ -1282,7 +1289,8 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial } 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 && ok => Some(if d.on_deleted == "restore" { "Will be restored" } else { "Will be replaced" }), + // checked with "Leave them", the file goes without `onDeleted`: the engine restores it + (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), (Some(_), _) if in_trash => Some("In Recently Deleted"), (Some(r), _) if r == "path" => Some("In library"), (Some(_), _) => Some("Duplicate"), @@ -1291,7 +1299,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial }; if let Some(b) = badge { 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)); + let br = badge_rect(img, cb, g.size()); 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); } @@ -1313,6 +1321,15 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial } } +/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit +/// beside it — drawn over the checkbox it hid it (issue #1285). +fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { + let size = text + vec2(8.0, 4.0); + let left = img.right() - size.x - 4.0; + let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; + Rect::from_min_size(pos2(left, top), size) +} + /// Reserve the actual translated tag button width beside a template field. pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { let t = Tokens::get(ui.ctx()); @@ -1580,6 +1597,44 @@ mod tests { assert_eq!(d.checked, [true, true, false, false, true, true]); } + /// With "Leave them", a file in Recently Deleted stays out of Check All and the type toggles, + /// but a click still checks it: it is imported (restored) on its own (issue #1285). + #[test] + fn leave_them_keeps_a_trashed_file_out_until_it_is_clicked() { + let mut d = dialog(); + d.set_trashed(vec![false, false, false, true, false, false]); + assert!(d.checked[3], "restored by default"); + d.set_on_deleted(""); + assert!(!d.checked[3]); + d.check_type("JPG", true); + assert!(!d.checked[3], "the type toggle leaves it out"); + d.click(3, false); + assert!(d.checked[3], "a click checks it"); + assert!(d.selected_paths().contains(&"img3.jpg".to_string())); + d.click(3, false); + assert!(!d.checked[3]); + // a plain duplicate (not in Recently Deleted) still can't be checked + d.set_trashed(vec![false; 6]); + d.click(3, false); + assert!(!d.checked[3]); + } + + /// A badge too wide to fit beside the checkbox goes under it instead of covering it (issue #1285). + #[test] + fn a_badge_never_covers_the_checkbox() { + let img = Rect::from_min_size(pos2(100.0, 50.0), vec2(116.0, 116.0)); + let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); + // "Will be restored" fits beside it, top right + let short = badge_rect(img, cb, vec2(76.0, 12.0)); + assert_eq!(short.right(), img.right() - 4.0); + assert_eq!(short.top(), img.top() + 6.0); + // "In Recently Deleted" doesn't + let long = badge_rect(img, cb, vec2(96.0, 12.0)); + assert!(!long.intersects(cb), "{long:?} covers {cb:?}"); + assert_eq!(long.right(), img.right() - 4.0); + assert!(img.contains_rect(long)); + } + #[test] fn shift_click_with_a_stale_anchor_toggles_one() { let mut d = dialog(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..22833854c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -107,7 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; 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); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; 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); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | From d7037d117f77952f967d932f616e079ec194b771 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Mon, 12 Oct 2026 18:20:15 +0200 Subject: [PATCH 104/142] M5.6: a view slot renders one job at a time, so a drag's renders don't pile up The job pool replaced a slot's queued job, but a newer job for a slot whose job was already running started beside it on another worker. A slider drag submits faster than renders finish, so one view had several renders on the GPU at once and about 30 % of finished renders were thrown away (61 MP raw, 1:1 Exposure drag). Now a slot's next job waits until its running one ends (a newer submission still replaces it in the queue); other slots are not held up, and a job that panics frees its slot. Measured on a 61 MP raw at 1:1, 8 s Exposure drags (M4 Max): thrown-away renders roughly halved, and frames over 20 ms 0 % in every one of 4 runs (one run of the overlapping pool dropped 10 %). Co-Authored-By: Claude Opus 5.5 --- crates/preview/src/pool.rs | 44 +++++++++++++++++++-- crates/preview/src/tests.rs | 76 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs index 4dbd35661..285cfd802 100644 --- a/crates/preview/src/pool.rs +++ b/crates/preview/src/pool.rs @@ -30,6 +30,11 @@ struct Entry { struct Shared { queue: Mutex>>, + /// Slots with a job running: their next job waits until it ends, so a slot never has two at + /// once (a slider drag submits faster than renders finish; overlapping renders of one view + /// were mostly thrown away, and took the GPU from the one that is shown). Locked inside + /// `queue`. + busy: Mutex>, cv: Condvar, shutdown: AtomicBool, /// The worker threads that have not ended yet, and the signal that one did. @@ -73,6 +78,7 @@ impl JobPool { JobPool { shared: Arc::new(Shared { queue: Mutex::new(Vec::new()), + busy: Mutex::new(Vec::new()), cv: Condvar::new(), shutdown: AtomicBool::new(false), #[cfg(not(target_arch = "wasm32"))] @@ -119,6 +125,8 @@ impl JobPool { let _ended = Ended(&shared); while let Some(e) = take(&shared, true) { let t0 = std::time::Instant::now(); + // the slot is free again when the job ends, also if it panics + let _free = Free(&shared, e.slot); let result = (e.job)(); let done = Done { slot: e.slot, key: e.key, result, ms: t0.elapsed().as_secs_f64() * 1000.0 }; if tx.send(done).is_err() { @@ -219,6 +227,7 @@ impl JobPool { let mut ran = 0; while ran < n { let Some(e) = take(&self.shared, false) else { break }; + let _free = Free(&self.shared, e.slot); let result = (e.job)(); let _ = self.tx.send(Done { slot: e.slot, key: e.key, result, ms: 0.0 }); ran += 1; @@ -229,14 +238,21 @@ impl JobPool { /// Pop the best job (highest priority, newest first). Blocks when `wait` until one is queued or /// the pool shuts down. -fn take(shared: &Shared, wait: bool) -> Option> { +fn take(shared: &Shared, wait: bool) -> Option> { let mut q = shared.queue.lock().unwrap_or_else(|e| e.into_inner()); loop { if shared.shutdown.load(Ordering::Relaxed) { return None; } - if let Some(i) = q.iter().enumerate().max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i) { - return Some(q.swap_remove(i)); + // (a slot with a job running waits for it: see `Shared::busy`) + let best = { + let busy = shared.busy.lock().unwrap_or_else(|e| e.into_inner()); + q.iter().enumerate().filter(|(_, e)| !busy.contains(&e.slot)).max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i) + }; + if let Some(i) = best { + let e = q.swap_remove(i); + shared.busy.lock().unwrap_or_else(|e| e.into_inner()).push(e.slot); + return Some(e); } if !wait { return None; @@ -245,6 +261,28 @@ fn take(shared: &Shared, wait: bool) -> Option> { } } +/// The job for `slot` ended: a job queued for it may start. +fn finished(shared: &Shared, slot: &S) { + { + // under the queue lock: a worker is either before its look at `busy` or waiting + let _q = shared.queue.lock().unwrap_or_else(|e| e.into_inner()); + let mut busy = shared.busy.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(i) = busy.iter().position(|s| s == slot) { + busy.swap_remove(i); + } + } + shared.cv.notify_all(); +} + +/// Frees a job's slot when the job ends (see [`finished`]). +struct Free<'a, S: PartialEq, R>(&'a Shared, S); + +impl Drop for Free<'_, S, R> { + fn drop(&mut self) { + finished(self.0, &self.1); + } +} + /// Takes a worker off the live list when its thread ends (also when a job panicked). #[cfg(not(target_arch = "wasm32"))] struct Ended<'a, S, R>(&'a Shared); diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index d733ad829..718fa0d20 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -351,3 +351,79 @@ fn disk_cache_never_touches_foreign_files() { assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); let _ = std::fs::remove_dir_all(&dir); } + +/// Wait up to `secs` for `f` to hold. +fn eventually(secs: u64, mut f: impl FnMut() -> bool) -> bool { + let t0 = std::time::Instant::now(); + while t0.elapsed() < std::time::Duration::from_secs(secs) { + if f() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false +} + +/// A slider drag submits for a view faster than its renders finish. A newer job for a slot used +/// to start beside the running one on another worker: one view's renders piled up on the GPU and +/// most were thrown away. Now a slot runs one job at a time: the newer one waits (replacing an +/// older one still queued), other slots keep going, and it starts when the slot's job ends. +#[test] +fn a_slot_runs_one_job_at_a_time() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let mut p: JobPool = JobPool::new(4); + let (release, released) = std::sync::mpsc::channel::<()>(); + let (started, has_started) = std::sync::mpsc::channel(); + let running = Arc::new(AtomicUsize::new(0)); + let most = Arc::new(AtomicUsize::new(0)); + let job = |n: u32, wait: Option>| -> crate::pool::Job { + let (running, most, started) = (running.clone(), most.clone(), started.clone()); + Box::new(move || { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + most.fetch_max(now, Ordering::SeqCst); + let _ = started.send(n); + if let Some(w) = wait { + let _ = w.recv_timeout(std::time::Duration::from_secs(30)); + } + running.fetch_sub(1, Ordering::SeqCst); + n + }) + }; + p.submit(1, 1, 9, job(1, Some(released))); + assert_eq!(has_started.recv().unwrap(), 1); + // two newer requests for the busy slot: the second replaces the first, neither starts + p.submit(1, 2, 9, job(2, None)); + p.submit(1, 3, 9, job(3, None)); + // another slot is not held up + p.submit(2, 9, 1, Box::new(|| 9)); + let mut done = Vec::new(); + assert!(eventually(10, || { + done.extend(std::iter::from_fn(|| p.try_recv()).map(|d| d.result)); + done.contains(&9) + })); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!(has_started.try_recv().is_err(), "a second job of slot 1 started beside the first"); + assert!(p.is_queued(1)); + drop(release); + assert!(eventually(10, || { + done.extend(std::iter::from_fn(|| p.try_recv()).map(|d| d.result)); + done.contains(&3) + })); + assert!(!done.contains(&2), "the replaced job never runs"); + assert_eq!(most.load(Ordering::SeqCst), 1, "slot 1 ran two jobs at once"); +} + +/// A job that panics still frees its slot: the slot's next job runs (on another worker). +#[test] +fn a_panicking_job_frees_its_slot() { + let mut p: JobPool = JobPool::new(2); + p.submit(1, 1, 9, Box::new(|| panic!("simulated render panic"))); + assert!(eventually(10, || !p.is_queued(1) && p.live_workers() == 1)); + p.submit(1, 2, 9, Box::new(|| 2)); + let mut got = None; + assert!(eventually(10, || { + got = got.or_else(|| p.try_recv().map(|d| d.result)); + got.is_some() + })); + assert_eq!(got, Some(2)); +} From ecd6618f6f809ae86a79a5c77bd413b1bed692dd Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 17:59:52 +0200 Subject: [PATCH 105/142] LR-IMP-FORMATS: uncompressed Leaf MOS is a raw, the Hasselblad Lunar decodes as an ARW (#1275) An uncompressed Leaf Aptus 22 MOS keeps its 16-bit mosaic in IFD0 (compression 1, BlackIsZero, one sample per pixel) with no Make tag and no CFA photometric, so `probe` returned None and the file imported as a greyscale image of the mosaic. Leaf's data block, tag 0x8606 (LeafData in the ExifTool EXIF tag-name documentation), now marks such an IFD0 as a raw: OtherTiff, preview only with the reason, as the lossless-JPEG MOS already is. A rendered RGB image that kept the block stays an image. The Hasselblad Lunar is a Sony NEX-7 that writes an ARW under Make HASSELBLAD; only SONY files reached the ARW reader, so it opened as its preview. A HASSELBLAD file with Sony's raw coding (an IFD with compression 32767 or the tone-curve tag 0x7010, which no 3FR / FFF has) now goes there and decodes like a NEX-7: black 512, the SR2SubIFD white balance. The Tag2010 white-balance fallback, the one table arw.rs keys by model, takes the body from the maker note's SonyModelID (0xb001) when the file is not Sony-branded. Docs: the Lunar is verified in docs/raw/evidence.tsv (236 verified), raw-parity.md, parity-checklist.md, gaps.md and whats-new.md. RENDER_CACHE_VERSION 61 -> 62: a Lunar's thumbnails were rendered from its preview. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/files.rs | 31 ++++++++++++ crates/engine/src/media.rs | 2 +- crates/raw/src/lib.rs | 78 +++++++++++++++++++++++++++++- crates/raw/src/vendor/arw.rs | 93 ++++++++++++++++++++++++++++++++++-- docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 17 +++---- docs/raw/coverage.tsv | 2 +- docs/raw/evidence.tsv | 1 + docs/whats-new.md | 8 ++++ 10 files changed, 223 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..594537dc3 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1846,6 +1846,37 @@ mod tests { assert!(e.contains("without an embedded preview"), "{e}"); } + /// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` + /// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. + #[test] + fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mos = |leaf_data: bool| { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); + if leaf_data { + ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); + } + TiffWriter::default().write(&[ifd0]).unwrap() + }; + let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); + assert!(p.preview_only.is_some_and(|why| !why.is_empty())); + let (img, src) = load_bytes(&mos(true), 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // the same grey image without Leaf's block is an ordinary 16-bit TIFF + let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); + } + /// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its /// headers; loading it says what is wrong instead of naming a bare TIFF compression number. #[test] diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9edb..1bd06f396 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -189,7 +189,10 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("NIKON") && has_cfa { return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw }); } - if make.starts_with("SONY") && sony_raw { + // Hasselblad's Sony-built bodies (the Lunar is a NEX-7 inside) write a Sony ARW under their own Make. Sony's + // raw coding gives them away: compression 32767 or the raw IFD's tone-curve tag 0x7010, which no 3FR / FFF has + let sony_coded = || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION) == Some(32767) || i.contains(0x7010)); + if (make.starts_with("SONY") && sony_raw) || (make.starts_with("HASSELBLAD") && sony_coded()) { return Some(RawFormat::Arw); } if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && pentax_raw { @@ -208,12 +211,23 @@ pub fn probe(bytes: &[u8]) -> Option { if dng::is_plain_cfa_tiff(&t, bytes) { return Some(RawFormat::CfaTiff); } - if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) { + if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) || is_leaf_mosaic(ifd0) { return Some(RawFormat::OtherTiff); } None } +/// Leaf's private data block in IFD0, tag `0x8606` (`LeafData` in the ExifTool EXIF tag-name documentation, which +/// says the Leaf tags "are found in .MOS images from Leaf digital camera backs"). +const LEAF_DATA: u16 = 0x8606; + +/// An uncompressed Leaf MOS: IFD0 is the 16-bit mosaic itself (compression 1, BlackIsZero, one sample per pixel), +/// with no Make tag and no CFA photometric, so only Leaf's data block marks it as a raw (issue #1275). A rendered +/// RGB image that kept the block stays an ordinary image. +fn is_leaf_mosaic(ifd0: &lightcraft_tiff::Ifd) -> bool { + ifd0.contains(LEAF_DATA) && ifd0.u16(lightcraft_tiff::tags::SAMPLES_PER_PIXEL).unwrap_or(1) == 1 +} + /// Whether the Exif maker note starts with the signature of the Pentax layouts (`AOC\0` or `PENTAX \0`). fn has_pentax_maker_note(t: &Tiff, bytes: &[u8]) -> bool { let Some(e) = t.exif().and_then(|e| e.get(lightcraft_tiff::tags::MAKER_NOTE)) else { return false }; @@ -1066,6 +1080,66 @@ mod tests { assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); } + /// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's + /// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD + /// without the block, or an RGB image carrying it, stays an ordinary image. + #[test] + fn uncompressed_leaf_mos_is_a_raw() { + let mosaic = |leaf_data: bool| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] }); + if leaf_data { + ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + } + ifd + }; + let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap(); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_)))); + assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_)))); + assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF"); + let mut rgb = rgb_ifd(16, 12); + rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block"); + } + + /// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767 + /// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there. + #[test] + fn hasselblad_make_with_sony_raw_coding_is_arw() { + let file = |make: &str, compression: u16, tone_curve: bool| { + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![64])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + if tone_curve { + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + } + raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("Lunar".into())); + ifd0.add_sub_ifd(raw); + write(&[ifd0]) + }; + assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw)); + assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw)); + let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap(); + assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14)); + // a Hasselblad CFA image in another coding is not an ARW + assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw)); + assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); + } + // --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- /// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index e8c766004..83d1b4177 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -199,9 +199,28 @@ const TAG2010_WB: &[(&[&str], usize)] = &[ ), ]; +/// Maker-note `SonyModelID` (`0xb001`, int16u, ExifTool Sony tag documentation). +const MN_SONY_MODEL_ID: u16 = 0xb001; + +/// `SonyModelID` values (ExifTool Sony tag documentation) of the Sony bodies Hasselblad sold under its own Make and +/// model (the Lunar, a NEX-7; the Stellar and Stellar II, an RX100 and RX100 II; the HV, an SLT-A99), so that +/// [`TAG2010_WB`] finds their layout. Measured on the CC0 Lunar (raw.pixls.us 4051): Make HASSELBLAD, Model Lunar, +/// SonyModelID 289 (issue #1275). +const SONY_MODEL_IDS: &[(u64, &str)] = &[(289, "NEX-7"), (294, "SLT-A99"), (297, "DSC-RX100"), (308, "DSC-RX100M2")]; + +/// The Sony body for the model-keyed tables: the Exif `model` of a Sony-branded file, else the one the maker note's +/// `SonyModelID` names (a Lunar says "Lunar"), else `model` as it is. +fn sony_body<'a>(make: &str, model: &'a str, mn: Option<&makernote::MakerNote>) -> &'a str { + if make.trim().to_ascii_uppercase().starts_with("SONY") { + return model; + } + let id = mn.and_then(|m| m.ifd.u64(MN_SONY_MODEL_ID)); + SONY_MODEL_IDS.iter().find(|(i, _)| Some(*i) == id).map_or(model, |(_, name)| name) +} + /// White balance from the maker note's enciphered `Tag2010` block, the fallback described at [`TAG2010_WB`]. -/// `model` is the Exif model; Sony appends a regional "V" to some names (SLT-A77V), which the documented lists -/// omit. +/// `model` is the Sony body ([`sony_body`]); Sony appends a regional "V" to some names (SLT-A77V), which the +/// documented lists omit. fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<[f32; 3]> { let model = model.trim(); let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?; @@ -697,11 +716,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .exif() .and_then(|e| e.get(t::MAKER_NOTE)) .and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default())); + let body = sony_body(&ifd0.string(t::MAKE).unwrap_or_default(), &model, mn.as_ref()); let wb = raw .f64s(WB_RGGB) .and_then(|v| rggb_gains(&v)) .or_else(|| rggb_gains(&sr2()?.shorts(WB_RGGB)?)) - .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))) + .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(body, m.ifd.bytes(MN_TAG2010)?, m.order))) .or_else(|| sr2_old_wb(sr2()?)); let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), @@ -1177,6 +1197,73 @@ mod tests { file } + /// Issue #1275: a Hasselblad Lunar (Make HASSELBLAD, Model Lunar) is a NEX-7. Its maker note (header-less, as the + /// Lunar's is) names the body by `SonyModelID`, which picks the NEX-7's `Tag2010` layout for the fallback white + /// balance; without the ID, or with one that isn't listed, there is no layout to read. + #[test] + fn rebadged_body_takes_its_tag2010_layout_from_the_sony_model_id() { + use lightcraft_tiff::{ByteOrder::Little, IfdBuilder, ImageData, TiffWriter, Value}; + let file = |make: &str, model: &str, id: Option| { + let mut block = vec![0u8; 4600]; + for (i, v) in [639u16, 256, 368].iter().enumerate() { + block[4480 + 2 * i..4482 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let block = encipher(&block); + let note = |at: usize| -> Vec { + let entries = 1 + u16::from(id.is_some()); + let mut n = entries.to_le_bytes().to_vec(); + n.extend(MN_TAG2010.to_le_bytes()); + n.extend(7u16.to_le_bytes()); + n.extend((block.len() as u32).to_le_bytes()); + n.extend(((at + 2 + 12 * entries as usize + 4) as u32).to_le_bytes()); + if let Some(id) = id { + n.extend(MN_SONY_MODEL_ID.to_le_bytes()); + n.extend(3u16.to_le_bytes()); + n.extend(1u32.to_le_bytes()); + n.extend(id.to_le_bytes()); + n.extend([0, 0]); + } + n.extend([0; 4]); + n.extend(&block); + n + }; + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; 32 * 4]] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set_child(t::EXIF_IFD, IfdBuilder::new().with(t::MAKER_NOTE, Value::Undefined(note(0)))); + ifd0.add_sub_ifd(raw); + let mut file = TiffWriter::new(Little, false).write(&[ifd0]).unwrap(); + let parsed = Tiff::parse(&file).unwrap(); + let e = parsed.exif().unwrap().get(t::MAKER_NOTE).unwrap(); + let at = e.offset as usize; + file[at..at + e.count()].copy_from_slice(¬e(at)); + file + }; + let wb = |make: &str, model: &str, id: Option| { + let bytes = file(make, model, id); + assert_eq!(crate::probe(&bytes), Some(RawFormat::Arw), "{make} {model}"); + let full = crate::decode(&bytes).unwrap(); + assert_eq!(crate::probe_info(&bytes).unwrap().wb_multipliers, full.wb_multipliers); + full.wb_multipliers + }; + let nex7 = Some([639.0 / 256.0, 1.0, 368.0 / 256.0]); + assert_eq!(wb("HASSELBLAD", "Lunar", Some(289)), nex7); + assert_eq!(wb("SONY", "NEX-7", None), nex7, "the Sony body itself"); + assert_eq!(wb("HASSELBLAD", "Lunar", None), None, "no SonyModelID"); + assert_eq!(wb("HASSELBLAD", "Lunar", Some(288)), None, "an ID that is not listed (NEX-5N)"); + // a Sony file keeps its Exif model whatever the ID says + assert_eq!(wb("SONY", "ILCE-1", Some(289)), None); + assert_eq!(sony_body("HASSELBLAD", "HV", None), "HV"); + } + #[test] fn dsc_r1_sr2_subifd_gives_black_wb_and_crop() { use lightcraft_tiff::ByteOrder::Little; diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..29e1207fe 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 10: the Hasselblad Lunar ARW decodes and is verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..9a96aad30 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-FORMATS: Hasselblad Lunar ARW decodes, uncompressed Leaf MOS recognised as a raw) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..a0d456463 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Hasselblad Lunar ARW decoded and verified; uncompressed Leaf MOS recognised as a raw) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,9 +14,9 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoded from sensor data and **verified** on a real file | **236** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | | Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | +| **Preview-only** (embedded JPEG) | **232** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | @@ -75,13 +75,13 @@ committed table is stale. | Leica | 61 | 13 | 48 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | -| Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF | +| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | | Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **236 (16%)** | **942 (65%)** | **232 (16%)** | **36 (2%)** | | ## By use (estimated) @@ -97,7 +97,7 @@ committed table is stale. | Pentax / Ricoh | 1.5% | 94% | 14% | | | Leica | 1% | 95% | 5% | | | DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | +| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F | | **Weighted** | 100% | **85%** | **42%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually @@ -121,7 +121,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633 | | NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | | NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | -| ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024 | +| ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024; the Hasselblad Lunar, a NEX-7 under Make HASSELBLAD, routed by Sony's raw coding (#1275) | | ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured | | ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` | | ARW | 12-bit compressed of DSLR-A200…A390 | ⬜ | preview (#535) | @@ -144,7 +144,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | DNG | GoPro GPR (VC-5) | ⬜ | refused | | 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | | 3FR / FFF | Hasselblad compression | ⬜ | preview | -| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview | +| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | | DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | ### Compressed ORF: a clean-room starting point @@ -266,6 +266,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Hasselblad Lunar verified (a Sony NEX-7 ARW under Make HASSELBLAD, now decoded): 236 verified, 232 preview; an uncompressed Leaf MOS is recognised as a raw (preview only) instead of opening as a greyscale image of its mosaic (#1275) | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..b9de3408a 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -521,7 +521,7 @@ Hasselblad H5D-50c 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Hasselblad Lunar 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad Lunar 3FR 2012 verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad X1D 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..180f596ad 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -31,6 +31,7 @@ Fujifilm X-T4** verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T50 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Google Pixel 2 XL verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Hasselblad Lunar verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Leica C (Typ 112) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux (Typ 109) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux 3 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..c6c19d33c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-12 + +### Leaf and Hasselblad raws +- The Hasselblad Lunar's raws (a Sony NEX-7 inside) now develop from their sensor data with every editing tool, + instead of opening as their embedded preview. Photos imported earlier switch over with Photo ▸ Reload from Disk. +- An uncompressed Leaf Aptus 22 MOS no longer opens as a dark, colourless picture of its sensor mosaic: it imports + as a raw and shows its embedded preview (preview only, with the reason), as the compressed MOS files do. + ## 2026-10-11 ### JPEG XL export From 7aaeef267622a3a80367c596a3536e70416655e8 Mon Sep 17 00:00:00 2001 From: untypequicode Date: Mon, 12 Oct 2026 19:13:04 +0200 Subject: [PATCH 106/142] Nix: fix the flake at HEAD (git dependency) and load libdbus/libEGL on NixOS Cargo.lock now pins storytold/craft-libs by git revision, so the flake failed at evaluation with "No hash was found while vendoring the git dependency" (cargoLock.allowBuiltinFetchGit fixes it). rfd dlopens libdbus-1 for the xdg-desktop-portal file chooser and wgpu may fall back to libEGL: neither is on NixOS's default library path, so add dbus and libglvnd to the wrapper's LD_LIBRARY_PATH. Co-Authored-By: Claude Sonnet 5.5 --- nix/package.nix | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 9e769602e..644d49b6a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,8 +4,8 @@ # nix build # → ./result/bin/lightcraft, ./result/bin/lightcraft-cli # # Kept in step with the release workflow (.github/workflows/release.yml) and packaging/env.sh: -# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump — only -# crates.io, no git sources; +# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump. The git +# source (storytold/craft-libs) is fetched at the revision the lock file pins; # * `CRAFT_FONTS_DIR` embeds the CJK fonts from storytold/craft-fonts (the `craft-fonts` # flake input); without it everything builds and runs, but Japanese and Chinese text have no glyphs; # * the desktop file, hicolor icons and AppStream metadata are the same files the .deb/.rpm ship @@ -32,6 +32,9 @@ libxi, libxrandr, vulkan-loader, + libglvnd, + # rfd opens libdbus-1 at run time to reach the xdg-desktop-portal file chooser (Open/Save dialogs). + dbus, # Validates the desktop file and the AppStream metadata we install (as CI does). desktop-file-utils, appstream, @@ -56,8 +59,9 @@ let appId = "ai.storyteller.lightcraft"; # Libraries the binaries open at run time with dlopen(): nothing links them, so no RPATH points at - # them — winit loads libxkbcommon/libxcb, wgpu the Vulkan loader (NixOS patches that loader to - # find the drivers in /run/opengl-driver). The wrapper below points LD_LIBRARY_PATH here. + # them — winit loads libwayland-client/libxkbcommon/libxcb, rfd libdbus-1, wgpu the Vulkan loader + # or libEGL (NixOS patches that loader to find the drivers in /run/opengl-driver). The wrapper + # below points LD_LIBRARY_PATH here. runtimeLibs = lib.optionals isLinux [ libxkbcommon wayland @@ -67,6 +71,8 @@ let libxi libxrandr vulkan-loader + libglvnd + dbus ]; # The two native binaries. The wasm app (apps/lightcraft-web) is built by `cargo xtask web`, not @@ -98,7 +104,11 @@ rustPlatform.buildRustPackage { ]); }; - cargoLock.lockFile = root + "/Cargo.lock"; + cargoLock = { + lockFile = root + "/Cargo.lock"; + # The git dependency (storytold/craft-libs) is pinned by revision in Cargo.lock. + allowBuiltinFetchGit = true; + }; # Both binaries, as the .deb/.rpm install them (`packaging/linux/package.sh`). cargoBuildFlags = [ From ef3e50bb2d6440c6ca0888c286a9edc885d69148 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 11 Oct 2026 10:45:55 -0400 Subject: [PATCH 107/142] Issue #989: the lock test finds true on the PATH (macOS has no /bin/true) The spawning thread ran /bin/true, which is /usr/bin/true on macOS, so it never spawned a child and the test failed there. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/tests_lock.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index 1f5dcec24..96a965d45 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -141,7 +141,7 @@ fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { let mut spawned = 0usize; while !stop.load(Ordering::Relaxed) { if let Ok(mut c) = - std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() + std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let _ = c.wait(); spawned += 1; From f0862221c1176394699e289b52d8dc0f9b9b725a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 11 Oct 2026 12:25:59 -0400 Subject: [PATCH 108/142] Issue #989: format the lock test (the shorter command fits on one line) Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/tests_lock.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index 96a965d45..d464d5c25 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -140,8 +140,7 @@ fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { std::thread::spawn(move || { let mut spawned = 0usize; while !stop.load(Ordering::Relaxed) { - if let Ok(mut c) = - std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() + if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let _ = c.wait(); spawned += 1; From 13e803ab07a828b542ea198a008c766b24e8c585 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 20:37:31 +0200 Subject: [PATCH 109/142] LR-IMP-CAMERA-COVERAGE: PEF trims masked lines a little above black, saturated lines outside masked ones, and keeps a lens-cap frame whole (#1271) Masked lines are measured against the outermost line's level when it sits just above BlackPoint (K-70, K-1), saturated outer lines count when dark ones follow (645Z), and a frame whose interior is within the tolerance keeps its whole area. Line means read samples with get(). Render cache version 62: PEF frames change size. --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/pef.rs | 105 +++++++++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// 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/pef.rs b/crates/raw/src/vendor/pef.rs index 60b34d95b..abb928e68 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -167,32 +167,42 @@ pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits Ok(out) } -/// The image area without all-dark leading/trailing columns and rows (masked or empty), kept at even offsets. With +/// The image area without leading/trailing lines that hold no picture (masked or empty), kept at even offsets. With /// the maker-note `black` level, a line is dark when its mean is at most 1/256 of the remaining range (at least 16) -/// above it: masked lines sit at the black level plus noise, while dark real content (a night sky) is well above. -/// The tolerance is the one `cr2::masked_columns` uses. Without it, below 5% of the interior's mean. +/// above it, the tolerance `cr2::masked_columns` uses: masked lines sit at the black level plus noise, while dark +/// real content (a night sky) is well above. Without it, below 5% of the interior's mean. fn dark_trimmed(d: &[u16], w: usize, h: usize, black: Option, white: f32) -> Rect { if w < 256 || h < 256 { return Rect::new(0, 0, w, h); } let (xstep, ystep) = (w / 256, h / 256); - let col_mean = |x: usize| (0..h).step_by(ystep).map(|y| d[y * w + x] as f64).sum::() / h.div_ceil(ystep) as f64; - let row_mean = |y: usize| (0..w).step_by(xstep).map(|x| d[y * w + x] as f64).sum::() / w.div_ceil(xstep) as f64; + let at = |x: usize, y: usize| d.get(y * w + x).map_or(0.0, |&v| f64::from(v)); + let col_mean = |x: usize| (0..h).step_by(ystep).map(|y| at(x, y)).sum::() / h.div_ceil(ystep) as f64; + let row_mean = |y: usize| (0..w).step_by(xstep).map(|x| at(x, y)).sum::() / w.div_ceil(xstep) as f64; let interior = (w / 4..w * 3 / 4).step_by(w / 64).map(col_mean).sum::() / 32.0; - let dark = match black { - Some(b) => b + ((f64::from(white) - b) / 256.0).max(16.0), - None => interior * 0.05, + let (base, tol) = match black { + Some(b) => (b, ((f64::from(white) - b) / 256.0).max(16.0)), + None => (0.0, interior * 0.05), + }; + // a frame no brighter than masked lines (a lens-cap shot) has nothing to tell them from picture + if interior - base <= tol { + return Rect::new(0, 0, w, h); + } + let range = f64::from(white) - base; + // lines from one edge that hold no picture: within `tol` of the outermost line's level when that sits a little + // above black (K-70, K-1: 65 over BlackPoint), and saturated lines when dark ones follow (645Z) + let run = |mean: &dyn Fn(usize) -> f64| { + let saturated = (0..64).take_while(|&k| mean(k) - base >= range * 0.98).count(); + let outer = mean(saturated) - base; + let level = if outer > 0.0 && outer < range * 0.01 { outer } else { 0.0 }; + match (saturated..64).take_while(|&k| mean(k) - base < level + tol).count() { + 0 => 0, + dark => saturated + dark, + } }; let edges = |n: usize, mean: &dyn Fn(usize) -> f64| { - let mut lo = 0; - while lo < 64 && mean(lo) < dark { - lo += 1; - } - let mut hi = n; - while hi > n - 64 && mean(hi - 1) < dark { - hi -= 1; - } - let lo = lo.next_multiple_of(2); + let lo = run(mean).next_multiple_of(2); + let hi = n - run(&|k| mean(n - 1 - k)); (lo, (hi - lo) & !1) }; let (left, width) = edges(w, &col_mean); @@ -502,11 +512,10 @@ mod tests { assert_eq!(img.cfa, Some(Cfa::bayer_static("BGGR"))); } - /// A 512 x 384 big-endian PEF of plain 16-bit words without area tags, whose maker note has only a BlackPoint - /// of `black`, with `px(x, y)` as the samples. - fn pef_with_black_point(black: u16, px: impl Fn(usize, usize) -> u16) -> Vec { + /// A `w x h` big-endian 14-bit PEF of plain 16-bit words without area tags, whose maker note has only a + /// BlackPoint of `black`, with `px(x, y)` as the samples. + fn pef_with_black_point(w: usize, h: usize, black: u16, px: impl Fn(usize, usize) -> u16) -> Vec { use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; - let (w, h) = (512usize, 384usize); let strip: Vec = (0..w * h).flat_map(|i| px(i % w, i / w).to_be_bytes()).collect(); let build = |levels_at: u32| { let mut ifd = IfdBuilder::new(); @@ -537,7 +546,7 @@ mod tests { #[test] fn masked_rows_and_columns_at_the_black_level_are_trimmed_without_crop_tags() { let (w, h) = (512, 384); - let file = pef_with_black_point(256, |x, y| { + let file = pef_with_black_point(w, h, 256, |x, y| { if x >= w - 8 { 0 } else if y >= h - 14 { @@ -558,7 +567,7 @@ mod tests { #[test] fn dark_real_content_next_to_the_masked_border_is_kept() { let (w, h) = (512, 384); - let file = pef_with_black_point(256, |x, y| { + let file = pef_with_black_point(w, h, 256, |x, y| { if x >= w - 8 { 0 } else if y >= h - 14 { @@ -574,4 +583,54 @@ mod tests { let img = decode(&file, Mode::Full).unwrap(); assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); } + + /// The K-70 (and K-1) layout: masked lines 65 DN over BlackPoint 64, just above the tolerance over black, and + /// empty rows at the bottom. + #[test] + fn masked_lines_a_little_above_the_black_level_are_trimmed() { + let (w, h) = (320, 288); + let file = pef_with_black_point(w, h, 64, |x, y| { + if y >= h - 12 { + 0 + } else if x < 56 || y < 28 { + 128 + ((x + y) % 3) as u16 + } else { + 2064 + ((x * 7 + y * 13) % 64) as u16 + } + }); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(56, 28, 264, 248)); + } + + /// The 645Z layout: 12 saturated columns outside 46 masked ones on the right; they hold no picture either. + #[test] + fn saturated_lines_outside_masked_ones_are_trimmed() { + let (w, h) = (400, 288); + let file = pef_with_black_point(w, h, 64, |x, y| { + if x >= w - 12 { + 16316 + } else if x >= w - 58 { + 64 + ((x + y) % 3) as u16 + } else { + 2064 + ((x * 7 + y * 13) % 64) as u16 + } + }); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w - 58, h)); + } + + /// A blown-out edge with the picture right behind it is picture: nothing is trimmed. + #[test] + fn a_saturated_edge_followed_by_picture_stays() { + let (w, h) = (400, 288); + let file = pef_with_black_point(w, h, 64, |x, y| if x >= w - 12 { 16316 } else { 2064 + ((x * 7 + y * 13) % 64) as u16 }); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h)); + } + + /// A frame at the black level everywhere (a lens-cap shot) has no picture to tell masked lines from: it keeps + /// its whole area. + #[test] + fn a_frame_at_the_black_level_keeps_its_whole_area() { + let (w, h) = (320, 288); + let file = pef_with_black_point(w, h, 64, |x, y| 64 + ((x * 7 + y * 13) % 5) as u16); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h)); + } } From dd502bac9aa36149886c22672d215f74d9edfab4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 20:37:31 +0200 Subject: [PATCH 110/142] LR-IMP-CAMERA-COVERAGE: Pentax PEF masked borders in raw-parity, the checklist and what's new (#1271) --- docs/parity-checklist.md | 5 +++-- docs/raw-parity.md | 5 +++-- docs/whats-new.md | 8 ++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..35df35d1c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-CAMERA-COVERAGE: Pentax PEF masked borders trimmed; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Pentax PEFs without area tags lose their masked border lines (issue #1271); status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..10b8bcb38 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Pentax PEFs without area tags: masked border lines trimmed, issue #1271) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -133,7 +133,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | ORF | uncompressed 16-bit, 12-bit packed, two-field 12-bit, 12.8 bpp | ✅ | E-1, E-400, XZ-2 corpus; #651 | | ORF | **compressed** (every Olympus / OM body since ~2008) | ⬜ | `orf.rs` refuses; the E-M1 and E-M10 III corpus files are `KNOWN_UNSUPPORTED` | | RW2 / RWL / RAW | formats 2, 4, 5, 6, 7, 8 and 16-bit words | ✅ | 118 bodies (#215) | -| PEF | uncompressed, Huffman, packed 12-bit | ✅ | K10D, K-3, K-5 IIs | +| PEF | uncompressed, Huffman, packed 12-bit | ✅ | K10D, K-3, K-5 IIs; files without area tags lose their masked, empty and saturated border lines (#1271: KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) | | PEF / DNG | Pentax pixel shift | ⬜ | frames not combined | | SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 | | SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused | @@ -267,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | +| 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..963c5d98d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-12 + +### Pentax raws without black borders +- Raws from the Pentax KP, KF, K-3 Mark III (and Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 and 645Z no longer + show a black (or, on the 645Z, white) band along one or more edges (issue #1271). The sensor's masked border is + left out, so these photos come out a little smaller, closer to the camera's own JPEG. Crops and masks on photos + edited earlier may shift by a few pixels. + ## 2026-10-11 ### JPEG XL export From d41de88c05f965bb7e0b3440ae6c4099e5e222cb Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 21:02:48 +0200 Subject: [PATCH 111/142] LR-IMP-FORMATS: active_rows steps an odd number of rows, so a plateau on red or blue sites alone is found (#1272) --- crates/raw/src/vendor/mod.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 46c401cd5..0d59fd6ed 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -64,9 +64,10 @@ pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range, } /// Samples of up to 512 evenly spaced rows of the active area, for [`white_from_data`]: the borders can hold -/// their own saturated runs (0xffff junk on RW2, masked rows at 16383 on some Canon CR2s). +/// their own saturated runs (0xffff junk on RW2, masked rows at 16383 on some Canon CR2s). The step is odd so both +/// row parities, and with them every CFA site, are read. pub(crate) fn active_rows(data: &[u16], w: usize, a: Rect) -> Vec { - let step = (a.height / 512).max(1); + let step = (a.height / 512).max(1) | 1; (a.y..a.y + a.height).step_by(step).filter_map(|y| data.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() } @@ -142,6 +143,15 @@ mod tests { /// A Canon EOS 70D CR2 saturates at 13583 but has a handful of samples up to 14426 (out of ~20M): the plateau /// is still found instead of falling back to 16383 (≈0.3 EV too dark, highlights never seen as clipped). + /// A plateau on the blue sites alone (odd rows) is still found: an even row step would read one row parity only. + #[test] + fn active_rows_reads_both_row_parities() { + let n = 2048; + let d: Vec = (0..n * n).map(|i| if (i / n) % 2 == 1 && i % 2 == 1 { 15000 } else { (i % 10000) as u16 }).collect(); + let w = white_from_data(&active_rows(&d, n, Rect::new(0, 0, n, n)), 14); + assert!((14900.0..=15000.0).contains(&w), "{w}"); + } + #[test] fn white_plateau_with_strays_above() { let mut d: Vec = (0..100_000).map(|i| (2048 + i % 10000) as u16).collect(); From 8c539426eb1de18958fda6eb686d6d6524c542bf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 21:02:48 +0200 Subject: [PATCH 112/142] Docs: CR2 white level from the active area (#1272), 60D, 450D and 550D affected too --- docs/parity-checklist.md | 5 +++-- docs/raw-parity.md | 7 ++++--- docs/whats-new.md | 7 +++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..1a640b9f9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** patch (LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..bd1874bb3 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** patch (CR2 white level from the active area, #1272) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -109,7 +109,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | Format | Variant | Status | Evidence | |---|---|---|---| -| CR2 | lossless JPEG slices | ✅ | 8 corpus bodies; CFA phase from `CR2CFAPattern` (#85, 35 bodies); masked-column black level | +| CR2 | lossless JPEG slices | ✅ | 8 corpus bodies; CFA phase from `CR2CFAPattern` (#85, 35 bodies); masked-column black level; white level from the active area only (#1272: masked rows at 16383 hid the image plateau on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1), clipped highlights at 81–98%) | | CR2 | sRAW / mRAW (YCbCr) | ✅ | `vendor/cr2/sraw.rs`, 31 samples from 16 models | | CR3 | CRX lossless, version 0x100 | ✅ | M50, R100, R8 exact against a reference; R5 Mark II (odd-height planes) pinned | | CR3 | C-RAW 0x100 (horizontal tiles), 0x200 (single 14-bit tile, adaptive QP incl. the QP-map escape) | ✅ | same 3 bodies exact; R5 Mark II pinned, QP maps of 33,665 files decode exactly, matches the camera JPEG | @@ -171,7 +171,7 @@ sample files. A pointer for contributors, not legal advice. | Foveon | ✅ (older bodies) | — | ⬜ | | Monochrome / LinearRaw | ✅ | handled, no demosaic | ✅ | | Pixel shift (Sony ARQ, Pentax, OM high-res, Nikon) | ✅ (Sony, Pentax, Nikon) | — | ⬜ | -| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) | +| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed); CR2 white level taken from saturated masked rows (#1272, fixed) | | Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | | Lens metadata | ✅ read for profile selection | EXIF lens fields read; used only for the in-file corrections below | 🟡 | | Highlight reconstruction | ✅ | neutral clip, chromaticity diffusion (`highlight.rs`) | 🟡 tuned by eye | @@ -266,6 +266,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..a8657ecd8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-12 + +### Canon CR2 highlights +- Blown highlights in CR2 files from the Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), + 1200D (Rebel T5) and 100D (Rebel SL1) now count as clipped (issue #1272). They used to stop at 81–98% of white, so + highlight recovery missed them and, darkened, they took a colour cast instead of going neutral grey. + ## 2026-10-11 ### JPEG XL export From 07d7d5d49898b66d6dde5fabb57fc074a16ae6a9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 21:05:42 +0200 Subject: [PATCH 113/142] M-export: a resized export keeps the edge it was asked for (fixes #1312) Resize::apply rounds the size, then the render fit the frame into that box again: on a frame a hair under 3:2 the refit gave 1333 x 1.4999999 = 1999.49 and lost a pixel of the long edge. Frame::fit now keeps a box that already has the frame's aspect up to rounding, and output_size rounds the native size to whole pixels first so float noise can't decide a half-pixel tie. --- crates/engine/src/export.rs | 45 ++++++++++++++++++++++++++++++++- crates/pipeline/src/geometry.rs | 5 ++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..1e0df08a2 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1536,9 +1536,11 @@ pub struct Exported { /// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) { let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); + // whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie + let (w, h) = (w.round().max(1.0), h.round().max(1.0)); match &o.resize { Some(r) => r.apply(w, h), - None => ((w.round() as usize).max(1), (h.round() as usize).max(1)), + None => (w as usize, h as usize), } } @@ -2696,6 +2698,47 @@ mod tests { assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1)); } + #[test] + fn resize_size_survives_the_render() { + // the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312) + let cropped = |r: Resize, w: u32, h: u32, x1: f64| { + let mut p = named("IMG.ARW"); + (p.width, p.height) = (w, h); + std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0); + let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() }); + lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh)) + }; + let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0); + let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true }; + // a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5 + let hair = 1.0 - 1e-9; + assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365)); + assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000)); + // 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says + for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] { + for (w, h) in [(w, h), (h, w)] { + for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] { + for r in [ + Resize::long_edge(value as u32), + mode(ResizeMode::ShortEdge, value, 0), + mode(ResizeMode::Width, value, 0), + mode(ResizeMode::Height, value, 0), + mode(ResizeMode::Dimensions, value, 1365), + mode(ResizeMode::Megapixels, value / 1000.0, 0), + mode(ResizeMode::Percent, value / 100.0, 0), + ] { + let want = r.apply(w.into(), h.into()); + assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}"); + } + } + } + } + } + /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. fn p3_green(space: OutputSpace) -> Rgba8 { use lightcraft_color::{DISPLAY_P3, REC2020}; diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index a02de1235..bea792c19 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -182,6 +182,11 @@ impl Frame { pub fn fit(&self, max_w: usize, max_h: usize) -> (usize, usize) { let a = self.aspect(); let (mw, mh) = (max_w.max(1) as f64, max_h.max(1) as f64); + // a box that is already this aspect up to rounding (an export size) is the size: fitting + // into it again rounds twice and can lose a pixel of the edge that was asked for + if (mw - 0.5) / a <= mh + 0.5 && (mw + 0.5) / a >= mh - 0.5 { + return (max_w.max(1), max_h.max(1)); + } let (w, h) = if mw / mh > a { (mh * a, mh) } else { (mw, mw / a) }; ((w.round() as usize).max(1), (h.round() as usize).max(1)) } From c11764a12aaa965b59105f7097213af195ac3c24 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 21:50:24 +0200 Subject: [PATCH 114/142] LR-PROF-CAMERACOLOR: fit the camera toe from dark JPEG tones Use separate luminance pairs for tone fitting so the camera look follows its JPEG into deep shadows. Keep colour pairs and holdouts unchanged. Require enough correlated shadow samples and lower held-out error, with noise and compression floors and the existing median curve fit. Add synthetic regressions, bump look and render cache versions, and record the public multi-make and private numeric validation in the docs. Fixes #1307 Co-Authored-By: Codex --- crates/engine/src/camera_preview.rs | 193 ++++++++++++++++++++++++++-- crates/engine/src/media.rs | 2 +- docs/camera-preview-colour.md | 14 +- docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/whats-new.md | 8 ++ 6 files changed, 210 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..50d439eac 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -25,7 +25,8 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa /// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF /// states, for every make (#778). /// 4: a tone curve from black-and-white camera JPEGs (#577). -pub const LOOK_VERSION: u32 = 4; +/// 5: dark JPEG luminance pairs for the camera toe (#1307). +pub const LOOK_VERSION: u32 = 5; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -569,7 +570,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi if (0.015..=1.0).contains(&y) { bright.push((input.map(f64::from), output.map(f64::from))); } - if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) { + if !colour_pair(*input, *output) { continue; } let min = output.iter().copied().fold(f32::INFINITY, f32::min); @@ -580,6 +581,77 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) } +/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. +fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { + input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) + && output.iter().all(|v| *v > 0.004 && *v < 0.98) + && (0.015..0.85).contains(&luminance_2020(output)) +} + +/// Fit tone independently of colour eligibility when enough dark JPEG signal is available. +/// The original pairs and their split still own the colour fit and its acceptance gates. +/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and +/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise. +fn fit_tone_with_shadows( + fit: ToneFit, + pairs: &[([f64; 3], [f64; 3])], + sensor: &Rgb32f, + reference: &Rgb32f, + edge_limit: Option, + colour: impl Fn([f64; 3]) -> [f64; 3], +) -> Option { + let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y)); + let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect()); + let mut eligible = Vec::new(); + let (mut colour_index, mut extra_index) = (0, 0); + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } + // About one 8-bit sRGB code above black: don't learn a flat black segment from + // JPEG compression. The sensor proxy averages many samples before this test. + if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5) + || !output.iter().all(|v| v.is_finite() && *v >= 0.0) + || !(0.0003..0.85).contains(&luminance_2020(*output)) + { + continue; + } + // A colour holdout must never enter tone training just because the wider sample set + // changes its index. Split the extra samples separately, keeping every colour index. + let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index }; + let held = *index % 3 == 0; + *index += 1; + let (x, y) = luminances(input.map(f64::from), output.map(f64::from)); + if x.is_finite() && x > 0.0 { + eligible.push((x, y, held)); + } + } + let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count(); + let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect(); + // Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank, + // so a single quantized grey also keeps the original curve. + if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION { + return original; + } + let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect(); + let Some(candidate) = fit.fit(training) else { return original }; + let error = |tone: &CameraTone| -> f64 { + held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum() + }; + // A wider histogram must also help independent dark pixels, not just redistribute the + // knots. The usual held-out gates still check the completed look on the colour pairs. + if let Some(old) = original { + let (before, after) = (error(&old), error(&candidate)); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}"); + } + if !after.is_finite() || after >= before * 0.99 { + return original; + } + } + Some(candidate) +} + /// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs. fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { let mut gram = [[0.0; 3]; 3]; @@ -705,9 +777,7 @@ fn fit_pairs_on( let p = matrix.apply(x); correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) }; - let tone_pairs: Vec<_> = - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect(); - let Some(curve) = tone_fit.fit(tone_pairs) else { continue }; + let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue }; let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() }; if let Some(tone) = fit_chroma(&bright, &look) { look.tone = tone; @@ -849,9 +919,8 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) })), None => Mat3::IDENTITY, }; - let tone_pairs: Vec<_> = - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect(); - let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None }; + let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?; + let mut look = CameraLook { matrix, tone, hue_sat: None }; if let Some(tone) = fit_chroma(&bright, &look) { look.tone = tone; } @@ -957,9 +1026,8 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, let p = matrix.apply(x); correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) }; - let tone_pairs: Vec<_> = - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(); - let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() }; + let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?; + let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); @@ -1317,6 +1385,109 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { mod tests { use super::*; + fn steep_toe_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + // A broad scene with a steep camera toe below the old JPEG floor of 0.015. + // Permute brightness so the held-out pixels span the same range independently. + let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25; + let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025]; + *src = rgb.map(|v| v * y / luminance_2020(rgb)); + let display = 3.0 * y * (y / (y + 0.02)).powi(2); + *dst = src.map(|v| v * display / y); + } + (sensor, reference) + } + + #[test] + fn camera_tone_follows_the_jpegs_deep_shadows() { + let (sensor, reference) = steep_toe_scene(); + let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + assert!(dark.len() > 256); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "deep-shadow luminance error {error}"); + assert_eq!(look.matrix, Mat3::IDENTITY); + } + + #[test] + fn colour_holdouts_stay_out_of_the_wider_tone_training() { + let (sensor, reference) = steep_toe_scene(); + let fit = |reference: &Rgb32f| { + let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap(); + fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() + }; + let expected = fit(&reference); + let mut changed = reference.clone(); + let mut index = 0; + for (x, y) in sensor.data.iter().zip(&mut changed.data) { + if colour_pair(*x, *y) { + if index % 3 == 0 { + *y = y.map(|v| v * 1.1); + assert!(colour_pair(*x, *y)); + } + index += 1; + } + } + assert_eq!(fit(&changed), expected); + } + + #[test] + fn camera_toe_is_robust_to_shadow_noise() { + let (sensor, clean) = steep_toe_scene(); + let mut noisy = clean.clone(); + for (i, p) in noisy.data.iter_mut().enumerate() { + if luminance_2020(*p) < 0.015 { + // Quantized JPEG levels with bounded noise and occasional compression outliers. + let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004; + *p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003); + if i % 23 == 0 { + *p = [0.014; 3]; + } + } + } + let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "noisy shadow luminance error {error}"); + assert_eq!(look.tone.apply(0.0), 0.0); + assert_eq!(look.matrix, Mat3::IDENTITY); + for i in 1..1000 { + assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0)); + } + } + + #[test] + fn unreliable_dark_pairs_keep_the_original_tone_and_colour() { + let (sensor, reference) = steep_toe_scene(); + let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect(); + let mut excluded = sensor.clone(); + for &i in &dark { + excluded.data[i] = [f32::NAN; 3]; + } + let colour = Some((Mat3::IDENTITY, None)); + let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap(); + for case in 0..6 { + let (mut x, mut y) = (sensor.clone(), reference.clone()); + for (j, &i) in dark.iter().enumerate() { + match case { + 0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal + 1 => x.data[i] = [0.00001; 3], // below the sensor noise floor + 2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs + 3 => x.data[i] = [0.003; 3], // no scene luminance spread + 4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe + 5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise + _ => {} + } + } + let look = fit_pairs_with(&x, &y, colour.clone()).unwrap(); + assert_eq!(look.tone, original.tone, "case {case}"); + assert_eq!(look.matrix, original.matrix, "case {case}"); + assert!(look.hue_sat.is_none()); + } + } + #[test] fn camera_jpeg_matches_the_sensor_proxy_size_exactly() { // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// 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/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..1feef2e17 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -4,7 +4,7 @@ ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF decoding supply sensor data and c ## Colour and tone are separate -A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. +A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. For colour fitting, near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (5 values × 72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout with an sRGB-encoded value axis, applied in linear ProPhoto RGB after the baseline exposure) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The value axis matters: on a sunny waterfall scene dark yellows had to turn ~12° toward orange while brighter yellow-greens turned ~9° toward green, and one shift per hue made both worse. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels). @@ -22,6 +22,18 @@ One third of the pixels are held out from both fits. The combined model must red **A tone curve from a black-and-white JPEG.** A raw shot in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon Monochrome) records full colour, but its camera JPEG is grey, so neither the full look nor the partial look has colour to fit. Such a photo started from the spectral matrices or the neutral fallback with the default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter than the same body's photos shot in colour (issue #577). The grey JPEG still shows the camera's tone curve. When no look was fitted and every pixel of the JPEG proxy but one in a thousand has its channels within 0.002 of each other (`monochrome`; a sepia-toned JPEG has colour and isn't one), the quantile tone curve is fitted alone, luminance against luminance, on top of the colour the photo gets otherwise: the model's camera profile when it has one (in `fit_preview`, like the profile's colour looks), else its spectral matrices or the neutral fallback's (`monochrome_look`). The chroma curve stays neutral. It has the partial look's checks: the JPEG must rank the pixels like the scene (Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey JPEG relates to nothing), and the curve must cut the held-out squared luminance error below 0.7 of the default curve's, in linear and gamma-encoded values, on all pixels or away from edges. Sony's DRO is handled as for colour JPEGs, and white balance stays relative to the as-shot look. The check reads pixels, not the picture style: a colour JPEG of a scene so colourless that every pixel is grey within 0.002 counts too (such a photo gets no look today either). Photos whose JPEG has colour render as before. +## Deep shadows and the camera toe + +The colour pairs exclude JPEG luminance below 0.015 (about L* 13). Using those same pairs for tone ignored the camera's darkest tones. Below the first knot, `CameraTone::apply` could only interpolate in a straight line to black, lifting shadows (#1307). + +Tone fitting now also tries a separate luminance sample set from the same binned proxies. Each sensor channel must be finite and between 0.0001 and 1.5; JPEG channels must be finite and nonnegative, with luminance between 0.0003 and 0.85. The lower JPEG floor is about one 8-bit sRGB code above black. It leaves compression-clipped black samples out of the fit. Luminance does not need the colour fit's per-channel ratios or its JPEG channel floor. The existing clip mask and, on an attempt away from edges, the same edge exclusion still apply. + +The wider set needs at least 96 dark samples (JPEG luminance below 0.015), including at least 32 held-out dark samples. Two thirds train the curve, with the existing quantile medians or conditional-median fallback. Extra samples are split separately, so the wider set cannot put original colour holdouts into tone training. The held-out dark samples must have luminance rank correlation of at least 0.5, with ties sharing their rank. The candidate must reduce their squared gamma-encoded luminance error by at least 1% against the original curve. Sparse, flat, uncorrelated or invalid shadow samples keep the original curve. The completed look must still pass its existing held-out gates. Colour-pair selection, colour fitting and the original colour training split are unchanged. + +The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 5 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 62 redraws cached renders. + +Compared with main `701f78ea` at a 1200-pixel long edge, all 15 public raw.pixls.us files across six makes and the private #1021 file retain their accepted look. The darkest 10% moves closer to the JPEG on all seven public deep-shadow files (at least 10% below L* 13) and #1021. On Sony RX100 V sample 1324, its mean L* changes from 4.79 to 2.13 against the JPEG's 2.14; on a6700 sample 6735, 7.20 to 5.23 against 4.60. On #1021 (numbers only), 9.28 to 3.25 against 3.50; the 10th percentile changes from 11.94 to 5.34 against 5.45, and mean CIEDE2000 at 200 pixels changes from 7.25 to 6.37. Five of eight controls are byte-identical. The largest control changes are on a7 IV sample 6928, which still has 6.8% below L* 13: mean CIEDE2000 improves by 0.048, whole-frame mean L* falls by 0.149, and the 5th percentile falls by 1.56 L*. This is a limited camera-JPEG comparison, not a claim of colour calibration or Lightroom parity. + ## Source metadata and white balance Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render jobs used header-only facts (including a hard-coded RAW WB). Decoded pixels and source facts now travel and remain cached together, including across web-worker renders. Smart previews keep the tone curve in their header (the matrix is already in their pixels), so an offline original renders and exports with the same look; proxies built before this fall back to the generic curve until rebuilt. Pixel-statistics/colour-sampling commands use decoded facts too. Render cache version 6 invalidates old thumbnails and loupe renders. diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..b4f53da20 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (camera-look deep shadows use the camera JPEG toe, #1307; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -15,7 +15,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| -| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | | 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..06dbd02cd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (camera-look deep shadows use the camera JPEG toe, #1307; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..463d1d98f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-12 + +### Camera-look deep shadows + +- The starting look fitted from a raw's camera JPEG now uses its darkest tones too (issue #1307). + Photos with deep shadows no longer have to extend the first measured tone in a straight line to black. + Existing smart previews are refreshed when their originals are online, and cached renders are redrawn. + ## 2026-10-11 ### JPEG XL export From 1926c9029fb6098f0a5babd860f38ddde79804d6 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Mon, 12 Oct 2026 22:27:31 +0200 Subject: [PATCH 115/142] LR-PROF-CAMERACOLOR: cover the held-out toe error gate Add correlated dark holdouts that reject misleading training and accept matching training. Check finite, monotone curves in both cases. Confirm the regression fails with the error gate removed and passes when restored. Qualify the improvement claim when no original curve can be fitted, and explain the source-look process policy and offline smart preview refresh. Co-Authored-By: Codex --- crates/engine/src/camera_preview.rs | 58 +++++++++++++++++++++++++++++ docs/camera-preview-colour.md | 6 ++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 50d439eac..7f68911e0 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1458,6 +1458,64 @@ mod tests { } } + #[test] + fn camera_toe_requires_lower_independent_dark_error() { + for holdout_matches_original in [true, false] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 }; + *src = [y; 3]; + // Midtones teach the original slope of 3. Dark training teaches 0.3, + // but the independent dark holdout can agree with either slope. + let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 }; + *dst = [y * slope; 3]; + assert_eq!(colour_pair(*src, *dst), i >= 600); + } + let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap(); + let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); + // Both sample sets start at a multiple of three, so this split matches + // the helper's separate colour and extra-sample indices. + let candidate = fit_tone( + sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from)))) + .collect(), + ) + .unwrap(); + let held: Pairs = + sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); + assert_eq!(held.len(), 200); + assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y)))); + assert!(rank_correlation_ties(&held) > 0.99); + let error = |tone: &CameraTone| { + held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::() + }; + assert_ne!(candidate, original); + let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap(); + if holdout_matches_original { + assert!(error(&candidate) > error(&original) + 0.1); + assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve"); + } else { + assert!(error(&candidate) < error(&original) * 0.99); + assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate"); + } + let mut previous = 0.0; + assert_eq!(selected.apply(0.0), 0.0); + for i in 0..10_000 { + let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0)); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + assert_eq!(selected.apply(f32::NAN), 0.0); + assert_eq!(selected.apply(f32::INFINITY), 0.0); + } + } + #[test] fn unreliable_dark_pairs_keep_the_original_tone_and_colour() { let (sensor, reference) = steep_toe_scene(); diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1feef2e17..8c544854b 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -28,10 +28,14 @@ The colour pairs exclude JPEG luminance below 0.015 (about L* 13). Using those s Tone fitting now also tries a separate luminance sample set from the same binned proxies. Each sensor channel must be finite and between 0.0001 and 1.5; JPEG channels must be finite and nonnegative, with luminance between 0.0003 and 0.85. The lower JPEG floor is about one 8-bit sRGB code above black. It leaves compression-clipped black samples out of the fit. Luminance does not need the colour fit's per-channel ratios or its JPEG channel floor. The existing clip mask and, on an attempt away from edges, the same edge exclusion still apply. -The wider set needs at least 96 dark samples (JPEG luminance below 0.015), including at least 32 held-out dark samples. Two thirds train the curve, with the existing quantile medians or conditional-median fallback. Extra samples are split separately, so the wider set cannot put original colour holdouts into tone training. The held-out dark samples must have luminance rank correlation of at least 0.5, with ties sharing their rank. The candidate must reduce their squared gamma-encoded luminance error by at least 1% against the original curve. Sparse, flat, uncorrelated or invalid shadow samples keep the original curve. The completed look must still pass its existing held-out gates. Colour-pair selection, colour fitting and the original colour training split are unchanged. +The wider set needs at least 96 dark samples (JPEG luminance below 0.015), including at least 32 held-out dark samples. Two thirds train the curve, with the existing quantile medians or conditional-median fallback. Extra samples are split separately, so the wider set cannot put original colour holdouts into tone training. The held-out dark samples must have luminance rank correlation of at least 0.5, with ties sharing their rank. When the original curve can be fitted, the candidate must reduce its held-out dark error by at least 1%, measured as squared gamma-encoded luminance error. Sparse, flat, uncorrelated or invalid shadow samples keep the original curve. + +If the original curve cannot be fitted, the wider curve can still be accepted after the sample-count and rank checks. In either case, the completed look must pass its existing held-out gates: a full look needs finite errors, linear-display squared error below 70% of the neutral fallback's and per-channel RMS no greater than 0.10. Partial and monochrome tone-only looks need luminance rank correlation of at least 0.5 and finite squared errors below 70% of the fallback's in both linear and gamma-encoded values. The monochrome comparison uses luminance; the full and partial comparisons use colour channels. Colour-pair selection, colour fitting and the original colour training split are unchanged. The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 5 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 62 redraws cached renders. +No process-version bump is needed under the [rule for changes to source data](process-versions.md#the-rule). The decoded source carries the fitted camera tone in `SourceInfo`; both V1 and V2 use it in `finish::base_tone`. Existing raws edited under V1 or V2 can therefore change when decoded again. Offline smart previews keep their stored pixels and tone until rebuilt from the original. The scan on library opening runs once per look version for each smart previews folder. If an original was offline at that scan, run Build Smart Previews after reconnecting it. + Compared with main `701f78ea` at a 1200-pixel long edge, all 15 public raw.pixls.us files across six makes and the private #1021 file retain their accepted look. The darkest 10% moves closer to the JPEG on all seven public deep-shadow files (at least 10% below L* 13) and #1021. On Sony RX100 V sample 1324, its mean L* changes from 4.79 to 2.13 against the JPEG's 2.14; on a6700 sample 6735, 7.20 to 5.23 against 4.60. On #1021 (numbers only), 9.28 to 3.25 against 3.50; the 10th percentile changes from 11.94 to 5.34 against 5.45, and mean CIEDE2000 at 200 pixels changes from 7.25 to 6.37. Five of eight controls are byte-identical. The largest control changes are on a7 IV sample 6928, which still has 6.8% below L* 13: mean CIEDE2000 improves by 0.048, whole-frame mean L* falls by 0.149, and the 5th percentile falls by 1.56 L*. This is a limited camera-JPEG comparison, not a claim of colour calibration or Lightroom parity. ## Source metadata and white balance From 6a82142d69b156a41e22a5cc96721bac2f47bad9 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:33:21 +0000 Subject: [PATCH 116/142] GH-251: document controller polling and edit interactions Document polling of selection/settings/control ranges and shared edit interactions. Clarify explicit-photo reads, empty selection and navigation, and correct photo-tool ID descriptions and MCP initialization guidance. Partial documentation work for issue 251; hardware feedback remains outside this change. Co-authored-by: Codex --- crates/mcp/src/server.rs | 2 +- crates/mcp/src/tools.rs | 4 +-- docs/control-protocol.md | 72 ++++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 13 +++++++- docs/parity-checklist.md | 4 +-- 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index ba64ef45a..4565db76e 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -25,7 +25,7 @@ const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \ -Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \ +Photos have integer ids; most editing acts on the *active* photo (`set_develop`, `crop` and `export` with an explicit photo id end the current edit interaction, replace the selection with that one photo and make it active; `get_develop` and `render_photo` do not change the selection). \ Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \ render_preview to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \ coordinates 0..1 with the origin at the top-left. Every action is a command: command_list shows ids and parameter \ diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..993e692e9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -182,7 +182,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "get_develop", "Get develop settings", "The full develop settings JSON of a photo (white balance, light, color, curves, mixer, grading, effects, crop, masks, spots…).", - json!({"id": photo_id}), + json!({"id": {"type": "integer", "description": "Photo id (default: the active photo). Reads without changing the selection."}}), &[], ), tool( @@ -216,7 +216,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "Render photo", "Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.", json!({ - "id": photo_id, + "id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"}, "size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"}, "format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"}, "path": {"type": "string", "description": "Also save the rendered file here"} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf648..cdc35b633 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -140,6 +140,78 @@ opens as one. With no library open (the library problem window) it is an error i places (`EngineError::LightroomCatalog`, so `lightcraft-cli --library` and `mcp --library` too). `library.importLightroom {status: true}` reports the catalog's `path` after the task ends as well as while it runs. +## External controllers + +External apps can use this channel to send commands and poll the running desktop app for feedback +(e.g. knob values or LEDs). Start the app with `--control PORT`, then keep a JSON-lines connection to +`127.0.0.1:PORT`. Discover command ids, parameters and current enablement with `engine.commands`; +use control ids and ranges from `develop.controls` rather than translated labels or widget positions. + +| Task | Command through `engine.execute` | Result / effect | +|---|---|---| +| Read selection and active photo | `library.state {}` | `selection: {ids, active}`; `active` is `null` when no photo is active | +| Read photo identity and settings together | `photo.inspect {}` | Active photo's `id`, `develop`, rating, flag and history in one reply | +| Read a specific photo without selecting it | `photo.inspect {id}` / `develop.get {id}` | Photo details / full develop JSON; omitting `id` reads the active photo | +| Read global slider metadata and values | `develop.controls {section?}` | Array of `{id, label, section, min, max, default, step, decimals, track, value}` for the active photo | +| Set absolute slider values | `develop.set {values: {"light.exposure": 0.7}}` | Undoable edit of the active photo; also accepts `{control, value}` | +| Move a relative control | `develop.adjust {control: "light.exposure", delta: 0.1}` | Adds to the current value, clamped to the control's range | +| Change photo | `library.select {ids: [photoId]}` / `library.next {}` / `library.previous {}` | Changes the active photo; next/previous follow the current view's order | +| Undo / redo | `edit.undo {}` / `edit.redo {}` | Restore settings; poll again for feedback | + +With Auto Sync enabled, `develop.set` and `develop.adjust` can also change the other selected photos. +Only changed settings propagate; spot removal, red eye and the rendering process stay per photo. +During an interaction, propagation occurs when it ends. Re-read affected photos after the edit. +`develop.autoSync {}` toggles the mode; it is not a state query. + +For example, send these requests one at a time and read each reply before sending the next: + +```json +{"id":1,"method":"engine.commands"} +{"id":2,"method":"engine.execute","params":{"command":"photo.inspect"}} +{"id":3,"method":"engine.execute","params":{"command":"develop.controls","params":{"section":"light"}}} +{"id":4,"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"light.exposure":0.7}}}} +{"id":5,"method":"engine.execute","params":{"command":"photo.inspect"}} +``` + +Replies use `{id, ok, result}` or `{id, ok: false, error}`. In the photo snapshot, exposure is +`result.develop.light.exposure`; the controls array's matching `light.exposure` entry has `value`, +`min: -5`, `max: 5` and `step: 0.01`. `wb.temp` is Kelvin on raws with colour matrices and relative +(6500 = as shot) elsewhere, as described above. Re-read values after edits to reflect the stored result. +Local mask adjustments are in the photo snapshot's `develop.masks`; use the mask commands from the +registry (e.g. `mask.adjust`) for those, rather than treating global control values as mask values. + +Feedback uses polling: the channel sends replies to requests, with no unsolicited change events. +Compare successive `photo.inspect` snapshots for photo-id or develop-setting changes, including GUI +edits, photo navigation, presets and undo/redo. GUI slider updates are visible while a drag is in +progress; ending a changed interaction commits it as one undo step (unchanged drags add none). +A controller can group `develop.set` / `develop.adjust` updates between +`develop.beginInteraction {label}` and `develop.endInteraction {}`. The GUI and controllers share +one interaction: another `develop.beginInteraction` or `library.select` ends the current one. +Update controller feedback from reads without sending the same values back as new edits. + +Each request is a separate snapshot. `photo.inspect` keeps identity and settings together; a +`develop.controls` reply has no photo id, so do not associate it with an earlier cached selection +when the user is changing photos. Refresh the selection and retry those reads after navigation. +With no active photo, `develop.controls` returns default values and `photo.inspect` / `develop.get` +without `id` return an error; check `library.state` before treating defaults as photo feedback. +Photo ids are local to the current library/session. After reconnecting or opening another library, +discard cached photo mappings and re-read state; matching photo ids alone do not identify the library. +For persistent libraries, poll `library.info` for the current `path` as well. Polling observes the state +at each read, not every intermediate change. + +The same queries and edits are available through the connected CLI: + +```sh +lightcraft-cli run --connect 127.0.0.1:7980 library.state photo.inspect develop.controls section=light +lightcraft-cli run --connect 127.0.0.1:7980 develop.adjust control=light.exposure delta=0.1 photo.inspect +``` + +For MCP, use `lightcraft-cli mcp --connect 127.0.0.1:7980`: `run_command` reaches the commands above, +`list_controls` / `set_develop` provide slider helpers, and `resources/read` polls +`lightcraft://library`, `lightcraft://photo/active`, `lightcraft://develop/active` and +`lightcraft://controls` ([mcp.md](mcp.md#resources)). Headless CLI/MCP mode owns a separate engine +session; use connect mode for feedback from edits in the desktop app. + ## Headless rendering (no window, no GPU) The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by diff --git a/docs/mcp.md b/docs/mcp.md index e1fb07373..e09138cfb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,7 +170,11 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | | `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true | -Tools taking `id` make that photo active first; without it they act on the active photo. +`get_develop {id}` and `render_photo {id}` use that photo without changing the selection. +`set_develop`, `crop` and `export` with a supplied photo `id` end the current edit interaction, +replace the selection with that one photo and make it active. Without `id`, +`get_develop`, `set_develop` and `crop` use the active photo; `render_photo` also falls back to the first +selected photo, and `export` uses the selection for batch export, as above. ### Connect mode only @@ -208,6 +212,13 @@ still reaches every command. | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | +Read these resources again to poll current state; they are snapshots, and `initialize` advertises +`resources.subscribe: false`. `lightcraft://photo/active` includes the photo's `id` and `develop` +settings together; `lightcraft://controls` contains slider values for the active photo at that read. +Separate reads can observe different active photos. For controller feedback from GUI edits, use +connect mode; headless mode owns a separate session. See [External controllers](control-protocol.md#external-controllers) +for command mapping, live slider values and polling semantics. + ## Example session ```text diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..18812b6d6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| **Total** | 408 | 51 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 172. ## Top gaps and shortcut notes From d9006c96f624893d37f54a20fd49a3545dd4d1f1 Mon Sep 17 00:00:00 2001 From: PiMinusMax Date: Mon, 12 Oct 2026 22:37:22 +0200 Subject: [PATCH 117/142] LR-EDIT-COLOR-TEMP: Kelvin white balance from Sony's white-balance presets Sony ARWs of cameras without measured colour matrices were edited on the relative scale (6500 K / 0 = as shot). Their SR2SubIFD stores the multipliers of the camera's presets at 2500, 3200, 4500, 6000 and 8500 K; lightcraft_raw::kelvin_presets reads the as-shot multipliers on the curve through them and they take the spectral matrices' place in the #730 design (as-shot Kelvin and tint only; look, Bradford adaptation and the pixel-identical legacy upgrade unchanged). Covered: ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S. Kelvin-dial shots read back 3400 -> 3396, 5200 -> 5193, 4700 -> 4708, 5600 -> 5594 K; Shade 7493 K. Off-curve constants (isotherm tilt 0.74, tint spacing 1) measured from the 14 Sony bodies with both spectral matrices and presets. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 13 +- crates/engine/src/camera_preview.rs | 40 ++++- crates/engine/src/files.rs | 60 ++++++-- crates/merge/src/output.rs | 1 + crates/raw/src/dng.rs | 1 + crates/raw/src/kelvin_presets.rs | 227 ++++++++++++++++++++++++++++ crates/raw/src/lib.rs | 5 + crates/raw/src/vendor/arw.rs | 16 +- docs/camera-preview-colour.md | 10 +- docs/control-protocol.md | 3 +- docs/lightroom-catalog-import.md | 3 +- docs/parity-checklist.md | 2 +- docs/raw-parity.md | 2 +- docs/whats-new.md | 8 + docs/xmp-interop.md | 2 +- 15 files changed, 359 insertions(+), 34 deletions(-) create mode 100644 crates/raw/src/kelvin_presets.rs diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index abeaadad4..6ceee7982 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -318,12 +318,13 @@ pub fn relative_wb_format(format: &str) -> bool { /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed /// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs): -/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera -/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into -/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730). -/// The engine's render path (`files.rs`) decides the same way from the file; both must agree. +/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor +/// white-balance presets to read Kelvin from. A camera with matrices in `lightcraft_raw::spectral` edits in Kelvin: +/// the matrices turn its as-shot multipliers into a temperature, and the slider back, while its colour look stays +/// whatever the file's fit gives (issue #730). A camera in `lightcraft_raw::kelvin_presets` does the same through +/// its own presets. The engine's render path (`files.rs`) decides the same way from the file; both must agree. pub fn relative_wb_camera(format: &str, camera: &str) -> bool { - relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() + relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera) } impl Photo { @@ -599,6 +600,8 @@ mod edited_tests { ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), ("SONY ILCE-7M2", "ARW", true), + ("SONY ILCA-99M2", "ARW", false), + ("SONY SLT-A33", "ARW", false), ("SONY ILCE-1", "ARW", true), ("", "ARW", true), ("SONY ILCE-7M3", "DNG", false), diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..5bb75a378 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1834,17 +1834,19 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let own = lightcraft_raw::color::has_matrix(&raw.color); - let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some(); + let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); + let measured = lightcraft_raw::spectral::find(make, model).is_some(); + let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); let before = raw.color.clone(); let (_, t, fitted) = starting_colour(&mut raw, &bytes); - let spectral = !own && fitted.is_none() && covered; + let spectral = !own && fitted.is_none() && measured; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); - // white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the - // spectral matrices, whichever look it got (issue #730) + // white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin, + // through the spectral matrices or its presets, whichever look it got (issue #730) assert_eq!(info.relative_wb, !own && !covered, "{name}"); assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); if covered && !own { @@ -2317,6 +2319,36 @@ mod tests { } } + /// Sony ARWs without measured matrices read Kelvin from their white-balance presets + /// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and + /// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its + /// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files. + #[test] + fn corpus_arw_white_balance_in_kelvin_through_the_presets() { + let dir = 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"); + for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); + assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); + assert!( + (info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, + "{name}: {} {}", + info.as_shot_temp, + info.as_shot_tint + ); + let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); + } + } + #[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 c48aaff77..6b2d5e57d 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -508,15 +508,16 @@ fn load_bytes_now( /// /// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without /// (`camera_preview::file_local_look`) has one when its camera's matrices are measured -/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look -/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). +/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one +/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the +/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). struct WbScaleOf { own_matrix: bool, relative: bool, - /// Kelvin through the camera's measured matrices: edited on the relative scale before those were used. + /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. legacy_relative: bool, - /// The as-shot white through the measured matrices, when that is where the scale comes from. - spectral_white: Option, + /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. + scale_white: Option, } impl WbScaleOf { @@ -528,18 +529,26 @@ impl WbScaleOf { ) -> Self { let own_matrix = lightcraft_raw::color::has_matrix(color); let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; - let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten(); - let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers)); - WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white } + let model = meta.model.as_deref().filter(|_| fitted); + let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model)); + let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model)); + let scale_white = match camera { + Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers), + None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers), + None => None, + }; + let covered = camera.is_some() || presets; + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white } } /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through - /// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog - /// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65. + /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the + /// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to + /// `own`, D65. fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { if self.relative { return (6500.0, 0.0); } - let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own)); + let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own)); (t.round(), tint.round()) } } @@ -908,28 +917,49 @@ mod tests { let d65 = lightcraft_color::D65; // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); - assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some()); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); let (t, tint) = a7m3.as_shot(d65); assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); - assert!(bare.legacy_relative && bare.spectral_white.is_none()); + assert!(bare.legacy_relative && bare.scale_white.is_none()); assert_eq!(bare.as_shot(d65), { let (t, tint) = xy_to_temp_tint(d65); (t.round(), tint.round()) }); // a camera without measured matrices: relative, whatever the multipliers let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); - assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none()); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as + // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with = ColorData { kelvin_presets: presets, ..Default::default() }; + let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); + assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); + let (t, tint) = a99m2.as_shot(d65); + assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone + let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); + assert!(other.relative && other.scale_white.is_none()); let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); // a file with matrices of its own (DNG): absolute from them, no matter whose camera let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); - assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none()); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index e020ac45d..8128644b6 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -53,6 +53,7 @@ pub fn color_data(color: &FrameColor) -> ColorData { baseline_exposure: 0.0, baseline_sharpness: None, profile: Default::default(), + kelvin_presets: None, } } } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 27e30002f..6d4987f20 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -121,6 +121,7 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData { // DNG 1.7 BaselineSharpness (IFD 0 or the enhanced IFD); implausible values are ignored baseline_sharpness: get(t::BASELINE_SHARPNESS).and_then(|v| v.first().copied()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 16.0), profile: profile_look(ifd0, raw, order), + kelvin_presets: None, } } diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs new file mode 100644 index 000000000..d4e6f33f3 --- /dev/null +++ b/crates/raw/src/kelvin_presets.rs @@ -0,0 +1,227 @@ +//! A Kelvin scale from the camera's own white-balance presets, for raws whose camera has no colour matrices. +//! +//! Sony ARWs from the DSLR-A200 on store, in their encrypted `SR2SubIFD` (see `vendor::sr2`), the multipliers the +//! camera would apply for each of its white-balance presets, among them five at a stated colour temperature: +//! `WB_RGBLevels2500K` (`0x782d`), `3200K` (`0x782c`), `4500K` (`0x7824`), `6000K` (`0x782b`) and `8500K` (`0x782a`; +//! tag numbers from `exiftool -v3`, run as a black-box tool on the files). They are Sony's own reading of its sensor: +//! the as-shot multipliers placed on the curve through them give the temperature Sony's Kelvin dial names for that +//! white. That is all a Kelvin slider needs; the look stays the file's JPEG fit, and the picture is adapted from the +//! as-shot white with Bradford, as for a camera read through measured matrices (`crate::spectral`, issue #730). +//! +//! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]): +//! - The five presets are joined piecewise linearly, with mired linear along each segment and the end segments +//! extended. On every Sony body measured, mired is nearly linear in arc length along the whole curve (2500 K to +//! 8500 K within ±3 %), so the segments add no visible kinks. +//! - A white off the curve is placed by its perpendicular offset from the nearest segment. Tint is that offset scaled +//! by the segment's own spacing of the Planckian locus in CIE 1960 (u, v), per unit arc length. +//! - The isotherms are not perpendicular to the curve in these coordinates: an offset also changes mired by +//! [`ISOTHERM_TILT`] times the mired the same distance along the curve would. +//! +//! The two constants come from the 14 Sony bodies that have both measured matrices and the presets (one CC0 +//! raw.pixls.us sample each for the presets, a grid of multipliers through `crate::spectral` for the matrices): +//! the tilt measured 0.59–0.84 (mean 0.74), the tint spacing 0.90–1.04 of the locus's (mean 0.98, taken as 1). +//! On 150 ILCE-7SM2 photos read both ways, the temperatures agree to ±4 mired around a constant offset (Sony's +//! Kelvin is about 8 % above the matrices', as #730 found for the ILCE-7M3's dial) and the tint to −9 ± 4: the +//! matrices put Sony's presets slightly off the locus, the presets themselves are tint 0 here. +use crate::ColorData; +use lightcraft_color::{Xy, cct}; +use serde::{Deserialize, Serialize}; + +/// Mired change per unit offset from the preset curve, in units of the mired change per unit distance along it. +pub const ISOTHERM_TILT: f64 = 0.74; + +/// The slider's range: an as-shot reading is kept inside it. +const MIN_K: f64 = 2000.0; +const MAX_K: f64 = 50000.0; +const MAX_TINT: f64 = 150.0; + +/// Models whose white balance is edited in Kelvin through their presets, checked to carry them (on 80–150 own +/// photos each, or the public corpus samples for the ILCE-3500 and ILCE-7S): the Exif model as Sony writes it. A +/// camera with measured matrices (`crate::spectral`) is read through those instead. +const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"]; + +/// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour +/// matrices of its own or measured: see [`crate::spectral::find`], which comes first). +pub fn covers(make: Option<&str>, model: &str) -> bool { + let model = model.trim(); + let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model); + make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY"))) + && MODELS.iter().any(|m| m.eq_ignore_ascii_case(model)) +} + +/// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). +pub fn covers_camera(camera: &str) -> bool { + match camera.split_once(char::is_whitespace) { + Some((make, model)) => covers(Some(make), model), + None => covers(None, camera), + } +} + +/// A camera's multipliers at five stated temperatures, ascending, green = 1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct KelvinPresets { + points: [(f32, [f32; 3]); 5], +} + +fn usable(m: [f32; 3]) -> bool { + m.iter().all(|v| *v > 0.0 && v.is_finite()) +} + +fn log_point(m: [f32; 3]) -> [f64; 2] { + [(m[0] as f64 / m[1] as f64).ln(), (m[2] as f64 / m[1] as f64).ln()] +} + +fn locus_uv(t: f64) -> (f64, f64) { + cct::xy_to_uv(cct::temp_tint_to_xy(t, 0.0)) +} + +impl KelvinPresets { + /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive + /// multipliers, red rising and blue falling from one preset to the next. + pub fn new(points: [(f32, [f32; 3]); 5]) -> Option { + let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) + && points.windows(2).all(|w| { + let (a, b) = (log_point(w[0].1), log_point(w[1].1)); + w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1] + }); + ok.then(|| KelvinPresets { points: points.map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])) }) + } + + /// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable + /// multipliers. + pub fn temp_tint(&self, wb_multipliers: [f32; 3]) -> Option<(f64, f64)> { + if !usable(wb_multipliers) { + return None; + } + let q = log_point(wb_multipliers); + let last = self.points.len() - 2; + let mut best: Option<(f64, f64, f64)> = None; + for (i, w) in self.points.windows(2).enumerate() { + let ((k0, m0), (k1, m1)) = (w[0], w[1]); + let (p0, p1) = (log_point(m0), log_point(m1)); + let d = [p1[0] - p0[0], p1[1] - p0[1]]; + let len = d[0].hypot(d[1]); + let t = [d[0] / len, d[1] / len]; + // the normal towards higher red and blue: a green white + let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] }; + let r = [q[0] - p0[0], q[1] - p0[1]]; + let along = (r[0] * t[0] + r[1] * t[1]) / len; + let off = r[0] * n[0] + r[1] * n[1]; + let lo = if i == 0 { f64::NEG_INFINITY } else { 0.0 }; + let hi = if i == last { f64::INFINITY } else { 1.0 }; + let c = along.clamp(lo, hi); + let dist = (r[0] - c * d[0]).hypot(r[1] - c * d[1]); + let (mired0, mired1) = (1e6 / k0 as f64, 1e6 / k1 as f64); + let mired_per_len = (mired1 - mired0) / len; + let ((u0, v0), (u1, v1)) = (locus_uv(k0 as f64), locus_uv(k1 as f64)); + let tint_per_len = cct::TINT_SCALE * (u1 - u0).hypot(v1 - v0) / len; + let mired = mired0 + along * (mired1 - mired0) + ISOTHERM_TILT * mired_per_len * off; + if best.is_none_or(|b| dist < b.0) { + best = Some((dist, mired, tint_per_len * off)); + } + } + let (_, mired, tint) = best?; + let t = 1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K); + Some((t, tint.clamp(-MAX_TINT, MAX_TINT))) + } + + /// The as-shot white of a file through its presets: [`Self::temp_tint`] of its multipliers as a chromaticity. + pub fn as_shot_white(&self, wb_multipliers: Option<[f32; 3]>) -> Option { + let (t, tint) = self.temp_tint(wb_multipliers?)?; + Some(cct::temp_tint_to_xy(t, tint)) + } +} + +/// The as-shot white of a file through its own presets ([`ColorData::kelvin_presets`]); `None` without presets or +/// usable multipliers. +pub fn as_shot_white(color: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Option { + color.kelvin_presets.as_ref()?.as_shot_white(wb_multipliers) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The ILCA-99M2's presets (every file of the body carries the same). + fn a99m2() -> KelvinPresets { + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]) + .unwrap() + } + + #[test] + fn a_preset_reads_as_its_own_temperature_with_no_tint() { + let p = a99m2(); + for (k, m) in p.points { + let (t, tint) = p.temp_tint(m).unwrap(); + assert!((t - k as f64).abs() < 0.5 && tint.abs() < 1e-6, "{k}: {t} {tint}"); + // the scale of the multipliers doesn't matter, only their ratios to green + let (t2, tint2) = p.temp_tint(m.map(|v| v * 2.3)).unwrap(); + assert!((t2 - t).abs() < 1e-3 && (tint2 - tint).abs() < 1e-4, "{t2} {tint2}"); + } + } + + #[test] + fn between_presets_the_temperature_follows_the_curve() { + let p = a99m2(); + let mid = |a: [f32; 3], b: [f32; 3]| [(a[0] * b[0]).sqrt(), 1.0, (a[2] * b[2]).sqrt()]; + let (t, tint) = p.temp_tint(mid(p.points[2].1, p.points[3].1)).unwrap(); + assert!((4500.0..6000.0).contains(&t) && tint.abs() < 1.0, "{t} {tint}"); + // beyond the ends: extended, then held to the slider's range + let (hot, _) = p.temp_tint([3.5, 1.0, 1.15]).unwrap(); + let (cold, _) = p.temp_tint([1.15, 1.0, 4.0]).unwrap(); + let (coldest, _) = p.temp_tint([0.3, 1.0, 20.0]).unwrap(); + assert!((8500.0..MAX_K).contains(&hot) && (MIN_K..2500.0).contains(&cold) && coldest == MIN_K, "{hot} {cold} {coldest}"); + } + + #[test] + fn less_green_in_the_multipliers_is_a_green_white_and_a_magenta_correction() { + let p = a99m2(); + let day = [2680.0 / 1024.0, 1.0, 1544.0 / 1024.0]; + let (t0, _) = p.temp_tint(day).unwrap(); + // a green light: the camera raises red and blue against green + let (t1, tint1) = p.temp_tint([day[0] * 1.08, 1.0, day[2] * 1.08]).unwrap(); + assert!(tint1 > 15.0 && tint1 < 40.0, "{tint1}"); + // the tilted isotherm: a higher temperature than the curve point it sits beside + assert!(t1 > t0 + 100.0, "{t1} {t0}"); + let (_, magenta) = p.temp_tint([day[0] / 1.08, 1.0, day[2] / 1.08]).unwrap(); + assert!(magenta < -15.0, "{magenta}"); + } + + #[test] + fn unusable_presets_or_multipliers_give_nothing() { + let p = a99m2(); + assert_eq!(p.temp_tint([0.0, 1.0, 1.0]), None); + assert_eq!(p.temp_tint([f32::NAN, 1.0, 1.0]), None); + assert_eq!(p.as_shot_white(None), None); + let mut pts = p.points; + pts.swap(1, 2); + assert_eq!(KelvinPresets::new(pts), None, "out of order"); + let mut pts = p.points; + pts[4].1 = pts[3].1; + assert_eq!(KelvinPresets::new(pts), None, "no step"); + let mut pts = p.points; + pts[0].1[0] = 0.0; + assert_eq!(KelvinPresets::new(pts), None); + assert_eq!(as_shot_white(&ColorData::default(), Some([2.0, 1.0, 1.5])), None); + let with = ColorData { kelvin_presets: Some(p), ..Default::default() }; + assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some()); + } + + #[test] + fn covered_models() { + for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] { + assert!(covers_camera(c), "{c}"); + } + for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] { + assert!(!covers_camera(c), "{c}"); + } + assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2")); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9edb..7bb1d85a0 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -34,6 +34,7 @@ pub mod gaintable; pub mod highlight; #[cfg(feature = "jxl")] mod jxl; +pub mod kelvin_presets; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; @@ -598,6 +599,10 @@ pub struct ColorData { /// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`). #[serde(default)] pub profile: profile::ProfileLook, + /// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour + /// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it. + #[serde(default)] + pub kelvin_presets: Option, } /// A decoded raw image. diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index e8c766004..ba7c3356c 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -62,6 +62,9 @@ const CROP_SIZE: u16 = 0x74c8; const SR2_OLD_BLACK: u16 = 0x7300; const SR2_OLD_WB: u16 = 0x7303; const SR2_OLD_CROP: u16 = 0x74c3; +/// `SR2SubIFD` white-balance presets at a stated temperature (`WB_RGBLevels2500K` … `8500K`: R, G, B), which give +/// the Kelvin scale of a camera without colour matrices (`crate::kelvin_presets`). +const SR2_KELVIN_PRESETS: [(f32, u16); 5] = [(2500.0, 0x782d), (3200.0, 0x782c), (4500.0, 0x7824), (6000.0, 0x782b), (8500.0, 0x782a)]; const YCBCR_COEFFICIENTS: u16 = 529; const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). @@ -478,6 +481,16 @@ fn sr2_old_wb(sr2: &super::sr2::SubIfd) -> Option<[f32; 3]> { } /// The DSC-R1's crop from `0x74c3` (x0, y0, x1, y1): an even origin (CFA phase) and a window inside the `w × h` frame. +/// The five presets of [`SR2_KELVIN_PRESETS`], when the block has them all and they make a curve. +fn sr2_kelvin_presets(sr2: &super::sr2::SubIfd) -> Option { + let mut points = [(0.0f32, [0.0f32; 3]); 5]; + for (p, (k, tag)) in points.iter_mut().zip(SR2_KELVIN_PRESETS) { + let v = sr2.shorts(tag).filter(|v| v.len() == 3)?; + *p = (k, [v[0] as f32, v[1] as f32, v[2] as f32]); + } + crate::kelvin_presets::KelvinPresets::new(points) +} + fn sr2_old_crop(sr2: &super::sr2::SubIfd, w: usize, h: usize) -> Option { let v = sr2.shorts(SR2_OLD_CROP).filter(|v| v.len() == 4)?; let [x0, y0, x1, y1] = [v[0] as usize, v[1] as usize, v[2] as usize, v[3] as usize]; @@ -728,7 +741,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: Rect::new(0, 0, w, h), crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + // (linear YCbCr comes white-balanced: its unit multipliers have no place on the presets' curve) + color: ColorData { kelvin_presets: if linear_rgb { None } else { sr2().and_then(sr2_kelvin_presets) }, ..Default::default() }, // Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..f82c2c084 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -28,7 +28,9 @@ Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. -**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative. +**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative, unless their own white-balance presets give the scale (next). + +**Kelvin through the camera's white-balance presets.** Sony ARWs store, in the encrypted `SR2SubIFD`, the multipliers of every white-balance preset, five of them at a stated temperature (2500, 3200, 4500, 6000 and 8500 K). For a camera without measured matrices whose files carry them (`lightcraft_raw::kelvin_presets`: Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S) they take the matrices' place in everything above: the as-shot multipliers are placed on the curve through the five presets (mired linear along it, in log red/green and blue/green), an offset from the curve is the tint, and the decision, the Bradford adaptation, the pixel-identical upgrade and `Op::SetAsShotWb` are the same. The scale is Sony's own: files shot on the Kelvin setting read back what the dial said (ILCA-77M2 3400 K → 3396 K and 5200 K → 5193 K, SLT-A33 4700 K → 4708 K, the public ILCE-3500 sample's 5600 K → 5594 K; with the colour filter one step towards magenta, the SLT-A33's 3400 K reads 3473 K / +11), and the Shade preset reads 7493 K on the public ILCE-7S sample. Off the curve, two constants come from the 14 Sony bodies that have both measured matrices and presets: the tint is the offset scaled by the locus's own spacing (the matrices give 0.90–1.04 of it), and the isotherms lean against the curve's normal (`ISOTHERM_TILT` 0.74, measured 0.59–0.84). On 150 ILCE-7SM2 photos read both ways, the presets' temperature tracks the matrices' to ±4 mired around Sony's 8 % higher scale, and the tint to −9 ± 4. A listed camera whose file has no presets keeps the Kelvin scale with As Shot at D65, as a covered camera without multipliers does. Bodies with measured matrices keep reading through them. What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). @@ -130,9 +132,9 @@ When the file's JPEG is black and white, step 2 fits the tone curve alone with t The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the gates or the file has no usable JPEG. White balance of a covered model is in Kelvin through its matrices whichever -look it got (above, issue #730); for every other raw without matrices of its own it stays relative to the as-shot -look (`Photo::relative_wb`). `lightcraft-cli calibrate` still pools the uncorrected camera colour of covered -models, so a profile can be fitted for them. +look it got (above, issue #730), and so is that of a model whose presets give the scale; for every other raw +without matrices of its own it stays relative to the as-shot look (`Photo::relative_wb`). `lightcraft-cli calibrate` +still pools the uncorrected camera colour of covered models, so a profile can be fitted for them. No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, is recorded here yet. Whether they should come before the file-local fit for these models is open until one is. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf648..ccbb97690 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -81,7 +81,8 @@ filmstrip is visible. White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on raws with colour matrices — in the file (DNG) or measured for the camera model (`lightcraft_raw::spectral`; issue -#730) — where As Shot reads the file's as-shot white; rendered files and raws without matrices use a relative scale +#730) — and on raws whose white-balance presets give the scale (`lightcraft_raw::kelvin_presets`), where As Shot +reads the file's as-shot white; rendered files and raws without matrices use a relative scale whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge` or a preset whose `wb` sets `temp` / `tint` **without** `scale` keeps the relative meaning it always had (6500 = as shot) on every raw, so agents that mean diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 878603e01..1f9deeea4 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -52,7 +52,8 @@ RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvi as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported (issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). A raw whose camera has -measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…) edits in Kelvin +measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…), or whose white-balance +presets give a Kelvin scale (`lightcraft_raw::kelvin_presets`: ILCA-99M2, ILCA-77M2…), edits in Kelvin and keeps the Kelvin value too (issue #730) — when its file was found and probed, since the camera is read from the file, not from the Lightroom catalog; a missing file is treated as a relative-scale raw until Reload reads it. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..2d91095b2 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -190,7 +190,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/raw/src/kelvin_presets.rs`, `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..47beec67e 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -188,7 +188,7 @@ sample files. A pointer for contributors, not legal advice. | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | -| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730); other raws keep the relative scale (#296) | +| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where Sony's white-balance presets in the file give the scale (6 bodies, `kelvin_presets`); other raws keep the relative scale (#296) | **How far the default rendering is from Adobe's** (CIE ΔE; measured where stated): diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..902075944 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-12 + +### Kelvin white balance for more Sony cameras +- Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 and ILCE-7S raws now show white balance in Kelvin too, + read from the white-balance presets the camera stores in every ARW: a photo shot at 3400 K on the camera reads + 3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative + scale keep their look, and As Shot shows the real temperature once the file is opened. + ## 2026-10-11 ### JPEG XL export diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d1199..6a34359bc 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -114,7 +114,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 | | `HDREditMode` | `hdr.enabled` | `1` / `True` = HDR editing on, `0` / `False` off | | `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom | -| `Temperature`, `Tint` | `wb.temp`, `wb.tint` (+ `wb.scale: kelvin`) | Kelvin / tint as written for raws with colour matrices — in the file (DNG) or measured for the camera (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…, issue #730) — and for presets; the mapped settings say `"scale": "kelvin"` so the value stays Kelvin wherever it lands. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW of other cameras: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510). Note that LightCraft's Kelvin for a camera comes from its measured matrices, Lightroom's from Adobe's: the two scales differ (about 7–10 % on the ILCE-7M3), so the number is carried over, not the exact look | +| `Temperature`, `Tint` | `wb.temp`, `wb.tint` (+ `wb.scale: kelvin`) | Kelvin / tint as written for raws with colour matrices — in the file (DNG) or measured for the camera (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…, issue #730) or read from its white-balance presets (`lightcraft_raw::kelvin_presets`: ILCA-99M2, ILCA-77M2…) — and for presets; the mapped settings say `"scale": "kelvin"` so the value stays Kelvin wherever it lands. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW of other cameras: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510). Note that LightCraft's Kelvin for a camera comes from its measured matrices, Lightroom's from Adobe's: the two scales differ (about 7–10 % on the ILCE-7M3), so the number is carried over, not the exact look | | `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider); a rendered file with only `Temperature` shifts from `AsShotTemperature` like the raws above when it is present | | `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 | | `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 | From 5eee65edac9fabf6f8b3e0de09426a49092ec6fd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:48:34 +0200 Subject: [PATCH 118/142] ORF: black level from the main maker note for bodies without ImageProcessing; gate and bound the 0x1017/0x1018 gains (part of #1273) --- crates/raw/src/vendor/orf.rs | 129 ++++++++++++++++++++++++++++------- docs/parity-checklist.md | 3 +- docs/raw-parity.md | 1 + docs/whats-new.md | 6 ++ 4 files changed, 114 insertions(+), 25 deletions(-) diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index a83d03684..ea7f30eb7 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -19,8 +19,7 @@ //! six CC0 files (C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ, SP-570UZ). Their Exif `CFAPattern` states the //! layout (it matches the embedded thumbnails), the maker note's entries `0x1017`/`0x1018` hold the red and //! blue gain (first value, 256 = 1.0; checked against neutral areas of the thumbnail), and nothing states a -//! black level or an active area. The E-10 (16-bit words) has no ImageProcessing sub-directory either and keeps -//! its gains in the same two entries. +//! black level or an active area. //! - 12-bit blocks (E-300, E-500, E-330; the E-M5 Mark II and PEN-F high-resolution files): one strip of 12.8 bits //! per pixel, `width * 8 / 5` bytes per row, made of 16-byte blocks. A block is 15 sample bytes followed by one //! pad byte that is always zero; the 15 bytes are a little-endian 120-bit string holding ten 12-bit samples, sample @@ -55,6 +54,8 @@ const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; const MN_WB_RED: u16 = 0x1017; const MN_WB_BLUE: u16 = 0x1018; +const MN_BLACK: u16 = 0x1012; +const MN_VALID_BITS: u16 = 0x102c; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; /// The Olympus maker note. @@ -76,6 +77,17 @@ pub(crate) fn sub_ifd(bytes: &[u8], mn: &MakerNote, tag: u16) -> Option { lightcraft_tiff::parse_ifd_at(bytes, at, mn.order, mn.base, false, &opts).ok().map(|(i, _)| i) } +/// Black level of a body without ImageProcessing: the main note's `0x1012` BlackLevel (the mean of its four values) +/// in `0x102c` ValidBits units, scaled to the decoded `bits`. The E-10 states 32 at 10 bits, and its 12-bit samples +/// bottom out at 124 to 132; the two-field compacts state 0 at 12 bits. +fn maker_note_black(m: &MakerNote, bits: u32) -> Option { + let bits = bits.min(16); + let v = m.ifd.f64s(MN_BLACK).filter(|v| v.len() == 4)?; + let valid = m.ifd.u64(MN_VALID_BITS).and_then(|b| u32::try_from(b).ok()).filter(|b| (8..=bits).contains(b))?; + let level = v.iter().sum::() / 4.0 * f64::from(1u32 << (bits - valid)); + (level >= 0.0 && level < f64::from(1u32 << bits) / 4.0).then_some(level as f32) +} + /// Unpack one row of 12-bit samples stored as little-endian 32-bit words read MSB-first. pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { let swapped: Vec = src @@ -196,6 +208,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let chunks = info.chunks(bytes.len() as u64); let total: u64 = chunks.iter().map(|c| c.len).sum(); let stated = cfa_from_exif(&tiff); + let mut two_field = false; let (mut data, bits) = if info.compression != 1 { return Err(RawError::Unsupported(format!("ORF compression {}", info.compression))); } else if total >= (n as u64) * 2 { @@ -232,6 +245,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .then(|| field_rows(&info.offsets.iter().copied().zip(info.byte_counts.iter().copied()).collect::>(), w, h, bytes.len())) .flatten() { + two_field = true; let d = if mode == Mode::Full || stated.is_none() { let (rb, h0) = (w * 3 / 2, h.div_ceil(2)); let mut d = vec![0u16; n]; @@ -281,7 +295,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2.0, v[3]][c as usize] as f32).collect(); BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() } } - _ => BlackLevel::uniform(0.0), + _ => BlackLevel::uniform(mn.as_ref().filter(|_| ip.is_none()).and_then(|m| maker_note_black(m, bits)).unwrap_or(0.0)), }; let wb = ip .as_ref() @@ -289,9 +303,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .filter(|v| v.len() >= 2 && v[0] > 0.0 && v[1] > 0.0) .map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32]) .or_else(|| { - // two-field bodies and the E-10: the red and blue gains lead the entries of the main maker note - let m = mn.as_ref()?; - let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 0.0); + // the red and blue gains lead the main note's entries 0x1017/0x1018 on the two-field compacts and on + // the E-10 (16-bit words, no ImageProcessing; 384 and 406 on its CC0 file 5424) + let m = mn.as_ref().filter(|_| two_field || ip.is_none())?; + let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 25.6 && *g < 2560.0); Some([(g(MN_WB_RED)? / 256.0) as f32, 1.0, (g(MN_WB_BLUE)? / 256.0) as f32]) }); let white = white_from_data(samples, bits); @@ -372,36 +387,102 @@ mod tests { b } - /// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) holding two-short - /// entries, whose values fit inline. - fn olymp_note(entries: &[(u16, [u16; 2])]) -> Vec { - let mut b = b"OLYMP\0\x01\0".to_vec(); + /// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) of SHORT (type 3) or + /// UNDEFINED (type 7) entries. Values longer than four bytes follow the IFD; their offsets count from the TIFF + /// header, so `at` is where the note sits in the file. + fn olymp_note(at: usize, entries: &[(u16, u16, Vec)]) -> Vec { + let ifd_end = 8 + 2 + 12 * entries.len() + 4; + let (mut b, mut extra) = (b"OLYMP\0\x01\0".to_vec(), Vec::new()); b.extend_from_slice(&(entries.len() as u16).to_le_bytes()); - for (tag, [v0, v1]) in entries { + for (tag, typ, value) in entries { b.extend_from_slice(&tag.to_le_bytes()); - b.extend_from_slice(&3u16.to_le_bytes()); - b.extend_from_slice(&2u32.to_le_bytes()); - b.extend_from_slice(&v0.to_le_bytes()); - b.extend_from_slice(&v1.to_le_bytes()); + b.extend_from_slice(&typ.to_le_bytes()); + b.extend_from_slice(&(if *typ == 3 { value.len() / 2 } else { value.len() } as u32).to_le_bytes()); + if value.len() <= 4 { + let mut v = value.clone(); + v.resize(4, 0); + b.extend_from_slice(&v); + } else { + b.extend_from_slice(&((at + ifd_end + extra.len()) as u32).to_le_bytes()); + extra.extend_from_slice(value); + } } b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&extra); b } + /// SHORT values, little-endian. + fn shorts(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() + } + + /// A 16 × 4 ORF of 12-bit samples in 16-bit words with an [`olymp_note`] of `entries`, decoded. + fn decode_noted(entries: &[(u16, u16, Vec)]) -> RawImage { + let (w, h) = (16u32, 4u32); + let words: Vec = (0..w * h).flat_map(|i| (((200 + i * 37) % 4096) as u16).to_le_bytes()).collect(); + let file = |at| orf_with_note(w, h, 16, words.clone(), Some(RGGB), Some(olymp_note(at, entries))); + let at = file(0).windows(6).position(|b| b == b"OLYMP\0").unwrap(); + let bytes = file(at); + assert_eq!(bytes.windows(6).position(|b| b == b"OLYMP\0"), Some(at)); + crate::decode(&bytes).unwrap() + } + + /// An ImageProcessing sub-directory stored inline (old-style note) with one unrelated entry and no levels. + fn bare_image_processing() -> (u16, u16, Vec) { + let mut ifd = 1u16.to_le_bytes().to_vec(); + ifd.extend_from_slice(&[0, 0, 3, 0, 1, 0, 0, 0, 1, 0, 0, 0]); + ifd.extend_from_slice(&0u32.to_le_bytes()); + (IMAGE_PROCESSING, 7, ifd) + } + /// Issue #1273: the E-10 (16-bit words, no ImageProcessing sub-directory) keeps its red and blue gains in /// maker-note entries `0x1017`/`0x1018`, as the two-field compacts do. #[test] fn as_shot_white_balance_from_the_main_maker_note() { - let (w, h) = (16usize, 4usize); - let words: Vec = (0..w * h).flat_map(|i| (((100 + i * 37) % 4096) as u16).to_le_bytes()).collect(); - let wb = |entries: &[(u16, [u16; 2])]| { - crate::decode(&orf_with_note(w as u32, h as u32, 16, words.clone(), Some(RGGB), Some(olymp_note(entries)))).unwrap().wb_multipliers + let wb = |red: u16, blue: Option| { + let mut e = vec![(MN_WB_RED, 3, shorts(&[red, 64]))]; + e.extend(blue.map(|b| (MN_WB_BLUE, 3, shorts(&[b, 64])))); + decode_noted(&e).wb_multipliers }; - assert_eq!(wb(&[(MN_WB_RED, [384, 64]), (MN_WB_BLUE, [406, 64])]), Some([1.5, 1.0, 406.0 / 256.0])); - // both gains are needed, and a zero gain is none - assert_eq!(wb(&[(MN_WB_RED, [384, 64])]), None); - assert_eq!(wb(&[(MN_WB_RED, [0, 64]), (MN_WB_BLUE, [406, 64])]), None); - assert_eq!(wb(&[]), None); + assert_eq!(wb(384, Some(406)), Some([1.5, 1.0, 406.0 / 256.0])); + // both gains are needed, within 0.1 to 10 (25.6 to 2560 in 256 units) + assert_eq!(wb(384, None), None); + assert_eq!(wb(0, Some(406)), None); + assert_eq!(wb(25, Some(406)), None); + assert_eq!(wb(384, Some(2560)), None); + assert_eq!(wb(27, Some(2559)), Some([27.0 / 256.0, 1.0, 2559.0 / 256.0])); + assert_eq!(decode_noted(&[]).wb_multipliers, None); + // a body with ImageProcessing but without its levels doesn't fall back to these entries + let e = [bare_image_processing(), (MN_WB_RED, 3, shorts(&[384, 64])), (MN_WB_BLUE, 3, shorts(&[406, 64]))]; + assert_eq!(decode_noted(&e).wb_multipliers, None); + } + + /// Issue #1273: a body without ImageProcessing states its black level in the main note, `0x1012` (four + /// values) in `0x102c` ValidBits units: the E-10's 32 at 10 bits is 128 on its 12-bit samples. The two-field + /// compacts state 0 at 12 bits. + #[test] + fn black_level_from_the_main_maker_note() { + let black = |level: u16, valid: Option| { + let mut e = vec![(MN_BLACK, 3, shorts(&[level; 4]))]; + e.extend(valid.map(|v| (MN_VALID_BITS, 3, shorts(&[v, 0])))); + let r = decode_noted(&e); + assert_eq!(r.bits, 12); + r.black.values + }; + assert_eq!(black(32, Some(10)), vec![128.0]); + assert_eq!(black(0, Some(12)), vec![0.0]); + assert_eq!(black(32, Some(12)), vec![32.0]); + // the four values are averaged + let e = [(MN_BLACK, 3, shorts(&[30, 32, 32, 34])), (MN_VALID_BITS, 3, shorts(&[10, 0]))]; + assert_eq!(decode_noted(&e).black.values, vec![128.0]); + // no ValidBits, ValidBits above the samples' depth, a level of a quarter of the range or more: no black + assert_eq!(black(32, None), vec![0.0]); + assert_eq!(black(32, Some(14)), vec![0.0]); + assert_eq!(black(256, Some(10)), vec![0.0]); + // ImageProcessing present (without BlackLevel2): the main note isn't read + let e = [bare_image_processing(), (MN_BLACK, 3, shorts(&[32; 4])), (MN_VALID_BITS, 3, shorts(&[10, 0]))]; + assert_eq!(decode_noted(&e).black.values, vec![0.0]); } /// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy). diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..be6e59e39 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Olympus E-10 as-shot white balance and black level from the main maker note (issue #1273); status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..4eb4d742a 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -266,6 +266,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Olympus E-10: as-shot white balance (`0x1017`/`0x1018`) and black level (`0x1012` in `0x102c` ValidBits units) from the main maker note, issue #1273; ΔE76 against its thumbnail 18.5 → 11.5, camera-look fit now accepted; counts unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..9d6e404eb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-12 + +### Older raw cameras +- Olympus E-10 raws open with the camera's white balance (White Balance ▸ As Shot) and without the grey haze in + the shadows: the black level recorded in the file is now subtracted. + ## 2026-10-11 ### JPEG XL export From 3480c99b3ed7f06b88b3493134f5bc6303ff4e04 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:58:13 +0200 Subject: [PATCH 119/142] macOS: closing the window ends the process in on_exit, as menu Quit does (fixes #1331) The close button let eframe drop the window while AppKit's run loop was still running; AccessKit restored the content view's class and AppKit's Touch Bar KVO observer threw on the next display cycle. on_exit now ends the process on macOS once settings and library are saved and background work has stopped, as #444 does for Quit from the menu bar. Same fix as storytold/gridcraft#117. --- apps/lightcraft/src/main.rs | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index c9b9696e1..b206c58f1 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -87,6 +87,16 @@ impl eframe::App for App { if !self.0.shutdown(EXIT_WAIT) { log::warn!("quit: background work was still running after {EXIT_WAIT:?}"); } + // macOS (#1331): end the process here, before eframe tears the window down, as Quit from + // the menu bar already does (PR #444). AccessKit's macOS adapter subclasses the window's + // content view and restores its class on teardown; AppKit's Touch Bar support observes + // that view (KVO), so in the next display cycle `-[_NSTouchBarFinderObservation + // invalidate]` throws "not registered" and closing the window ends in a crash report. + // Nothing after this is needed: settings and library are saved, background work has + // stopped and the log file is unbuffered. `on_exit` only runs once the app exists, so a + // renderer that fails to start still relaunches on the next one (`window_gpu`). + #[cfg(target_os = "macos")] + std::process::exit(0); } } @@ -1207,6 +1217,35 @@ mod tests { assert!(loaded.is_some() && warning.is_none()); let _ = std::fs::remove_dir_all(&d); } + + /// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. + /// The check runs in a child (this test binary again), since on macOS it ends the process. + #[test] + fn on_exit_ends_the_process_on_macos() { + if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut app = App( + app, + PrefsWriter::default(), + #[cfg(target_os = "macos")] + None, + ); + println!("calling on_exit"); + eframe::App::on_exit(&mut app, None); + println!("on_exit returned"); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"]) + .env("LIGHTCRAFT_TEST_ON_EXIT", "1") + .env("LIGHTCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{stdout}"); + assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); + assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); + } } #[cfg(test)] From c07035c44487264b432ad852fc610c6133fa612c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:04:47 +0200 Subject: [PATCH 120/142] CR2: as-shot white balance of the PowerShot G9 from its WBInfo preset rows (part of #1273) --- crates/raw/src/vendor/cr2.rs | 116 ++++++++++++++++++++++++++++++++--- docs/parity-checklist.md | 3 +- docs/raw-parity.md | 1 + docs/whats-new.md | 6 ++ 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 7f52888f4..c368cacd9 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -37,6 +37,10 @@ const SRAW_YCC: u32 = 4; const CR2_CFA_PATTERN: u16 = 0xc5e0; const SENSOR_INFO: u16 = 0x00e0; const COLOR_BALANCE: u16 = 0x4001; +/// ShotInfo, whose value 7 is the white-balance setting. +const SHOT_INFO: u16 = 0x0004; +/// WBInfo, the PowerShot G9's preset levels (it writes no `ColorData`). +const WB_INFO: u16 = 0x0029; fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE))) @@ -65,23 +69,51 @@ fn words(v: &Value, order: ByteOrder) -> Vec { } } +/// WB multipliers (R, G, B; G = 1) from one set of levels, when they are plausible. +fn wb_from_levels(r: f32, g1: f32, g2: f32, b: f32) -> Option<[f32; 3]> { + if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 { + return None; + } + let g = (g1 + g2) / 2.0; + let (mr, mb) = (r / g, b / g); + ((0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb)).then_some([mr, 1.0, mb]) +} + /// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array. fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] { let q = v.get(off..off + 4)?; - let (r, g1, g2, b) = (q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32); - if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 { - continue; - } - let g = (g1 + g2) / 2.0; - let (mr, mb) = (r / g, b / g); - if (0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb) { - return Some([mr, 1.0, mb]); + if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) { + return Some(wb); } } None } +/// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that +/// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in +/// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy, +/// 26 Tungsten, 34 Fluorescent, 50 Flash, 58 Underwater, 66 Custom 1, 74 Custom 2); other settings (14, Daylight +/// Fluorescent, has no documented row) give none. Measured on the CC0 file IMG_1188.CR2 (Custom 1, row 66: +/// 848 1747 1676 848), whose camera JPEG's neutral areas imply the same gains within 0.1 %. +fn wb_from_wb_info(m: &makernote::MakerNote) -> Option<[f32; 3]> { + let row = match m.ifd.u64s(SHOT_INFO)?.get(7)? { + 0 => 2, + 1 => 10, + 2 => 18, + 3 => 26, + 4 => 34, + 5 => 50, + 15 => 66, + 16 => 74, + 17 => 58, + _ => return None, + }; + let b = m.ifd.bytes(WB_INFO)?; + let level = |i: usize| Some(m.order.u32(b.get(4 * (row + i)..4 * (row + i + 1))?.try_into().ok()?) as i32 as f32); + wb_from_levels(level(1)?, level(0)?, level(3)?, level(2)?) +} + /// The optically black columns left of the image area: from column 2 up to two columns before the first column /// whose mean (over the image rows) rises above the leftmost columns' level by more than 1/256 of the remaining /// range (at least 16), else up to two columns before the image area. Empty when the border is too narrow. @@ -187,7 +219,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active = Rect::new(l, tp, r - l + 1, b - tp + 1); } } - let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v)); + let wb = mn + .as_ref() + .and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))) + .and_then(|v| wb_from_color_balance(&v)) + .or_else(|| wb_from_wb_info(mn.as_ref()?)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); let white = white_from_data(&data, prec as u32); let black = match masked_columns(&data, width, active, white) { @@ -376,6 +412,68 @@ pub(crate) mod tests { crate::decode(&bytes).unwrap() } + /// A maker note of `entries` (tag, TIFF type, value bytes), each value stored after the IFD, decoded; the value + /// offsets (relative to the TIFF header) are patched once the note's position is known. + fn decode_with_note(entries: &[(u16, u16, Vec)]) -> RawImage { + let ifd_len = 2 + 12 * entries.len() + 4; + let (mut note, mut data, mut at) = (Vec::new(), Vec::new(), Vec::new()); + note.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, ty, value) in entries { + let count = if *ty == 3 { value.len() / 2 } else { value.len() } as u32; + note.extend_from_slice(&tag.to_le_bytes()); + note.extend_from_slice(&ty.to_le_bytes()); + note.extend_from_slice(&count.to_le_bytes()); + at.push((note.len(), ifd_len + data.len())); + note.extend_from_slice(&0u32.to_le_bytes()); + data.extend_from_slice(value); + } + note.extend_from_slice(&0u32.to_le_bytes()); + note.extend_from_slice(&data); + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note)); + let tiff = Tiff::parse(&bytes).unwrap(); + let off = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for (field, value) in at { + bytes[off + field..off + field + 4].copy_from_slice(&((off + value) as u32).to_le_bytes()); + } + crate::decode(&bytes).unwrap() + } + + /// Issue #1273: the PowerShot G9 has no `ColorData`; its preset levels are WBInfo (`0x0029`: BYTE, int32 values, + /// four per preset in G R B G order), and ShotInfo (`0x0004`) value 7 says which preset was used (15 = Custom 1, + /// row 66: 848 1747 1676 848 on the CC0 file IMG_1188.CR2). + #[test] + fn white_balance_from_wb_info_when_there_is_no_color_data() { + let shot_info = |wb: u16| { + let mut v = [0u16; 34]; + v[7] = wb; + v.iter().flat_map(|x| x.to_le_bytes()).collect::>() + }; + let mut rows = vec![0i32; 512]; + rows[2..6].copy_from_slice(&[850, 1805, 1684, 850]); + rows[66..70].copy_from_slice(&[848, 1747, 1676, 848]); + rows[74..78].copy_from_slice(&[820, 1901, 4000, 820]); + rows[18..22].copy_from_slice(&[850, 1805, 6000, 850]); + let wb_info: Vec = rows.iter().flat_map(|x| x.to_le_bytes()).collect(); + let wb = |si: u16| decode_with_note(&[(0x0004, 3, shot_info(si)), (0x0029, 1, wb_info.clone())]).wb_multipliers; + assert_eq!(wb(15), Some([1747.0 / 848.0, 1.0, 1676.0 / 848.0])); + assert_eq!(wb(0), Some([1805.0 / 850.0, 1.0, 1684.0 / 850.0])); + // each preset its own row (Custom 2: row 74); an empty row (Daylight here), an implausible one (Cloudy: + // B 7.1) or a value without a documented row (14) gives none + assert_eq!(wb(16), Some([1901.0 / 820.0, 1.0, 4000.0 / 820.0])); + assert_eq!(wb(1), None); + assert_eq!(wb(2), None); + assert_eq!(wb(14), None); + // without ShotInfo, or with WBInfo too short for the row + assert_eq!(decode_with_note(&[(0x0029, 1, wb_info.clone())]).wb_multipliers, None); + assert_eq!(decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info[..4 * 68].to_vec())]).wb_multipliers, None); + // ColorData, when it gives levels, wins + let mut color_data = vec![0u8; 2 * 120]; + color_data[0..2].copy_from_slice(&[0xfd, 0xff]); + color_data[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); + let both = decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info.clone()), (COLOR_BALANCE, 7, color_data)]); + assert_eq!(both.wb_multipliers, Some([1988.0 / 745.0, 1.0, 1641.0 / 745.0])); + } + /// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71, /// exactly as the same words stored as SHORT do. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..0375bf141 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Canon PowerShot G9 as-shot white balance from WBInfo (issue #1273); status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..2ab113dc2 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -266,6 +266,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..5a33c1a26 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-12 + +### Older raw cameras +- Canon PowerShot G9 raws open with the camera's white balance (White Balance ▸ As Shot); it used to start from a + neutral default. + ## 2026-10-11 ### JPEG XL export From f0d1ea9c4e79c27b740ecb7345e0ab79830a3d46 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:14:43 +0200 Subject: [PATCH 121/142] Before/After wipe: the line stays in the middle of the view when zoomed in (fixes #1351) A wipe drew its line through the middle of the photo. Zoomed in, that point is usually off screen, so the whole view fell on one side of it and showed only the Before (the original) or only the After. The line now goes through the middle of the part of the photo on screen: the same place at Fit, and in view at any zoom. --- crates/ui-egui/src/panels/detail.rs | 17 ++++--- crates/ui-egui/src/region.rs | 21 ++++++-- crates/ui-egui/src/tests_before_after.rs | 63 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..93a848942 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -642,19 +642,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } app.loupe_shown = Some((id, shown)); // a wipe shows the Before whole-frame render on its side of the line… + let line = crate::region::wipe_line(img_rect, canvas); if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { let before_rect = fit_texture_rect(img_rect, tex.size); - let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom())); - p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE); + let u = ((line.x - before_rect.left()) / before_rect.width().max(1.0)).clamp(0.0, 1.0); + let left = Rect::from_min_max(before_rect.min, pos2(before_rect.left() + u * before_rect.width(), before_rect.bottom())); + p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(u, 1.0)), Color32::WHITE); } if app.ui.before_after == BeforeAfter::SplitTopBottom && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { let before_rect = fit_texture_rect(img_rect, tex.size); - let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y)); - p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); + let v = ((line.y - before_rect.top()) / before_rect.height().max(1.0)).clamp(0.0, 1.0); + let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.top() + v * before_rect.height())); + p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, v)), Color32::WHITE); } // …and the window renders go over the whole-frame ones, all of them, where they belong in the // frame (clipped to their pane, or their side of the line) @@ -666,12 +669,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // the line of a wipe over both if app.ui.before_after == BeforeAfter::Split { - let mid = img_rect.center().x; - p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); + p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); } if app.ui.before_after == BeforeAfter::SplitTopBottom { - let mid = img_rect.center().y; - p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); + p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], Stroke::new(1.5, Color32::WHITE)); } if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..82b33b8f6 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -157,17 +157,26 @@ impl SizeHold { } } +/// Where the line of a wipe crosses: the middle of the part of the photo that is on screen. At +/// Fit that is the photo's middle; zoomed in, the photo's own middle is mostly off screen, and a +/// line there left the whole view to one side (#1351). +pub fn wipe_line(img: egui::Rect, canvas: egui::Rect) -> egui::Pos2 { + let shown = img.intersect(canvas); + if shown.is_positive() { shown.center() } else { img.center() } +} + /// Where a window of one side of a Before/After view may be drawn: its own pane in side by side /// and stacked views (a window carries margin beyond what its pane shows, which would otherwise /// spill into the neighbour), its side of the line in a wipe, else the canvas. pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egui::Rect, canvas: egui::Rect, img: egui::Rect) -> egui::Rect { use crate::state::BeforeAfter; let (min, max) = (canvas.min, canvas.max); + let line = wipe_line(img, canvas); let clip = match (mode, before_side) { - (BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(img.center().x, max.y)), - (BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, img.center().y)), - (BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(img.center().x, min.y), max), - (BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, img.center().y), max), + (BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(line.x, max.y)), + (BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, line.y)), + (BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(line.x, min.y), max), + (BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, line.y), max), (BeforeAfter::SideBySide | BeforeAfter::TopBottom, _) => pane, _ => canvas, }; @@ -423,6 +432,10 @@ mod tests { assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); + // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle + let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); + assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); } diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 96fab9cbe..ae35a35b2 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -69,3 +69,66 @@ fn wb_selector_picks_the_same_point_in_the_before_view() { } } } + +/// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. +fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { + let mut sum = [0.0f32; 3]; + let mut n = 0.0; + for y in (at.y as usize - 20)..=(at.y as usize + 20) { + for x in (at.x as usize - 20)..=(at.x as usize + 20) { + let c = shot[(x, y)]; + sum[0] += f32::from(c.r()); + sum[1] += f32::from(c.g()); + sum[2] += f32::from(c.b()); + n += 1.0; + } + } + sum.map(|s| s / n) +} + +/// Zoomed in to compare a detail, every Before / After view still shows the edited photo on its +/// After side. A wipe drew its line through the middle of the photo, which zoomed in is mostly off +/// screen: the whole view was the Before, the original photo (#1351). +#[test] +fn zoomed_in_the_after_side_shows_the_edits() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.5}}), T); + assert_eq!(r["ok"], true, "{r}"); + let shot = |h: &mut Headless, mode: BeforeAfter, zoom: Zoom| { + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + // (far enough from the middle of the photo that, zoomed in, it is off screen) + h.app.ui.pan = (0.25, 0.75); + h.settle(SETTLE); + h.paint() + }; + for zoom in [Zoom::Fit, Zoom::Percent(100.0), Zoom::Percent(400.0)] { + let (edited, original) = (shot(&mut h, BeforeAfter::Off, zoom), shot(&mut h, BeforeAfter::Original, zoom)); + let c = h.app.canvas_rect.expect("canvas").center(); + let luma = |m: [f32; 3]| m.iter().sum::(); + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom, BeforeAfter::Split, BeforeAfter::SplitTopBottom] { + let s = shot(&mut h, mode, zoom); + let canvas = h.app.canvas_rect.expect("canvas"); + // the middle of the After pane, or of the After side of the line + let (after, before) = match mode { + BeforeAfter::SideBySide | BeforeAfter::Split => { + (c + egui::vec2(canvas.width() / 4.0, 0.0), c - egui::vec2(canvas.width() / 4.0, 0.0)) + } + _ => (c + egui::vec2(0.0, canvas.height() / 4.0), c - egui::vec2(0.0, canvas.height() / 4.0)), + }; + let (a, b) = (mean_around(&s, after), mean_around(&s, before)); + if matches!(mode, BeforeAfter::Split | BeforeAfter::SplitTopBottom) { + // a wipe shows the loupe's own pixels on each side of the line + let (e, o) = (mean_around(&edited, after), mean_around(&original, before)); + assert!((luma(a) - luma(e)).abs() < 6.0, "{mode:?} {zoom:?}: the After side {a:?} is the edited photo {e:?}"); + assert!((luma(b) - luma(o)).abs() < 6.0, "{mode:?} {zoom:?}: the Before side {b:?} is the original {o:?}"); + } else { + // two views of the same point of the photo: the After one is the brighter + assert!(luma(a) > luma(b) + 30.0, "{mode:?} {zoom:?}: the After {a:?} is the edited photo, brighter than the Before {b:?}"); + } + } + } +} From 60639aa6779b70217e709ff6777264337187ea16 Mon Sep 17 00:00:00 2001 From: Menno Date: Tue, 13 Oct 2026 00:25:33 +0200 Subject: [PATCH 122/142] LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their filler columns (no pink band) The D90 writes 4352 x 2868 samples whose last 42 columns (from 4310) are filler at a flat ~254, above its dark image samples (black 0). The masked-column test only trims columns near zero, so they stayed in the image and developed as a pink band on the right. Measured on 10 of 550 NEFs from one body: the camera's 4288 x 2848 JPEG sits at (10, 10), and green saturates at 3840-3851 while red and blue run on to 4095, so one white level of 3830 keeps clipped highlights white. The D90 joins the D4 in MEASURED_BODIES; other bodies are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/raw/src/vendor/nef.rs | 30 ++++++++++++++++++++++++++++-- docs/parity-checklist.md | 5 +++-- docs/raw-parity.md | 7 ++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index cacb516af..be8a89051 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -74,8 +74,15 @@ struct MeasuredBody { /// 4928 × 3280 JPEG sits at (8, 6). Without this area the optical black shows as purple stripes on both sides. /// Green saturates at 15546–15672 while red and blue run on to 15687–16383, so the data's own top (~16300) leaves /// clipped highlights magenta; one level of 15520 for every channel turns them white and keeps the white balance. -const MEASURED_BODIES: &[MeasuredBody] = - &[MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }]; +/// * **NIKON D90** (550 compressed 12-bit NEFs from one body, 10 measured): the data is 4352 × 2868 and its last 42 +/// columns (from 4310) are filler at a flat ~254 whatever the exposure, above the dark image samples (black 0), so +/// the masked-column test keeps them and they develop as a pink band on the right. The camera's 4288 × 2848 JPEG +/// sits at (10, 10). Green saturates at 3840–3851 while red and blue run on to 4095; one level of 3830 keeps +/// clipped highlights white. +const MEASURED_BODIES: &[MeasuredBody] = &[ + MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }, + MeasuredBody { model: "NIKON D90", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: 3830.0 }, +]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) @@ -398,6 +405,25 @@ mod tests { } } + /// The D90's last 42 columns are filler at a flat ~254, above its dark image samples: without the measured + /// area they stay in the image and develop as a pink band on the right. + #[test] + fn measured_d90_drops_the_filler_columns() { + let (w, h) = (4352usize, 2868usize); + let mut px = vec![40u16; w * h]; + for row in px.chunks_mut(w) { + row[4310..].fill(254); + } + let mut nef = nef(1, 12, vec![px.iter().flat_map(|v| v.to_le_bytes()).collect()], w as u32, h as u32, h as u32); + let at = nef.windows(10).position(|b| b == b"NIKON TEST").unwrap(); + nef[at..at + 10].copy_from_slice(b"NIKON D90 "); + let d90 = crate::decode(&nef).unwrap(); + assert_eq!(d90.crop, Rect::new(10, 10, 4288, 2848)); + assert!(d90.crop.x + d90.crop.width <= 4310, "the filler columns are outside the image"); + assert_eq!(d90.white, vec![3830.0]); + assert_eq!((d90.metadata.width, d90.metadata.height), (Some(4288), Some(2848))); + } + /// Issue #708: the active width is judged from the first [`MASK_ROWS`] rows in both modes, so the header probe /// (which reads only those rows' strips) and the full decode agree whether the masked columns are there or not. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..774ed001f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-IMP-CAMERA-COVERAGE: Nikon D90 image area and white level measured, no pink band on the right; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..dd8a17fca 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Nikon D90: filler columns no longer develop as a pink band; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -118,7 +118,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | CR3 | HEIF / HDR PQ | ⬜ | HEVC tracks recognised, not decoded | | CR3 | dual-pixel raw | ⬜ | never substituted | | CRW | all | ⬜ | preview | -| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633 | +| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4 and D90 image area and white level measured against the camera JPEG | | NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | | NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | | ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024 | @@ -172,7 +172,7 @@ sample files. A pointer for contributors, not legal advice. | Monochrome / LinearRaw | ✅ | handled, no demosaic | ✅ | | Pixel shift (Sony ARQ, Pentax, OM high-res, Nikon) | ✅ (Sony, Pentax, Nikon) | — | ⬜ | | Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) | -| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | +| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | | Lens metadata | ✅ read for profile selection | EXIF lens fields read; used only for the in-file corrections below | 🟡 | | Highlight reconstruction | ✅ | neutral clip, chromaticity diffusion (`highlight.rs`) | 🟡 tuned by eye | | AI denoise | ✅ Bayer and X-Trans, model bundled | Bayer only, user-installed weights, pure-Rust CPU / GPU | 🟡 no X-Trans, no shippable weights | @@ -266,6 +266,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Nikon D90 image area (4288 × 2848 at 10, 10) and white level (3830) measured on 10 of 550 NEFs from one body: its 42 filler columns no longer develop as a pink band | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | From a99c08480584ea7341115bfbf84fcf9bae0e65c5 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 00:29:45 +0200 Subject: [PATCH 123/142] M5.6: a slider drag no longer decodes the edited photo's thumbnail Each drag tick changed the edited photo's settings, so the filmstrip asked for a new thumbnail render every frame. For an unedited raw shown with its embedded preview, the thumbnail-level source had never been decoded, so each of those renders decoded the whole raw (a 64 MP ARW: over a second), several side by side, and the loupe's drafts queued behind them. - The edited photo's thumbnail is not re-rendered during a slider drag while it has one; it follows once the drag ends. - A thumbnail of a photo whose preview-level source is decoded (the one in the loupe) is cut from that source (SourceRef::Fit) instead of decoding the file again; not when the photo has a denoised picture. Co-Authored-By: Claude --- apps/lightcraft-web/src/wire.rs | 2 +- crates/engine/src/media.rs | 41 +++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/grid.rs | 5 ++++ crates/ui-egui/src/render.rs | 20 +++++++++++++++ docs/whats-new.md | 9 +++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index e231c47c5..b3d685f5f 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -64,7 +64,7 @@ impl WireJob { /// `view`: the slot name, for jobs that keep a stage cache. pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob { let max_edge = match &job.source { - SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge, + SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge, SourceRef::Loaded(img) => img.image.width.max(img.image.height), SourceRef::Smart { .. } => 2560, }; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..4740dfcda 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -223,6 +223,13 @@ impl DecodedSource { #[derive(Clone)] pub enum SourceRef { Loaded(Box), + /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of + /// the photo open in the loupe is cut from its preview-level source instead of decoding the + /// file again (a second or more for a 64 MP raw). + Fit { + from: Box, + max_edge: usize, + }, Demo { scene: Box, max_edge: usize, @@ -251,6 +258,13 @@ impl SourceRef { pub fn load_source(&self) -> Result { let image = match self { SourceRef::Loaded(a) => return Ok((**a).clone()), + SourceRef::Fit { from, max_edge } => { + if from.image.width.max(from.image.height) <= *max_edge { + return Ok((**from).clone()); + } + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); + } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, SourceRef::File { path, max_edge, loader, fallback, denoise } => { let plain = || match loader { @@ -625,6 +639,15 @@ impl MediaCache { if cached && let Some(a) = self.get_source(p.id, level) { return SourceRef::Loaded(Box::new(a)); } + // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not + // with a denoised picture, which the thumbnail would lose + if cached + && level == SourceLevel::Thumb + && self.denoise.spec(p.id).is_none() + && let Some(big) = self.get_source(p.id, SourceLevel::Preview) + { + return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; + } // Procedural scenes have a nominal size: "full" is that size, not unbounded. let max_edge = level.max_edge().min(match (&p.source, level) { (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, @@ -1931,6 +1954,24 @@ mod tests { assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); } + /// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here + /// one nothing can decode) is not read again. + #[test] + fn a_thumbnail_is_cut_from_the_decoded_preview() { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); + let job = s.thumb_job(p.id, 512).unwrap(); + assert!(matches!(job.source, SourceRef::Fit { .. })); + let r = job.run(); + let thumb = r.rendered.as_ref().unwrap(); + assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); + s.accept(&r); + let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); + assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); + } + #[test] fn has_source_follows_what_the_session_accepted() { let mut s = crate::Session::with_demo(); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..bc47907e0 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -449,6 +449,11 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: /// [`lightcraft_engine::media::Session::embedded_thumb_is_final`]), which for a folder of raws on a /// network share is the difference between reading every file whole and reading a preview each. pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) { + // A slider drag changes the photo every frame: its thumbnail follows once the drag ends, so its + // renders (a raw decode the first time) don't compete with the loupe's drafts. + if app.session.interaction.as_ref().is_some_and(|i| i.photo == id) && app.renderer.thumb(id).is_some() { + return; + } let bucket = lightcraft_engine::media::thumb_bucket(size); if let Some(photo) = app.session.catalog.photo(id) && app.renderer.thumb_current(photo, bucket, priority) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..595fae824 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -1015,6 +1015,26 @@ mod thumbnail_tests { assert!(work.borrow().jobs.is_empty()); } + #[test] + fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); + for v in [0.1, 0.2, 0.3] { + app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); + assert!(work.borrow().jobs.is_empty()); + app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); + } + #[test] fn unchanged_queued_frames_build_no_jobs() { let (mut app, _, _) = fixture(); diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..374a3f0ec 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-13 + +### Slider drags on large raws +- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you + let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's + previews for up to a second. +- A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file + again. + ## 2026-10-11 ### JPEG XL export From dead4367b00ed165e7cbcd47c71126c4d18dac46 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 00:40:59 +0200 Subject: [PATCH 124/142] M5.6: the pinned original no longer evicts the photo's other sources (64 MP edits decoded the raw again) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MediaCache::enforce_budget never evicts the original of the photo on screen (set_on_screen), but it still counted it. A 64 MP original (Sony A7R VI: 9984×6656 linear float, 760 MiB) nearly fills the default cache share (768 MiB) alone, so the loop evicted everything else instead: the photo's preview and thumbnail sources, the rendered previews and the prefetched neighbours. Every edit at 1:1 (the whole-frame render and the thumbnail), every zoom out and every step to the next photo then decoded the raw again. The pinned original is now left out of what is compared with the budget. The rest still fits the share; the original sits over it, as the first commit of this PR already allowed. Co-Authored-By: Claude --- crates/engine/src/media.rs | 24 +++++++++++++++++++++++- docs/whats-new.md | 3 +++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b0ff1b265..0fce578d7 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -491,7 +491,11 @@ impl MediaCache { /// one just decoded) is never evicted here, nor is the original of the photo on screen /// ([`Self::set_on_screen`]). pub fn enforce_budget(&mut self) { - let mut held = self.held(); + // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) + // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and + // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. + let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); + let mut held = self.held().saturating_sub(pinned); while held > self.budget { let newest_preview = self.previews.iter().map(|e| e.2).max(); let candidates = [ @@ -1807,6 +1811,24 @@ mod tests { assert!(m.has_source(PhotoId(3), SourceLevel::Full)); } + // Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay + // (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) + #[test] + fn the_on_screen_original_does_not_evict_the_other_sources() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { + assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); + } + } + // A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): // evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo // on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. diff --git a/docs/whats-new.md b/docs/whats-new.md index 191360f58..385cf92c8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,6 +13,9 @@ - A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no longer frees it, which cost a re-upload of about a second on the next pan (800 MB for a 64 MP raw). It is freed once you zoom out or move on. +- The kept original no longer pushes everything else out of the cache: on a 64 MP raw it filled the cache share + alone, so the photo's preview and thumbnail sources and its neighbours' were decoded from the raw again on every + edit at 1:1, every zoom out and every step to the next photo. ## 2026-10-11 From cd3a9423767e29de6cb604f96ce98b8da445a8cf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:47:34 +0200 Subject: [PATCH 125/142] =?UTF-8?q?LR-EDIT-LIGHT-CURVE-POINT:=20=E2=8C=A5-?= =?UTF-8?q?drag=20moves=20a=20curve=20point=20a=20tenth=20as=20far,=20for?= =?UTF-8?q?=20fine=20adjustment=20(fixes=20#1363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/edit.rs | 13 +++++++++++-- crates/ui-egui/src/tests_curve.rs | 17 +++++++++++++++++ docs/parity-checklist.md | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..ba36d77d7 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -858,7 +858,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev }; let line: Vec = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect(); p.add(egui::Shape::line(line, Stroke::new(2.0, color))); - // interaction: drag a point (both axes, between its neighbours), drag empty space to add and + // interaction: drag a point (both axes, between its neighbours; ⌥ = fine), drag empty space to add and // drag a new point, click empty space to add, double-click a point to delete it let drag_id = egui::Id::new(("curve-drag", &ch)); // the point being dragged (stored as `Option` so a stale value can be cleared) @@ -919,8 +919,17 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev } else if resp.dragged() && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) { - let (mut x, y) = from_screen(q); let mut v = pts.clone(); + // ⌥-drag = fine adjustment, as in Lightroom: the point moves a tenth of the pointer's + // motion (relative to where it is, so pressing ⌥ mid-drag doesn't make it jump) + let fine = ui.input(|i| i.modifiers.alt); + let (mut x, y) = match v.get(i) { + Some(cur) if fine => { + let dd = ui.input(|i| i.pointer.delta()); + ((cur.x + (dd.x / r.width()) as f64 * 0.1).clamp(0.0, 1.0), (cur.y - (dd.y / r.height()) as f64 * 0.1).clamp(0.0, 1.0)) + } + _ => from_screen(q), + }; if i < v.len() { let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 }; let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 }; diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index ca5848bee..cc5d71301 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -69,6 +69,23 @@ fn dragging_a_point_moves_it_in_both_axes_with_one_undo_step() { assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step per drag"); } +#[test] +fn alt_dragging_a_point_moves_it_a_tenth_as_far() { + let mut h = curve_open("master"); + exec(&mut h, "develop.curve", json!({"channel": "master", "points": [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]})); + h.step(); + let side = curve_side(&mut h); + // the same drag as above with ⌥ held: x 0.5 → ~0.49, y 0.5 → ~0.52 + let r = + h.request("ui.dragWidget", json!({"id": "curve", "fx": 0.5, "fy": 0.5, "dx": -0.1 * side, "dy": -0.2 * side, "steps": 12, "alt": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let c = pts(&develop(&h).curve.master); + assert_eq!(c.len(), 3, "{c:?}"); + assert!(c[1].0 < 0.5 && c[1].0 > 0.485, "x moves a tenth of the pointer: {c:?}"); + assert!(c[1].1 > 0.51 && c[1].1 < 0.525, "y moves a tenth of the pointer: {c:?}"); +} + fn click_widget(h: &mut Headless, id: &str, count: u64) { let r = h.request("ui.clickWidget", json!({"id": id, "count": count}), T); assert_eq!(r["ok"], true, "{id}: {r}"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..20b815025 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -184,7 +184,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | | LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | | LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | -| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | +| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | From 713d81d64b97f6d682387c0c77d1d23d19d21d42 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:51:05 +0200 Subject: [PATCH 126/142] KEY-SLIDER-TYPE: a typed slider value keeps editing after the click is released (fixes #1362) egui gives the opening release's click to the readout, so the field under it surrenders the focus. Only the field's first two frames took it back: a click held any longer ended the edit, and the value could only be typed while holding the button. --- crates/ui-egui/src/widgets.rs | 12 ++++++++---- .../src/widgets/tests_numeric_entry.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0872b2ec4..a1b881ef0 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -222,7 +222,10 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let mut v = value; if let Some((mut text, frames, pressed)) = typing.take() { // Keep the owned opening release focused without restarting its draft or selection. - if pressed && value_released { + // egui gives that release's click to the readout, so the field under it gives up the focus + // whatever frame it lands on (issue #1362): the field takes it back after drawing too. + let opening_release = pressed && value_released; + if opening_release { ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); } let pressed = pressed && !value_released; @@ -237,10 +240,11 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ .select_on_focus(true) .begin_edit(frames == 0) .show(&mut child); - if frames < 2 && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { + if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { field.response.request_focus(); - typing = Some((text.clone(), frames + 1, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1, pressed))); + let frames = frames.saturating_add(1); + typing = Some((text.clone(), frames, pressed)); + ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); } else if field.ending.is_some() || !field.editing || !enabled { // Esc (or the slider turning off) keeps the old value; Return or clicking away applies let cancelled = !enabled || field.cancelled(); diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 1935cde95..5e54ff064 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -75,3 +75,22 @@ fn dragging_away_from_readout_does_not_capture_keyboard() { } assert!(!h.view.ctx.egui_wants_keyboard_input(), "a cancelled readout gesture must not retain keyboard focus"); } + +#[test] +fn typing_after_a_released_click_still_edits() { + // Issue #1362: a normal click (press, a few frames held, release) must leave the field + // editing, so the value can be typed without holding the button down. + let mut h = Harness::new(); + let pos = h.rect.center(); + let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![button(true)]); + for _ in 0..4 { + h.frame(vec![]); + } + assert_eq!(h.frame(vec![button(false)]).value, None); + h.frame(vec![]); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after the click"); + h.frame(vec![Event::Text("2".into())]); + assert_eq!(h.key(Key::Enter).value, Some(2.0)); +} From f47fb8607722179a1a6494337a7b0d2f36705e45 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 01:24:26 +0200 Subject: [PATCH 127/142] LR-BEHAV-GPU: say once when no GPU is found, instead of quietly rendering on the CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With GPU rendering on and no graphics adapter at all (wgpu: "no GPU adapter among …", e.g. no Vulkan loader: a `cargo run` outside `nix develop` on NixOS), every render ran on the CPU, much slower, and only Settings ▸ Performance, Help ▸ System Info and `perf.gpuReason` said why. The desktop app now queues one start-up notice per session once the device has been tried (`gpu::ready()`, never blocking): "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.", on Linux with "On NixOS, start it from `nix develop`." (→, not ▸: no installed font paints ▸.) `panels::notices::gpu_notice` decides from the reason: nothing when the GPU was turned off (preference, LIGHTCRAFT_GPU, LIGHTCRAFT_GPU_BACKEND=off), only a software adapter was skipped, or it failed later. Headless sessions and tests never show it (`app.gpu_notice` is set by the desktop app only). Translated in all nine catalogs; one README sentence in the Nix section. Co-Authored-By: Claude --- README.md | 3 ++ apps/lightcraft/src/main.rs | 1 + crates/ui-egui/locales/de.json | 4 ++- crates/ui-egui/locales/es.json | 4 ++- crates/ui-egui/locales/fr.json | 4 ++- crates/ui-egui/locales/ja.json | 4 ++- crates/ui-egui/locales/pt-br.json | 4 ++- crates/ui-egui/locales/ru.json | 4 ++- crates/ui-egui/locales/uk.json | 4 ++- crates/ui-egui/locales/zh-hans.json | 4 ++- crates/ui-egui/locales/zh-hant.json | 4 ++- crates/ui-egui/src/lib.rs | 4 +++ crates/ui-egui/src/panels/notices.rs | 52 +++++++++++++++++++++++++++- docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 ++++ 15 files changed, 94 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8884f2bd3..30a6f1a7b 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,9 @@ nix build github:storytold/lightcraft # → ./result/bin/{light nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts ``` +On NixOS, run a `cargo` build from `nix develop`: outside it the app can't find `libvulkan.so.1`, finds no GPU and +renders on the CPU, much more slowly (it says so once at start-up). `nix run` and `nix build` set the path themselves. + In a flake configuration (NixOS, home-manager, nix-darwin): ```nix diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index c9b9696e1..170604ccd 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -842,6 +842,7 @@ fn main() -> eframe::Result { app.notices.extend(prefs_warning); app.notices.extend(launch_notices); app.notices.extend(window_notice); + app.gpu_notice = true; // what's on disk now: only changes are written let writer = PrefsWriter { path: if in_memory { None } else { prefs_path() }, diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..63149650b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,7 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", + "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..fc5dee59f 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,7 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", + "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..259b694a2 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,7 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", + "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..e51b0f8a2 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,7 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", + "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..daee81957 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,7 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", + "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..cfe182aa6 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,7 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", + "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..e409aaa6b 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,7 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", + "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..a807797be 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,7 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", + "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..b097e4a76 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,7 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", + "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..52ebf23a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -377,6 +377,9 @@ pub struct LightcraftApp { pub window_is_fullscreen: bool, /// The GPU preference last applied (`app.gpu`), to apply Settings changes once. gpu_applied: Option, + /// Say once, when the GPU device has been tried, that no graphics processor was found + /// ([`panels::notices::gpu_notice`]). The desktop app sets it; headless sessions and tests don't. + pub gpu_notice: bool, /// The memory budget setting last applied (MB, 0 = automatic). memory_applied: Option, /// The display profile setting last applied (`app.displayProfile`). @@ -459,6 +462,7 @@ impl LightcraftApp { hover_preview: None, window_is_fullscreen: false, gpu_applied: None, + gpu_notice: false, memory_applied: None, display_applied: None, display_error: None, diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 619efe1d0..016323e95 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -8,10 +8,36 @@ use crate::theme::Tokens; use crate::widgets::register; use crate::{LightcraftApp, QuitPrompt}; -/// Collect the library's settings-file warnings (each is shown once). +/// Collect the library's settings-file warnings (each is shown once), and the no-GPU notice once the GPU device has +/// been tried. pub fn logic(app: &mut LightcraftApp) { let new = app.session.take_library_warnings(); app.notices.extend(new); + use lightcraft_engine::gpu; + if app.gpu_notice && app.gpu_applied == Some(true) && gpu::ready() { + app.gpu_notice = false; + if !gpu::available() { + app.notices.extend(gpu_notice(gpu::unavailable_reason().as_deref(), app.ui.settings.gpu)); + } + } +} + +/// What to tell the user when GPU rendering is on (`enabled`) but the GPU could not be used for `reason`: only when no +/// graphics adapter was found at all ("no GPU adapter among …", e.g. no Vulkan loader on Linux), which makes every +/// render much slower without saying so. Nothing when it was turned off (preference, `LIGHTCRAFT_GPU`), a software +/// adapter was skipped, or it failed later; Settings ▸ Performance and Help ▸ System Info show those. +pub fn gpu_notice(reason: Option<&str>, enabled: bool) -> Option { + if !enabled || !reason?.starts_with("no GPU adapter") { + return None; + } + let mut text = + crate::i18n::tr("No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.") + .to_string(); + if cfg!(target_os = "linux") { + text.push(' '); + text.push_str(crate::i18n::tr("On NixOS, start it from `nix develop`.")); + } + Some(text) } /// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that @@ -143,3 +169,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod tests { + use super::gpu_notice; + + #[test] + fn the_gpu_notice_is_only_for_a_missing_adapter() { + let none = "no GPU adapter among Backends(VULKAN | GL) (No suitable graphics adapter found)"; + let text = gpu_notice(Some(none), true).expect("no adapter: say so"); + assert!(text.starts_with("No graphics processor found") && text.contains("System Info"), "{text}"); + assert_eq!(text.contains("nix develop"), cfg!(target_os = "linux"), "{text}"); + assert_eq!(gpu_notice(Some(none), false), None, "the preference is off"); + for reason in [ + "disabled by LIGHTCRAFT_GPU=0", + "disabled by LIGHTCRAFT_GPU_BACKEND=off", + "disabled by the GPU rendering preference (app.gpu)", + "software adapter (llvmpipe (LLVM 19.1.7, 256 bits)) skipped: the CPU pipeline is faster", + "stopped after a GPU failure: device lost", + ] { + assert_eq!(gpu_notice(Some(reason), true), None, "{reason}"); + } + assert_eq!(gpu_notice(None, true), None, "the GPU is in use"); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..dea72e5db 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -457,7 +457,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | -| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..e56d5efb3 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-13 + +### Told when there is no GPU +- When GPU rendering is on but no graphics processor can be found, LightCraft now says so once at start-up instead + of quietly rendering everything on the CPU, much more slowly. Help ▸ System Info says why. On NixOS this happens + when a `cargo` build runs outside `nix develop` (no Vulkan loader); start it from there. + ## 2026-10-11 ### JPEG XL export From e1b29a3fd48fd84704eb10eb6d0f82cc4ee16b9c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 02:16:52 +0200 Subject: [PATCH 128/142] LR-IMP-CAMERA-COVERAGE: Nikon D7100 NEFs develop at Lightroom's frame, so its crops line up (fixes #1248) --- crates/raw/src/vendor/nef.rs | 46 +++++++++++++++++++++++++++++++----- docs/parity-checklist.md | 2 +- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index cacb516af..526cb65d6 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -58,7 +58,8 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { } /// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the -/// camera's full-size JPEG sits when the two are aligned) and the level where its channels clip. +/// camera's full-size JPEG sits when the two are aligned) and, when the data's own top would be wrong, the level +/// where its channels clip. struct MeasuredBody { model: &'static str, /// Sensor data size and sample depth of the measured files. @@ -66,7 +67,7 @@ struct MeasuredBody { bits: u32, /// Image area in sensor pixels: x, y, width, height. crop: (usize, usize, usize, usize), - white: f32, + white: Option, } /// * **NIKON D4** (148 uncompressed 14-bit NEFs from one body, 2023–2026, ISO 100–10000): the data is 4992 × 3292, @@ -74,8 +75,18 @@ struct MeasuredBody { /// 4928 × 3280 JPEG sits at (8, 6). Without this area the optical black shows as purple stripes on both sides. /// Green saturates at 15546–15672 while red and blue run on to 15687–16383, so the data's own top (~16300) leaves /// clipped highlights magenta; one level of 15520 for every channel turns them white and keeps the white balance. -const MEASURED_BODIES: &[MeasuredBody] = - &[MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }]; +/// * **NIKON D7100** (issue #1248; 14-bit NEFs from the public zjqc/photographing-art repository, with their +/// Lightroom Classic exports): the data holds picture, not masked lines, beyond the camera's frame. In full frame +/// it is 6036 × 4020 and the camera's JPEG is 6000 × 4000; in the 1.3x crop mode it is 4812 × 3212 and the JPEG +/// 4800 × 3200. Fitting renders of the full data to Lightroom's exports (feature matches, homography) puts +/// Lightroom's frame, the camera JPEG's size, at (18, 10) and (6, 6): within 0.5 px mean on DSC_1316, DSC_2763 +/// and DSC_1486. Without it, renders are larger than Lightroom's and its crops land up to 6 px off (18 px in +/// full frame). +const MEASURED_BODIES: &[MeasuredBody] = &[ + MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) }, + MeasuredBody { model: "NIKON D7100", size: (6036, 4020), bits: 14, crop: (18, 10, 6000, 4000), white: None }, + MeasuredBody { model: "NIKON D7100", size: (4812, 3212), bits: 14, crop: (6, 6, 4800, 3200), white: None }, +]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) @@ -164,8 +175,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let active_w = trailing_masked_columns(samples, w, bits); // the white level is measured from the whole image (or known for a measured body); the probe (no samples in its // result) takes the nominal one - let white = match (body, mode) { - (Some(b), _) => b.white, + let white = match (body.and_then(|b| b.white), mode) { + (Some(white), _) => white, (None, Mode::Full) => white_from_data(samples, bits), (None, Mode::Header) => ((1u32 << bits.clamp(1, 16)) - 1) as f32, }; @@ -398,6 +409,29 @@ mod tests { } } + /// Issue #1248: the D7100 records no CropArea, and its data holds picture beyond Lightroom's frame in both + /// image areas (full frame and 1.3x crop), so renders came out larger and Lightroom crops landed off. + #[test] + fn measured_d7100_image_area() { + let decode_as = |model: &str, (w, h): (usize, usize)| { + let mut nef = nef(1, 14, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32); + // the fixture's model is "NIKON TEST" plus its NUL: 11 bytes to overwrite + let at = nef.windows(11).position(|b| b == b"NIKON TEST\0").unwrap(); + let name = format!("{model:\0<11}"); + nef[at..at + 11].copy_from_slice(name.as_bytes()); + crate::decode(&nef).unwrap() + }; + for (size, crop) in [((6036, 4020), Rect::new(18, 10, 6000, 4000)), ((4812, 3212), Rect::new(6, 6, 4800, 3200))] { + let d7100 = decode_as("NIKON D7100", size); + assert_eq!(d7100.crop, crop); + assert_eq!((d7100.metadata.width, d7100.metadata.height), (Some(crop.width as u32), Some(crop.height as u32))); + assert_ne!(d7100.white, vec![15520.0]); + let developed = d7100.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (crop.width, crop.height)); + assert_eq!(decode_as("NIKON D7200", size).crop, Rect::new(0, 0, size.0, size.1)); + } + } + /// Issue #708: the active width is judged from the first [`MASK_ROWS`] rows in both modes, so the header probe /// (which reads only those rows' strips) and the full decode agree whether the masked columns are there or not. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..e4b338423 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | From 0fa8070647e612e0a0f219b966931bd8d679c098 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 02:33:10 +0200 Subject: [PATCH 129/142] AI Denoise: Install from file says why a model file is refused (fixes #1381) --- crates/engine/src/cmd/denoise.rs | 17 +++++++++--- crates/engine/src/tests_denoise.rs | 23 ++++++++++++++++ crates/ui-egui/src/headless.rs | 39 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/denoise.rs | 22 ++++++++++++++-- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index 8802460a1..f93a2434a 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -303,9 +303,20 @@ fn inspect(s: &mut Session, p: &Value) -> Result { if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) { return Err(bad(C, "choose an .onnx file with denoise-model.json beside it")); } - let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES) - .ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?; - let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?; + let sibling = src.with_file_name("denoise-model.json"); + let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES) else { + let why = if sibling.is_file() { + "denoise-model.json could not be read or is too large" + } else if src.with_file_name("model.json").is_file() { + // other apps ship their models with a model.json of their own (#1381) + "No denoise-model.json beside this .onnx file. The model.json there is another app's description, which LightCraft \ + does not read: it needs its own denoise-model.json (see docs/denoise.md)" + } else { + "No denoise-model.json beside this .onnx file: LightCraft needs one that says how to use the model (see docs/denoise.md)" + }; + return Err(bad(C, why)); + }; + let m = manifest::parse(&bytes).map_err(|e| bad(C, format!("denoise-model.json: {e} (see docs/denoise.md)")))?; let dir = models_dir(s, C)?; Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m, "sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()})) diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index ae202f192..1cba9f648 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -277,6 +277,29 @@ fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() { let _ = std::fs::remove_dir_all(&x.dir); } +/// Another app's model folder (#1381): an `.onnx` with no manifest, then with that app's `model.json` beside it, then +/// with the `model.json` renamed. Each refusal says what is wrong and where LightCraft's manifest is described. +#[test] +fn inspecting_another_apps_model_folder_says_what_is_missing() { + let dir = temp_dir("denoise-foreign"); + let mut s = Session::new().with_fs(); + s.set_denoise_models_dir(Some(dir.join("denoise-models"))); + let src = dir.join("rawdenoise"); + std::fs::create_dir_all(&src).unwrap(); + let onnx = src.join("rawdenoise.onnx"); + std::fs::write(&onnx, b"not an onnx file").unwrap(); + let mut inspect = || s.execute("denoise.models.inspect", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string(); + let e = inspect(); + assert!(e.contains("No denoise-model.json beside") && e.contains("docs/denoise.md") && !e.contains("model.json there"), "{e}"); + std::fs::write(src.join("model.json"), json!({"name": "rawdenoise", "input": {"channels": 4}, "scale": 2}).to_string()).unwrap(); + let e = inspect(); + assert!(e.contains("No denoise-model.json beside") && e.contains("The model.json there") && e.contains("docs/denoise.md"), "{e}"); + std::fs::rename(src.join("model.json"), src.join("denoise-model.json")).unwrap(); + let e = inspect(); + assert!(e.contains("not a denoise-model manifest") && e.contains("docs/denoise.md"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() { let mut x = setup("background", true); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..0f56d79bf 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -508,6 +508,45 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Install from file… with a model the engine rejects says why in the AI Denoise section (#1381), instead of + /// leaving the button looking dead. + #[test] + fn a_rejected_model_file_says_why_in_settings() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-denoise-rejected-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); + // without the runtime the button is disabled (the workspace build turns it on through the desktop app) + if h.app.session.execute("denoise.models.list", &json!({})).unwrap()["runtime"] != true { + let _ = std::fs::remove_dir_all(&dir); + return; + } + // another app's model folder: the .onnx with that app's model.json, and no denoise-model.json + let onnx = dir.join("rawdenoise.onnx"); + std::fs::write(&onnx, b"not a model").unwrap(); + std::fs::write(dir.join("model.json"), br#"{"name": "rawdenoise", "scale": 2}"#).unwrap(); + let picked = onnx.to_string_lossy().into_owned(); + h.app.services.pick_denoise_model = Some(Box::new(move || vec![picked.clone()])); + h.app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() }); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "denoise:installFile"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); + let painted = h.painted_text(); + assert!( + painted + .iter() + .any(|s| s.starts_with("No denoise-model.json beside this .onnx file. The model.json there") && s.ends_with("(see docs/denoise.md)")), + "{painted:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + use super::*; /// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index bf89acd26..75488f76b 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -35,6 +35,8 @@ pub struct Ui { /// Models the user pressed Download for that have not been installed yet. dl_watch: Vec, list: Option<(u64, f64, Value)>, + /// Why the last file picked with Install from file… was refused, shown under the button. + rejected: Option, } /// Called every frame: keeps the engine's denoise work going and asks for a redraw when a photo's picture appears (the @@ -247,9 +249,17 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { register(ui.ctx(), "denoise:installFile", r.rect); if r.clicked() && let Some(path) = app.services.pick_denoise_model.as_mut().and_then(|f| f().into_iter().next()) - && let Ok(info) = app.run("denoise.models.inspect", json!({"path": path})) { - app.ui.dialog = Some(crate::state::Dialog::DenoiseModel { info, accepted: false }); + match app.session.execute("denoise.models.inspect", &json!({"path": path})) { + Ok(info) => { + app.caches.denoise.rejected = None; + app.ui.dialog = Some(crate::state::Dialog::DenoiseModel { info, accepted: false }); + } + Err(e) => app.caches.denoise.rejected = Some(plain(&e)), + } + } + if let Some(why) = &app.caches.denoise.rejected { + hint(ui, t, why); } if all.is_empty() { hint(ui, t, "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads."); @@ -550,6 +560,14 @@ pub fn install(app: &mut LightcraftApp, info: &Value, accepted: bool) -> Result< r } +/// What an engine error says, without the command it came from (the install job's errors read the same way). +fn plain(e: &lightcraft_engine::EngineError) -> String { + match e { + lightcraft_engine::EngineError::BadParams { msg, .. } => msg.clone(), + other => other.to_string(), + } +} + /// "1 photo" / "3 photos": the count with the right noun. fn count(n: u64, one: &str, many: &str) -> String { tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })) From 77d8558fc4e334ca7c75311f5edbc9a140d0e0fc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 02:55:39 +0200 Subject: [PATCH 130/142] LR-CROP-STRAIGHTEN: a crop shrunk to fit by Straighten grows back as the angle returns (fixes #1388) --- crates/engine/src/tests_crop_angle.rs | 22 ++++++++++++++++-- crates/geom/src/crop.rs | 33 +++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests_crop_angle.rs index 47759d60c..90a12b2d0 100644 --- a/crates/engine/src/tests_crop_angle.rs +++ b/crates/engine/src/tests_crop_angle.rs @@ -81,11 +81,11 @@ fn quick_develop_reset_and_batch_paths_fit_too() { let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]})); assert!(close(got, want), "develop.set ids {got:?} vs {want:?}"); - // back to 0°: the crop that fitted at 12° fits at 0° too and is kept + // back to 0°: the crop shrunk to fit at 12° grows back to the full photo (issue #1388) s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap(); s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap(); let g = geometry(&s); - assert_eq!((g.rect, g.angle), (want.rect, 0.0)); + assert_eq!((g.rect, g.angle), (Rect::UNIT, 0.0)); assert!(g.is_within_image(w, h)); } @@ -106,3 +106,21 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert!(close(geometry(&s), start)); } + +#[test] +fn a_crop_shrunk_by_straightening_grows_back_towards_0() { + // issue #1388: slide the Straighten slider out and back, in one drag or in several + let mut s = Session::with_demo(); + let (w, h) = dims(&s); + s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap(); + for angle in [5.0, 12.0, 20.0, 8.0, -20.0, -3.0, 0.0] { + s.execute("crop.straighten", &json!({"angle": angle})).unwrap(); + let g = geometry(&s); + assert!(close(g, lightcraft_geom::crop_fit_angle(w, h, angle, None)), "the largest crop at {angle}°, got {g:?}"); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + s.execute("crop.straighten", &json!({"angle": 20.0})).unwrap(); + s.execute("crop.straighten", &json!({"angle": 0.0})).unwrap(); + let g = geometry(&s); + assert!(close(g, CropGeometry::default()), "two separate drags: back to the full photo at 0°, got {g:?}"); +} diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs index 810e39cd4..857ea19b9 100644 --- a/crates/geom/src/crop.rs +++ b/crates/geom/src/crop.rs @@ -84,15 +84,21 @@ impl CropGeometry { /// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's /// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle, /// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so - /// they all give the same crop (issue #742). + /// they all give the same crop (issue #742). A crop that is the largest of its aspect at its + /// own angle (the full photo, or one an earlier turn shrank to fit) stays the largest at the new + /// angle, so it grows back as the angle returns towards 0° (issue #1388). pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry { - let cand = CropGeometry { rect: self.rect, angle: angle_deg }; - if cand.is_within_image(w, h) { - return cand; - } let r = self.rect_px(w, h); // a degenerate rect has no aspect to keep: fall back to the image's own let aspect = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0); + let largest = crop_fit_angle(w, h, self.angle, aspect).rect; + let is_largest = [(self.rect.x0, largest.x0), (self.rect.y0, largest.y0), (self.rect.x1, largest.x1), (self.rect.y1, largest.y1)] + .iter() + .all(|(a, b)| (a - b).abs() < 1e-9); + let cand = CropGeometry { rect: self.rect, angle: angle_deg }; + if !is_largest && cand.is_within_image(w, h) { + return cand; + } crop_fit_angle(w, h, angle_deg, aspect) } } @@ -172,6 +178,23 @@ mod tests { } } + #[test] + fn with_angle_grows_a_fitted_crop_back_as_the_angle_returns() { + // issue #1388: the crop a turn shrank to fit is the largest of its aspect; turning back + // gives the largest at the new angle, not the shrunk one again + for aspect in [None, Some(1.0), Some(16.0 / 9.0)] { + let mut c = crop_fit_angle(600.0, 400.0, 0.0, aspect); + for ang in [10.0, 25.0, -25.0, 7.0, 0.0] { + c = c.with_angle(ang, 600.0, 400.0); + let want = crop_fit_angle(600.0, 400.0, ang, aspect); + assert!( + (c.rect.width() - want.rect.width()).abs() < 1e-9 && (c.rect.height() - want.rect.height()).abs() < 1e-9, + "{aspect:?} at {ang}°: {c:?} vs {want:?}" + ); + } + } + } + #[test] fn with_angle_survives_a_degenerate_crop() { // zero height: no aspect to keep (width / height = ∞) From 58822f6229e898d4d6b4c4f0b1cdb03d1892aba8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Mon, 12 Oct 2026 21:03:15 -0400 Subject: [PATCH 131/142] LR-BEHAV-LOCALIZE: strong text is heavier in scripts whose fallback face is a variable font The semibold family had a semibold Inter but, for other scripts, the same regular craft-fonts faces as regular text, so a heading or the chosen tab in Arabic was drawn at regular weight beside bold Latin. A fallback face with a weight axis (today Noto Sans Arabic, 100 to 900) is now registered a second time for the semibold family at weight 600, through epaint's FontTweak::coords. The bytes are borrowed from the embedded font, not copied. Faces with one fixed weight, regular and monospace text, the order of fallbacks and Latin are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/theme.rs | 153 +++++++++++++++++++++++++++++++++++- docs/parity-checklist.md | 2 +- docs/whats-new.md | 6 ++ 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a87a196ae..f8657b5cc 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -292,6 +292,14 @@ pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFo fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes))); } } + // A variable face holds every weight in its one file: strong text draws it heavier, from the + // same bytes. A face with one fixed weight stays as it is. + for name in &mut bold { + let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) else { continue }; + let Some(heavier) = strong_weight(font.bytes) else { continue }; + *name = format!("{name} strong"); + fonts.font_data.insert(name.clone(), Arc::new(heavier)); + } let script = crate::i18n::language().script(); let prefer_chinese = chinese.is_some() && matches!(script, "Hans" | "Hant"); if let Some(font) = chinese { @@ -339,6 +347,22 @@ pub fn font_credits(chinese_loaded: bool) -> String { families.join(" / ") } +/// The weight of strong text in a variable face, to go with Inter SemiBold. +const STRONG_WEIGHT: f32 = 600.0; + +/// `bytes` as a face drawn at [`STRONG_WEIGHT`], when it is a variable font whose weight axis goes +/// heavier than the weight it draws at by default. The bytes are borrowed, not copied. +fn strong_weight(bytes: &'static [u8]) -> Option { + let face = FontData::from_static(bytes); + let axis = face.variation_axes().into_iter().find(|axis| axis.tag.to_be_bytes() == *b"wght")?; + let weight = STRONG_WEIGHT.min(axis.range.max); + if weight.partial_cmp(&axis.default) != Some(std::cmp::Ordering::Greater) { + return None; + } + let tweak = egui::FontTweak { coords: egui::epaint::text::VariationCoords::new([(axis.tag, weight)]), ..Default::default() }; + Some(face.tweak(tweak)) +} + fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } @@ -422,8 +446,11 @@ mod tests { let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); let name = craft_font_name(thai); assert!(definitions.font_data.contains_key(&name)); + // (The strong family has the face's heavier twin instead when it is a variable font.) + let strong = format!("{name} strong"); for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] { - assert!(definitions.families.get(&family).is_some_and(|names| names.contains(&name)), "{family:?} falls back on {name}"); + let names = definitions.families.get(&family).expect("UI font family"); + assert!(names.contains(&name) || (family == semibold_family() && names.contains(&strong)), "{family:?} falls back on {name}"); } } @@ -505,4 +532,128 @@ mod tests { assert_eq!(names.get(2 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT)); set_language(Locale::En); } + + const ARABIC_WORD: &str = "الصور"; + + fn semibold_family() -> FontFamily { + FontFamily::Name(FONT_SEMIBOLD.into()) + } + + /// The width of `text` in `family` at the body size, and the ink its glyphs put in the font + /// atlas (summed coverage), with `definitions` installed. + fn width_and_ink(definitions: FontDefinitions, text: &str, family: FontFamily) -> (f32, u64) { + let ctx = egui::Context::default(); + ctx.set_fonts(definitions); + let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {}); + frame.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let galley = fonts.layout_no_wrap(text.into(), FontId::new(13.0, family), Color32::WHITE); + let atlas = fonts.image(); + let mut ink = 0_u64; + for glyph in galley.rows.iter().flat_map(|row| row.glyphs.iter()) { + let (min, max) = (glyph.uv_rect.min, glyph.uv_rect.max); + for y in min[1]..max[1] { + for x in min[0]..max[0] { + ink += u64::from(atlas[(usize::from(x), usize::from(y))].a()); + } + } + } + (galley.size().x, ink) + }) + } + + /// The craft-fonts face for Arabic, when it is a variable font with a weight axis. + fn variable_arabic_face() -> Option<&'static lightcraft_engine::CraftFont> { + let face = lightcraft_engine::CRAFT_FONTS.iter().find(|font| font.covers("Arab"))?; + FontData::from_static(face.bytes).variation_axes().iter().any(|axis| axis.tag.to_be_bytes() == *b"wght").then_some(face) + } + + /// Strong text in a script whose fallback face is a variable font is really heavier: an Arabic + /// tab name used to be drawn at regular weight beside a bold Latin one. + #[test] + fn strong_arabic_text_is_heavier_than_regular_with_a_variable_face() { + // Given a build with craft-fonts whose Arabic face has a weight axis + if variable_arabic_face().is_none() { + eprintln!("skipped: built without a variable-weight craft-fonts face for Arabic"); + return; + } + set_language(Locale::En); + // When an Arabic word is laid out regular and strong at the same size + let (regular_width, regular_ink) = width_and_ink(font_definitions(lightcraft_engine::CRAFT_FONTS), ARABIC_WORD, FontFamily::Proportional); + let (strong_width, strong_ink) = width_and_ink(font_definitions(lightcraft_engine::CRAFT_FONTS), ARABIC_WORD, semibold_family()); + eprintln!("{ARABIC_WORD}: regular {regular_width} px, ink {regular_ink}; strong {strong_width} px, ink {strong_ink}"); + // Then the strong one is wider and puts down more ink + assert!(strong_width > regular_width, "strong Arabic is wider than regular: strong {strong_width} px, regular {regular_width} px"); + assert!(strong_ink as f64 > regular_ink as f64 * 1.05, "strong Arabic has more ink than regular: strong {strong_ink}, regular {regular_ink}"); + } + + /// The heavier face is for strong text only: regular and monospace text keep the face as the + /// font file has it, and Latin text keeps Inter in both weights. + #[test] + fn regular_text_and_latin_are_unchanged_by_the_strong_variable_face() { + // Given a build with craft-fonts whose Arabic face has a weight axis + let Some(arabic) = variable_arabic_face() else { + eprintln!("skipped: built without a variable-weight craft-fonts face for Arabic"); + return; + }; + set_language(Locale::En); + // When the UI builds its fonts + let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); + // Then the regular Arabic face is the font file untouched, and the only Arabic face of + // the regular and monospace families + let name = craft_font_name(arabic); + assert_eq!(definitions.font_data.get(&name).map(|data| &data.tweak), Some(&egui::FontTweak::default())); + let plain_weight = egui::epaint::text::VariationCoords::default(); + for family in [FontFamily::Proportional, FontFamily::Monospace] { + let names = definitions.families.get(&family).expect("UI font family"); + assert!(names.contains(&name), "{family:?} falls back on {name}"); + for other in names.iter().filter(|other| **other != name) { + let coords = definitions.font_data.get(other).map(|data| data.tweak.coords.clone()).unwrap_or_default(); + assert_eq!(coords, plain_weight, "{family:?}: {other} is drawn at the weight its file has"); + } + } + // And regular Arabic text is as wide as with that face alone after Inter + let mut plain = FontDefinitions::default(); + plain.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + plain.font_data.insert("Arabic".into(), Arc::new(FontData::from_static(arabic.bytes))); + plain.families.insert(FontFamily::Proportional, vec!["Inter".into(), "Arabic".into()]); + let (width, ink) = width_and_ink(definitions.clone(), ARABIC_WORD, FontFamily::Proportional); + assert_eq!((width, ink), width_and_ink(plain, ARABIC_WORD, FontFamily::Proportional)); + // And Latin text, strong and regular, is as it is without craft-fonts + for family in [FontFamily::Proportional, semibold_family()] { + assert_eq!( + width_and_ink(definitions.clone(), "Library", family.clone()), + width_and_ink(font_definitions(&[]), "Library", family.clone()), + "{family:?}" + ); + } + let (regular, _) = width_and_ink(definitions.clone(), "Library", FontFamily::Proportional); + let (strong, _) = width_and_ink(definitions, "Library", semibold_family()); + assert!(strong > regular, "strong Latin stays wider than regular: {strong} px, {regular} px"); + } + + /// A face with one fixed weight is registered once: nothing heavier can be asked of it. Without + /// craft-fonts there is nothing to register at all. + #[test] + fn a_static_face_has_no_strong_twin() { + // Given a face without a weight axis for another script + static FACES: &[lightcraft_engine::CraftFont] = &[lightcraft_engine::CraftFont { + family: "Static Sans", + style: "Regular", + scripts: &["Thai"], + bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), + }]; + set_language(Locale::En); + for craft in [FACES, &[]] { + // When the UI builds its fonts + let definitions = font_definitions(craft); + // Then no face is drawn at another weight than its file has, and the strong family + // holds what the regular one does after Inter + assert!(definitions.font_data.values().all(|data| data.tweak.coords == egui::epaint::text::VariationCoords::default())); + let regular = definitions.families.get(&FontFamily::Proportional).expect("regular family"); + let strong = definitions.families.get(&semibold_family()).expect("strong family"); + assert_eq!(regular.get(1..), strong.get(1..)); + assert_eq!(definitions.font_data.len(), FontDefinitions::default().font_data.len() + 2 + craft.len()); + } + } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..7a7ef9f39 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -474,7 +474,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..e8df59785 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-12 + +### Bold text in other scripts +- Arabic names and other Arabic text in headings, chosen tabs and other bold places are now drawn bold, as Latin text + beside them is; they used to stay at regular weight. + ## 2026-10-11 ### JPEG XL export From c8f676252feef41d09f607a2db2289d2053941fa Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 00:39:32 +0200 Subject: [PATCH 132/142] M5.6: with a GPU, a slider drag's preview is drawn in the frame of the drag tick A drag tick requested the loupe's draft and the frame ended; the draft, done in 1.5-3 ms on the GPU, was picked up at the start of the next frame, a frame after the slider moved. Now, in a drag, the loupe waits up to 40 % of a frame for its draft when the last loupe render took well under that (not with a render offload, nor zoomed in, where what changes on screen is the window's draft). A CPU render, which would only make the frame late, is not waited for. JobPool::wait waits for the next finished job, which try_recv then hands out; Renderer::poll's collection of finished jobs is split out so the wait can turn the draft into the texture. Co-Authored-By: Claude --- crates/preview/src/pool.rs | 15 +++++++- crates/preview/src/tests.rs | 13 +++++++ crates/ui-egui/src/panels/detail.rs | 9 +++++ crates/ui-egui/src/render.rs | 59 ++++++++++++++++++++++++++--- docs/whats-new.md | 7 ++++ 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs index 4dbd35661..afdf27414 100644 --- a/crates/preview/src/pool.rs +++ b/crates/preview/src/pool.rs @@ -56,6 +56,8 @@ pub struct JobPool { shared: Arc>, tx: Sender>, rx: Receiver>, + /// A finished job [`Self::wait`] took off the channel, handed out first by [`Self::try_recv`]. + early: Option>, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] threads: usize, started: bool, @@ -82,6 +84,7 @@ impl JobPool { }), tx, rx, + early: None, threads, started: false, seq: 0, @@ -210,8 +213,16 @@ impl JobPool { } /// A finished job, if any. - pub fn try_recv(&self) -> Option> { - self.rx.try_recv().ok() + pub fn try_recv(&mut self) -> Option> { + self.early.take().or_else(|| self.rx.try_recv().ok()) + } + + /// Wait up to `timeout` for a job to finish; whether one has (it is the next [`Self::try_recv`]). + pub fn wait(&mut self, timeout: Duration) -> bool { + if self.early.is_none() { + self.early = self.rx.recv_timeout(timeout).ok(); + } + self.early.is_some() } /// Run up to `n` jobs on the calling thread (wasm, tests). Returns how many ran. diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index d733ad829..690c60b4a 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -351,3 +351,16 @@ fn disk_cache_never_touches_foreign_files() { assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); let _ = std::fs::remove_dir_all(&dir); } + +/// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or +/// after the timeout. +#[test] +fn pool_wait_keeps_the_finished_job_for_try_recv() { + let mut p: JobPool = JobPool::new(1); + assert!(!p.wait(std::time::Duration::from_millis(10)), "nothing runs"); + p.submit(1, 0, 1, Box::new(|| 7)); + assert!(p.wait(std::time::Duration::from_secs(10))); + assert!(p.wait(std::time::Duration::ZERO), "kept until taken"); + assert_eq!(p.try_recv().map(|d| d.result), Some(7)); + assert!(p.try_recv().is_none()); +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..e93d727d0 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -473,6 +473,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); + // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed + // in, what changes on screen is the window's draft) + #[cfg(not(target_arch = "wasm32"))] + if interacting && plan.window_edge.is_none() { + let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); + // (a frame time that is not a number waits for nothing) + let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); + app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); + } } // hovering a preset or profile: the photo with that look, shown instead of the loupe render // once it is ready (nothing is committed) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..246decfe2 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -649,6 +649,37 @@ impl Renderer { }; #[cfg(not(target_arch = "wasm32"))] let inline_ms = 0.0; + let changed = self.collect(ctx, session, inline_ms); + self.evict_variants(); + #[cfg(not(target_arch = "wasm32"))] + self.trim_when_idle(ctx); + if !self.pending.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + changed + } + + /// In a slider drag, give the loupe's draft (just requested) until `budget` to finish, so that it + /// is drawn in this frame rather than picked up at the start of the next one. Only when the last + /// loupe render took well under the budget (a GPU draft takes a few milliseconds; a CPU render + /// that would only make the frame late is not waited for). + #[cfg(not(target_arch = "wasm32"))] + pub fn wait_for_draft(&mut self, ctx: &egui::Context, session: &mut Session, budget: std::time::Duration) { + if self.offload.is_some() || self.last_main_ms * 1.5 >= budget.as_secs_f64() * 1e3 { + return; + } + let Some(deadline) = std::time::Instant::now().checked_add(budget) else { return }; + while self.is_pending(Slot::Main) { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() || !self.pool.wait(left) { + break; + } + self.collect(ctx, session, 0.0); + } + } + + /// Turn finished jobs into textures. Returns true if anything changed. + fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool { let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms })); @@ -757,12 +788,6 @@ impl Renderer { } changed = true; } - self.evict_variants(); - #[cfg(not(target_arch = "wasm32"))] - self.trim_when_idle(ctx); - if !self.pending.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } changed } @@ -953,6 +978,28 @@ mod thumbnail_tests { assert!(!r.thumb_current(&p, 384, 10)); } + #[test] + fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { + let mut s = Session::new(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); + s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let mut r = Renderer::with_worker_threads(1); + let ctx = egui::Context::default(); + let budget = std::time::Duration::from_secs(10); + // the last loupe render took longer than the budget (a CPU render): not waited for + r.last_main_ms = 1e6; + r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); + // a fast one (GPU drafts): the texture is the new render when the wait returns + r.last_main_ms = 1.0; + r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); + let want = r.wanted(Slot::Main); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(!r.is_pending(Slot::Main)); + assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); + } + #[derive(Default)] struct Work { jobs: Vec<(Slot, RenderJob)>, diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..c9e3fc3bc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-13 + +### Slider drags follow the pointer more closely +- With a GPU, a slider drag's preview is drawn in the same frame as the slider moves instead of one frame later, + when the preview renders in a few milliseconds (it waits at most 40 % of a frame for it; slower renders, such as + on the CPU, are not waited for). + ## 2026-10-11 ### JPEG XL export From a10bc04b8bc72e3cd0c0a19389ba414a17e6958f Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:27:54 +0300 Subject: [PATCH 133/142] smart previews: store proxies at JPEG quality 97 and version the encoding (#709) The proxy's largest visible error against the original was the JPEG encoder's quantised 8x8 DCT DC - each block's own mean, luma and chroma - which shows as block steps across the frame and a small constant colour shift, and grows with saturation. Quality 92 -> 97 shrinks those quantisers to about a third (block -mean dE76 0.138 vs 0.202 on the fidelity test's smooth scene), at about twice the file size; what remains is the 8-bit sRGB quantisation both encoders share, which only a lossless proxy format can close. The header gains an `enc` field (encode version 2) and the folder's look marker becomes " ": a proxy or marker from before the encode version is rebuilt/rescanned once, so existing libraries pick the new quality up without any command. Old files still decode bit-identically (the scale mapping is untouched, decode ignores `enc`), and a new proxy read by an older build is left alone by its staleness check. The fidelity test pins the gain with a frozen copy of the old encoder and a block-mean dE76 metric (per-pixel dE is dominated by the shared 8-bit floor); the 0.8 bound is the geometric midpoint between the no-op ratio 1.000 and the 0.632 floor cap the 8-bit quantisation sets on this scene. --- crates/engine/src/smart.rs | 256 ++++++++++++++++++++++++++++-- crates/engine/src/tests_import.rs | 10 +- 2 files changed, 249 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 3628f188c..46b18e0c9 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -55,6 +55,28 @@ const HEADER_MAX: u64 = 4096; /// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve. const LOOK_FIELD: &str = "look_version"; +/// The JPEG quality a smart preview's pixels are stored at. The original 92 left the 8×8 DCT's +/// quantised DC — each block's own mean, luma and chroma — visible as block steps across the frame +/// and as a small constant colour shift; that is the largest part of what a proxy gets wrong +/// against the original, and the only part a parameter can reach. 97 shrinks those quantisers to +/// roughly 1/2.6 of their steps, which is what removes the steps, at about twice the file size +/// (~1 MB per preview at 2560 px). What is left is the 8-bit sRGB quantisation itself, which only +/// a lossless proxy format can close. (ICED: the measured change is printed by the fidelity test +/// below; drop this to 95 for ~1.4× the size and roughly half the gain.) +const JPEG_QUALITY: u8 = 97; + +/// The header field that records which version of this module's own encoding wrote a proxy's pixels +/// (the source mapping above and [`JPEG_QUALITY`]). Not to be confused with [`LOOK_FIELD`], which +/// tracks the camera-look fit that is baked into those pixels. +const ENC_FIELD: &str = "enc"; + +/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG +/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which +/// makes every proxy from before this version stale. Version 2 is the first to record the field at +/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]). +/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal. +pub(crate) const ENCODE_VERSION: u64 = 2; + /// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview. pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String> { // scale so all but the brightest 0.05 % fit into 0..1 @@ -78,13 +100,19 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String let rgba = Rgba8 { width: img.width, height: img.height, data }; let jpg = lightcraft_codecs::encode_jpeg( &lightcraft_codecs::EncodeImage::rgba8(&rgba), - 92, + JPEG_QUALITY, lightcraft_codecs::ChromaSubsampling::S444, &Default::default(), ) .map_err(|e| e.to_string())?; let mut out = MAGIC.to_vec(); - let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + let mut head = serde_json::json!({ + "w": img.width, + "h": img.height, + "scale": scale, + LOOK_FIELD: crate::camera_preview::LOOK_VERSION, + ENC_FIELD: ENCODE_VERSION, + }); if let Some(t) = tone { head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; } @@ -265,10 +293,17 @@ fn header_look_version(head: &serde_json::Value) -> u64 { head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) } -/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's -/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a -/// newer build is left alone (rebuilding it here would only undo that build's work), as is one -/// that can't be read: those are [`is_valid`]'s business. +/// The encode version a header records: a missing, negative, fractional or otherwise malformed +/// value counts as 0 (older than any real version), like [`header_look_version`]. +fn header_encode_version(head: &serde_json::Value) -> u64 { + head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) +} + +/// Whether the complete proxy at `path` is out of date for this build: written by an older +/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are +/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG +/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that +/// build's work), as is one that can't be read: those are [`is_valid`]'s business. pub fn is_stale(path: &Path) -> bool { use std::io::Read; if !is_valid(path) { @@ -283,15 +318,18 @@ pub fn is_stale(path: &Path) -> bool { let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; rest.get(..nl) .and_then(|h| serde_json::from_slice::(h).ok()) - .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION)) + .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION) } -/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked -/// at ([`look_checked`]). +/// The marker file in a smart previews folder: the camera-look fit version and encode version its +/// proxies were last checked at ([`look_checked`]). const LOOK_MARKER: &str = "look-version"; -/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's -/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not. +/// Whether the smart previews folder `dir` has been checked for out-of-date proxies at this build's +/// camera-look fit version and encode version (or newer ones). A missing or unreadable marker counts +/// as not, and so does one written before the encode version was recorded — it only says the look +/// was checked, so this build scans the folder once more and rebuilds the proxies [`is_stale`] +/// finds. pub fn look_checked(dir: &Path) -> bool { use std::io::Read; let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false }; @@ -299,14 +337,20 @@ pub fn look_checked(dir: &Path) -> bool { if f.take(64).read_to_string(&mut text).is_err() { return false; } - text.trim().parse::().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION)) + let mut versions = text.split_whitespace(); + let (Some(look), Some(enc)) = (versions.next(), versions.next()) else { return false }; + match (look.parse::(), enc.parse::()) { + (Ok(look), Ok(enc)) => look >= u64::from(crate::camera_preview::LOOK_VERSION) && enc >= ENCODE_VERSION, + _ => false, + } } -/// Record that the proxies in `dir` were checked at this build's camera-look fit version. +/// Record that the proxies in `dir` were checked at this build's camera-look fit version and encode +/// version. pub fn mark_look_checked(dir: &Path) -> Result<(), String> { let path = dir.join(LOOK_MARKER); - lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes()) - .map_err(|e| format!("{}: {e}", path.display())) + let text = format!("{} {}\n", crate::camera_preview::LOOK_VERSION, ENCODE_VERSION); + lightcraft_catalog::safe_file::write_atomic(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) } /// Load the proxy at `path`. @@ -451,4 +495,186 @@ mod tests { assert!(mean < 0.02, "mean encoded error {mean}"); assert!(decode(b"nope").is_err()); } + + /// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into + /// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never + /// enters what is measured. Built from a rendered demo scene so the dimensions and the flat + /// row-major layout are the real ones. + fn smooth_scene(w: usize, h: usize) -> Rgb32f { + let mut img = lightcraft_scenes::demo_library()[0].render(w, h); + let (w, h) = (img.width.max(1), img.height.max(1)); + for (i, px) in img.data.iter_mut().enumerate() { + let u = (i % w) as f32 / (w - 1).max(1) as f32; + let v = (i / w) as f32 / (h - 1).max(1) as f32; + let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step + let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; + let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; + *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); + } + img + } + + /// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The + /// input is clamped into 0..1, the only range a proxy ever stores. + fn lab(c: [f32; 3]) -> [f64; 3] { + let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); + let (x, y, z) = ( + (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, + 0.2126 * r + 0.7152 * g + 0.0722 * b, + (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, + ); + let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] + } + + /// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a + /// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. + fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let n = a.data.len().min(b.data.len()); + if n == 0 { + return 0.0; + } + (0..n) + .map(|i| { + let (p, q) = (lab(a.data[i]), lab(b.data[i])); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }) + .sum::() + / n as f64 + } + + /// A deliberately frozen copy of the pixel mapping and container this module used before the + /// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the + /// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on + /// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the + /// quality raise bought instead of trusting a written-down number. + fn encode_at_q92(img: &Rgb32f) -> Vec { + let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); + let scale = if lum.is_empty() { + 1.0 + } else { + let k = ((lum.len() as f64) * 0.9995) as usize; + let k = k.min(lum.len() - 1); + let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); + v.max(1.0) + }; + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let rgba = Rgba8 { width: img.width, height: img.height, data }; + let jpg = lightcraft_codecs::encode_jpeg( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + 92, + lightcraft_codecs::ChromaSubsampling::S444, + &Default::default(), + ) + .unwrap(); + // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field + let mut out = MAGIC.to_vec(); + let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + out.extend_from_slice(head.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(&jpg); + out + } + + /// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one + /// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its + /// mean, and rebuilds the whole block from it, so a proxy differs from the original by a + /// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels + /// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share + /// eightfold, which is what a per-pixel ΔE drowns in. + fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let w = a.width.min(b.width).max(1); + let h = a.height.min(b.height).max(1); + let (mut sum, mut n) = (0.0f64, 0usize); + for by in (0..h).step_by(8) { + for bx in (0..w).step_by(8) { + let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); + for y in by..(by + 8).min(h) { + for x in bx..(bx + 8).min(w) { + if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { + for k in 0..3 { + sa[k] += f64::from(ca[k]); + sb[k] += f64::from(cb[k]); + } + count += 1; + } + } + } + let d = f64::from(count.max(1)); + let (pa, pb) = ( + lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), + lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), + ); + sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); + n += 1; + } + } + sum / n.max(1) as f64 + } + + /// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit + /// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, + /// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the + /// quality raise is reverted and never trips on the honest result (measured 0.683). + const PROXY_BLOCK_GAIN: f64 = 0.8; + + #[test] + fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { + let img = smooth_scene(256, 160); + let ours = decode(&encode(&img, None).unwrap()).unwrap().0; + let before = decode(&encode_at_q92(&img)).unwrap().0; + let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); + let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); + println!( + "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ + (per-pixel {:.3} vs {:.3})", + block.0, block.1, pixel.0, pixel.1 + ); + assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); + assert!( + block.0 <= PROXY_BLOCK_GAIN * block.1, + "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ + (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", + block.0, + PROXY_BLOCK_GAIN * block.1, + block.1 + ); + } + + #[test] + fn a_proxy_from_an_older_encode_is_stale() { + let dir = temp("encode-version"); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let bytes = encode(&img, None).unwrap(); + let path = dir.join("p.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + assert!(!is_stale(&path), "this build's own proxy is up to date"); + // one written before the encode version was recorded is not + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); + let mut older = head.as_object().cloned().unwrap_or_default(); + older.remove(ENC_FIELD); + let mut old = MAGIC.to_vec(); + old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); + old.extend_from_slice(&bytes[nl..]); + std::fs::write(&path, &old).unwrap(); + assert!(is_valid(&path), "losing the field keeps the file readable"); + assert!(is_stale(&path), "an older encoding is rebuilt"); + // the marker records both versions, and one from before the encode version forces a scan + assert!(!look_checked(&dir)); + mark_look_checked(&dir).unwrap(); + assert!(look_checked(&dir)); + std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); + assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 185993494..5ea368d61 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1404,14 +1404,20 @@ fn smart_preview_look_marker_is_read_safely() { let dir = temp_dir("smartlook-marker"); assert!(!crate::smart::look_checked(&dir), "no marker"); let cur = crate::camera_preview::LOOK_VERSION; + let enc = crate::smart::ENCODE_VERSION; for (text, checked) in [ (String::new(), false), ("banana".into(), false), ("-1".into(), false), (format!("{}", cur.saturating_sub(1)), false), ("9".repeat(200), false), - (format!("{cur}\n"), true), - (format!(" {}\n", cur + 1), true), + // Written before the encode version was recorded: only says the look was checked, so the + // folder is scanned once more and the old-encoding proxies are rebuilt. + (format!("{cur}\n"), false), + (format!("{cur} {}\n", enc.saturating_sub(1)), false), + (format!("{cur} banana\n"), false), + (format!("{cur} {enc}\n"), true), + (format!(" {} {}\n", cur + 1, enc + 1), true), ] { std::fs::write(dir.join("look-version"), &text).unwrap(); assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}"); From 95d519ae643210be5f71814280496eb0192db10a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 11:04:18 +0700 Subject: [PATCH 134/142] Batch 32: cargo fmt --- crates/ui-egui/src/panels/edit.rs | 44 ++++++++++++++++++++++------ crates/ui-egui/src/panels/grid.rs | 9 ++---- crates/ui-egui/src/panels/presets.rs | 23 +++++++++++---- crates/ui-egui/src/panels/topbar.rs | 4 ++- 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 817f37279..9d8f30229 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -160,7 +160,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, crate::i18n::tr("Browse Profiles")).clicked() { + if crate::widgets::icon_button( + ui, + "profileBrowser", + Icon::ProfileGrid, + vec2(28.0, 28.0), + false, + true, + crate::i18n::tr("Browse Profiles"), + ) + .clicked() + { let _ = app.run("panel.profiles", json!({})); } }); @@ -230,8 +240,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let active = app.ui.tool == "wbPicker"; - if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, crate::i18n::tr("White Balance Selector (W)")) - .clicked() + if crate::widgets::icon_button( + ui, + "wbPicker", + Icon::Picker, + vec2(28.0, 28.0), + active, + true, + crate::i18n::tr("White Balance Selector (W)"), + ) + .clicked() { if active { app.ui.tool.clear(); @@ -1226,8 +1244,16 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.spacing_mut().item_spacing.x = 5.0; let active = app.ui.tool == "pointColor"; let full = n >= lightcraft_develop::MAX_POINT_COLORS; - if crate::widgets::icon_button(ui, "pointColorPicker", Icon::Picker, vec2(26.0, 26.0), active, !full, crate::i18n::tr("Sample a colour on the photo")) - .clicked() + if crate::widgets::icon_button( + ui, + "pointColorPicker", + Icon::Picker, + vec2(26.0, 26.0), + active, + !full, + crate::i18n::tr("Sample a colour on the photo"), + ) + .clicked() { app.ui.tool = if active { String::new() } else { "pointColor".into() }; } @@ -1311,7 +1337,9 @@ fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, phot ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (key, tip, icon) in GRADING_TABS { - if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)).clicked() { + if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)) + .clicked() + { app.ui.grading_mode = key.to_string(); } } @@ -1519,9 +1547,7 @@ mod curve_histogram_tests { #[test] fn curve_backdrop_tracks_luma_and_selected_channel() { - let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| { - if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] } - }); + let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] }); let h = lightcraft_raster::Histogram::of_srgb8(&img); let red = curve_histogram_profile(&h, "red"); let green = curve_histogram_profile(&h, "green"); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 172124bcf..1f7db4f6e 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1284,12 +1284,9 @@ mod tests { fn library_context_menu_labels_have_french_translations() { let saved = crate::i18n::language(); crate::i18n::set_language(crate::i18n::Locale::Fr); - for (label, expected) in [ - ("Pick", "Sélectionnée"), - ("Reject", "Rejeter"), - ("Group into Stack", "Regrouper en pile"), - ("HDR Panorama…", "Panorama HDR…"), - ] { + for (label, expected) in + [("Pick", "Sélectionnée"), ("Reject", "Rejeter"), ("Group into Stack", "Regrouper en pile"), ("HDR Panorama…", "Panorama HDR…")] + { assert_eq!(crate::i18n::tr(label), expected); } crate::i18n::set_language(saved); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 11e3b84bc..3e08ccf3d 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -38,9 +38,10 @@ impl AppliedPreset { && self.commits == session.commits() && session.interaction.is_none() && session.redo.is_empty() - && session.undo.last().is_some_and(|entry| { - session.presets.iter().any(|preset| preset.id == self.id && entry.label == format!("Preset: {}", preset.name)) - }) + && session + .undo + .last() + .is_some_and(|entry| session.presets.iter().any(|preset| preset.id == self.id && entry.label == format!("Preset: {}", preset.name))) } } @@ -55,7 +56,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); - if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), crate::i18n::tr("Create Preset…")).clicked() { + if icon_button( + &mut hdr, + "presetCreate", + Icon::Plus, + vec2(28.0, 28.0), + false, + app.session.active().is_some(), + crate::i18n::tr("Create Preset…"), + ) + .clicked() + { app.ui.dialog = Some(crate::state::Dialog::create_preset()); } let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, crate::i18n::tr("More preset options")); @@ -88,7 +99,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { track: Track::Plain, }; let out = slider(ui, &spec, last.amount, true, None); - if let Some(v) = out.value && app.run("edit.undo", json!({})).is_ok() { + if let Some(v) = out.value + && app.run("edit.undo", json!({})).is_ok() + { // Only replace the most recent preset; never undo intervening edits. if app.run("preset.apply", json!({"id": &last.id, "amount": v})).is_ok() { ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, last.id, v))); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 45de0424a..03810aca9 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -63,7 +63,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let search_left = full.center().x - sw / 2.0; ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")).clicked() { + if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) + .clicked() + { let _ = app.run("view.leftPanel", json!({})); } ui.add_space(8.0); From 702f90bfc93b1e80eaa5c8f2b3004205b726031a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 11:08:16 +0700 Subject: [PATCH 135/142] Batch 32: close the tests two merges added at one place; cargo fmt --- crates/engine/src/files.rs | 2 ++ crates/preview/src/tests.rs | 2 ++ crates/ui-egui/src/tests_panels.rs | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index ee17ee735..dc79b8b31 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1293,6 +1293,8 @@ mod tests { let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); assert!(read.planned, "the plan still answers"); assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); + } + /// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as /// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. #[test] diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 2aa5ce310..3179b91ec 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -391,6 +391,8 @@ fn pool_wait_keeps_the_finished_job_for_try_recv() { assert!(p.wait(std::time::Duration::ZERO), "kept until taken"); assert_eq!(p.try_recv().map(|d| d.result), Some(7)); assert!(p.try_recv().is_none()); +} + /// Wait up to `secs` for `f` to hold. fn eventually(secs: u64, mut f: impl FnMut() -> bool) -> bool { let t0 = std::time::Instant::now(); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 486819712..440621346 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2273,6 +2273,8 @@ fn an_album_menu_imports_into_that_album() { assert_eq!(r["result"]["scanning"], true, "{r}"); assert_eq!(review_album(&mut h), None); let _ = std::fs::remove_dir_all(&dir); +} + /// Delete asks before photos go to Recently Deleted, by default and from every way in: the key, the /// Photo menu and a photo's right-click menu (#1239). With the setting off, it deletes at once. #[test] @@ -2313,6 +2315,8 @@ fn deleting_a_photo_asks_first_from_the_key_the_menu_and_the_right_click_menu() click(&mut h, "button:contextDeletePhoto"); assert_eq!(h.app.ui.dialog, None); assert!(deleted(&h)); +} + /// New Album's "Include selected photos" (as in Lightroom): on by default; unticked, the new album /// starts empty; with nothing selected the option isn't shown, and a folder never has it. #[test] From d752076339fdd8e731247b47b3c84acde2636aae Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 11:11:02 +0700 Subject: [PATCH 136/142] Batch 32: fixes for PRs merged together (#984 leap-year lint, #1344 ColorData field, #1187 preset panel builds) --- crates/engine/src/camera_preview.rs | 1 + crates/meta/src/datetime.rs | 2 +- crates/ui-egui/src/panels/presets.rs | 14 ++++++-------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 5cfb9847b..741fc391d 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -154,6 +154,7 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) as_shot_white_xy: None, baseline_exposure: t.baseline_exposure + k.log2(), baseline_sharpness: tags.baseline_sharpness, + kelvin_presets: None, profile: ProfileLook { hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs index 4f5e03d7a..ffd2edd76 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -28,7 +28,7 @@ impl DateTime { // A timestamp must represent a real Gregorian calendar day, not just // numbers in the range 1..=31. Invalid dates would otherwise be // accepted from EXIF/XMP and silently normalized during time arithmetic. - let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0); + let leap = self.year.is_multiple_of(4) && (!self.year.is_multiple_of(100) || self.year.is_multiple_of(400)); let max_day = match self.month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 3e08ccf3d..081757bc1 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -86,7 +86,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // amount slider (applies to the last applied preset) let amt_id = egui::Id::new("preset-amount"); let last: Option = ui.data(|d| d.get_temp(amt_id)); - if let Some(last) = last.filter(|last| last.is_current(&app.session)) { + if let Some(last) = last.clone().filter(|last| last.is_current(&app.session)) { let spec = ControlSpec { id: "presetAmount", label: "Amount", @@ -160,7 +160,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); - let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); + let is_last = last.as_ref().is_some_and(|l| l.id == pid); if is_last { ui.painter().rect_filled(r, 0.0, t.tool_active); } else if resp.hovered() { @@ -192,12 +192,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label))); } } - if resp.clicked() { - if app.run("preset.apply", json!({"id": pid, "amount": 100})).is_ok() { - ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, pid.clone(), 100.0))); - ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); - app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); - } + if resp.clicked() && app.run("preset.apply", json!({"id": pid, "amount": 100})).is_ok() { + ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, pid.clone(), 100.0))); + ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); + app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); } let (builtin, group) = (pr.builtin, pr.group.clone()); resp.context_menu(|ui| { From 2f0931d3cafe378a6255586c1671fcc57f84a27b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 00:13:49 -0400 Subject: [PATCH 137/142] Batch 32: rustfmt after merges Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index e9d8575f6..87c7f175d 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -531,7 +531,7 @@ fn run(args: &[String]) -> Result<(), String> { if took { i += 1; } - }, + } a if a.starts_with("--connect=") => { let (addr, _) = connect_value(Some(&a["--connect=".len()..])); connect = Some(addr); From 1d70cf63581d1909e7329083b6136c2b439b0bd4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 11:20:47 +0700 Subject: [PATCH 138/142] Batch 32: the import tool tests date their fixtures (undated files take the file's date, #1216) --- crates/mcp/src/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 9975ef062..99193b480 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -2,6 +2,13 @@ use serde_json::{Value, json}; use crate::{Backend, Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name}; +/// An undated file takes its own date (#1216): give the fixture the date its folder template expects (noon UTC, +/// the same day in every time zone the tests run in). +fn file_dated_2026_01_14(path: &std::path::Path) { + let noon = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_392_000); + std::fs::File::options().write(true).open(path).unwrap().set_modified(noon).unwrap(); +} + fn server() -> Server { Server::new(Box::new(Headless::demo())) } @@ -82,6 +89,7 @@ fn import_tool_copies_with_a_folder_template() { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join("a.png"), png).unwrap(); + file_dated_2026_01_14(&src.join("a.png")); let mut b = Headless::demo(); b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); let args = @@ -255,6 +263,7 @@ fn import_tool_moves_with_a_folder_template() { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join("sample.png"), png).unwrap(); + file_dated_2026_01_14(&src.join("sample.png")); let mut b = Headless::demo(); b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(), From 2e877ad47624598d10125e4a1bb0441ee89a0ed7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 11:42:47 +0700 Subject: [PATCH 139/142] Batch 32: arrows over a slider nudge it (#1264), the toast area doesn't fade in (#1262), #1282's test runs on Windows --- crates/ui-egui/src/panels/mod.rs | 2 ++ crates/ui-egui/src/shortcuts.rs | 6 ++++++ crates/ui-egui/src/tests_labels.rs | 2 ++ crates/ui-egui/src/tests_panels.rs | 6 +++--- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7a90d5a10..7a0dfb313 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -90,6 +90,8 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Area::new(egui::Id::new("toast")) .order(egui::Order::Foreground) .movable(false) + // (the toast fades by its own clock) + .fade_in(false) .pivot(Align2::CENTER_CENTER) .fixed_pos(pos2(canvas.center().x, canvas.bottom() - 60.0)) .show(ctx, |ui| { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 01c6c03ad..1a21ab2fc 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -538,6 +538,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } // Up / Down in the grids go to the cell above / below (the filmstrip steps one photo) if matches!(f.as_str(), "library.up" | "library.down") { + // over a slider the arrows nudge it (widgets::slider takes the key) + let over_slider = + ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); + if over_slider { + continue; + } match crate::panels::grid::row_distance(app, f == "library.down") { Some(0) => {} Some(rows) => { diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 9325ab77e..9869223b4 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -75,6 +75,8 @@ fn label_tints_grid_and_filmstrip_chrome_without_changing_photo_pixels() { fn label_toast_has_pale_colour_and_rating_restores_neutral_style() { let mut h = app(ViewMode::SquareGrid); h.app.run("photo.label", json!({"label": "red"})).unwrap(); + // (two frames: the toast is an egui Area, sized in its first frame and painted from the second, #1262) + h.step(); h.step(); let at = egui::pos2(h.app.canvas_rect.unwrap().center().x, h.app.canvas_rect.unwrap().bottom() - 45.0); let red = pixel(&h.paint(), at); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 440621346..b18c4e968 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1727,8 +1727,8 @@ fn a_folder_menu_locates_its_missing_tree_only() { std::fs::write(path, b"photo").unwrap(); } - let root = base.to_string_lossy().replace('\\', "/"); - let old = format!("{root}/old"); + // (the missing tree is not on disk: a plain root, so the Folders section lists it the same on every platform) + let old = "/lc-locate-old".to_string(); let old_trip = format!("{old}/trip"); let paths = [format!("{old_trip}/a.jpg"), format!("{old_trip}/day2/b.jpg"), format!("{old}/other/c.jpg")]; let picked = new_trip.to_string_lossy().to_string(); @@ -1756,7 +1756,7 @@ fn a_folder_menu_locates_its_missing_tree_only() { }) .collect(); assert!(sources.contains(&new_trip.join("a.jpg").to_string_lossy().to_string()), "{sources:?}"); - assert!(sources.contains(&new_trip.join("day2/b.jpg").to_string_lossy().to_string()), "{sources:?}"); + assert!(sources.contains(&new_trip.join("day2").join("b.jpg").to_string_lossy().to_string()), "{sources:?}"); assert!(sources.contains(&format!("{old}/other/c.jpg")), "another missing folder stays untouched: {sources:?}"); } From 7693cbf3aef70c05317d3ccf5d44cf8310633db3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 11:54:39 +0700 Subject: [PATCH 140/142] Revert "Merge PR #1262": the toast as a widget takes the clicks meant for what is under it (a dialog's button) --- contributors/people.toml | 5 --- crates/ui-egui/src/lib.rs | 2 -- crates/ui-egui/src/panels/mod.rs | 30 +++++----------- crates/ui-egui/src/tests_labels.rs | 2 -- crates/ui-egui/src/tests_toast.rs | 58 ------------------------------ 5 files changed, 8 insertions(+), 89 deletions(-) delete mode 100644 crates/ui-egui/src/tests_toast.rs diff --git a/contributors/people.toml b/contributors/people.toml index e153870f4..79f20c726 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -27,8 +27,3 @@ sync_github_name = true [people.sanyamsingh001] display_name = "sanyamsingh001" - -[people.switch87] -real_name = "Gert Pellin" -display_name = "Gert Pellin" -sync_github_name = false diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1a80acf04..8248ee5a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -118,8 +118,6 @@ mod tests_temp_slider; #[cfg(test)] mod tests_titlebar; #[cfg(test)] -mod tests_toast; -#[cfg(test)] mod tests_unsaved; #[cfg(test)] mod tests_wb_selector; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7a0dfb313..a8d99aed2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -69,9 +69,6 @@ pub fn resizable_side( } /// The HUD toast at the bottom centre of the canvas. -/// -/// Drawn as widgets rather than a painted galley so the message can be selected and copied: an -/// error here often carries a path or a command the user wants to paste somewhere else. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); let Some((text, until, label)) = app.ui.toast.clone() else { return }; @@ -82,27 +79,16 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(canvas) = app.canvas_rect else { return }; let t = Tokens::get(ctx); let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32; - let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); - let background = background.gamma_multiply(fade); - let foreground = foreground.gamma_multiply(fade); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast"))); // long messages (a save warning, the web build's notices) wrap within the canvas let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0); - egui::Area::new(egui::Id::new("toast")) - .order(egui::Order::Foreground) - .movable(false) - // (the toast fades by its own clock) - .fade_in(false) - .pivot(Align2::CENTER_CENTER) - .fixed_pos(pos2(canvas.center().x, canvas.bottom() - 60.0)) - .show(ctx, |ui| { - ui.set_max_width(wrap); - egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show( - ui, - |ui| { - ui.add(egui::Label::new(egui::RichText::new(text).font(t.font(14.0)).color(foreground)).wrap()); - }, - ); - }); + let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); + let foreground = foreground.gamma_multiply(fade); + let galley = painter.layout(text, t.font(14.0), foreground, wrap); + let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0)); + let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size); + painter.rect_filled(r, 6.0, background.gamma_multiply(fade)); + painter.galley(r.center() - galley.size() / 2.0, galley, foreground); ctx.request_repaint_after(std::time::Duration::from_millis(30)); } diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 9869223b4..9325ab77e 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -75,8 +75,6 @@ fn label_tints_grid_and_filmstrip_chrome_without_changing_photo_pixels() { fn label_toast_has_pale_colour_and_rating_restores_neutral_style() { let mut h = app(ViewMode::SquareGrid); h.app.run("photo.label", json!({"label": "red"})).unwrap(); - // (two frames: the toast is an egui Area, sized in its first frame and painted from the second, #1262) - h.step(); h.step(); let at = egui::pos2(h.app.canvas_rect.unwrap().center().x, h.app.canvas_rect.unwrap().bottom() - 45.0); let red = pixel(&h.paint(), at); diff --git a/crates/ui-egui/src/tests_toast.rs b/crates/ui-egui/src/tests_toast.rs deleted file mode 100644 index cf671d242..000000000 --- a/crates/ui-egui/src/tests_toast.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! The toast is drawn as widgets, not as paint. That is what makes its message selectable: a toast -//! often carries a path or a command the user wants somewhere else, and painted text cannot be -//! picked up by the pointer at all. - -use std::time::Duration; - -use crate::headless::Headless; -use crate::{LightcraftApp, Services}; - -const T: Duration = Duration::from_secs(20); - -fn demo() -> Headless { - let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); - let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - // let the first renders finish: a render still running when the test binary exits can crash in the - // GPU driver's teardown (the preview pool doesn't join its workers) - h.settle(T); - h -} - -/// One event per frame: [`LightcraftApp::raw_input_hook`] takes pointer events singly. -fn send(h: &mut Headless, e: egui::Event) { - h.app.synthetic.push(e); - h.step(); -} - -fn show_toast(h: &mut Headless, msg: &str) -> egui::Rect { - h.app.ui.toast = Some((msg.to_owned(), f64::MAX, None)); - h.step(); - h.step(); - h.view.ctx.memory(|m| m.area_rect(egui::Id::new("toast"))).expect("the toast is laid out as a widget, not paint") -} - -#[test] -fn the_toast_is_a_widget_the_pointer_reaches() { - let mut h = demo(); - let msg = "Exported frame to /tmp/scene-0001.png"; - let area = show_toast(&mut h, msg); - - assert!(area.is_positive(), "the toast has a rect of its own: {area:?}"); - assert!(h.painted_text().iter().any(|t| t.contains("Exported frame")), "the message is still painted"); - - // The pointer reaches the message: egui offers the text cursor over it. Painted text is never - // hit-tested, so this is the difference that makes selection possible at all. - send(&mut h, egui::Event::PointerMoved(area.center())); - assert_eq!(h.last_cursor, egui::CursorIcon::Text, "the toast text is a text target"); -} - -#[test] -fn the_toast_still_wraps_a_long_message_inside_the_canvas() { - let mut h = demo(); - let msg = "The SAM 3 download failed: the file is 4.1 GB and the connection dropped at 98 %, so nothing was installed and the masks are still unavailable"; - let area = show_toast(&mut h, msg); - - let canvas = h.app.canvas_rect.expect("the demo has a canvas"); - assert!(area.width() <= canvas.width() - 80.0, "the toast wraps within the canvas: {area:?}"); - assert!(h.painted_text().iter().any(|t| t.contains("SAM 3 download failed")), "the whole message is painted"); -} From ece9bd83e4b1ff929ab00aa386f660fd0f9b2227 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 12:05:26 +0700 Subject: [PATCH 141/142] Batch 32: one tracker row per id (rows two PRs both edited), parity summary refreshed --- docs/parity-checklist.md | 42 ++++++++++++---------------------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0860d391c..0ab638f35 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -44,7 +44,7 @@ cited here still exists. Formerly docs/parity.md. | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | -| S. Export (EXP) | 15 | 3 | 0 | 0 | 7/7 (100%) | 7/7 (100%) | +| S. Export (EXP) | 15 | 4 | 0 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 51 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. ## Top gaps and shortcut notes @@ -80,20 +80,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); 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 (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248). | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies. | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %). | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers). | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue). | -| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | -| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | @@ -118,8 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; 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); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; 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); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; 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); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | @@ -150,10 +138,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram | -| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag; no drag-to-adjust on the histogram | -| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | +| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | ## D. Search & filter (FILT) @@ -204,8 +190,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/raw/src/kelvin_presets.rs`, `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | @@ -249,8 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); 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; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) | | 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 | @@ -701,10 +685,8 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | -| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | +| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) | | LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) | -| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; picking one in Photo Grid switches to Square Grid); no people view | -| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | From ae9b09d3c2ee116d8c31542530e9fa740bc685a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 02:07:07 -0400 Subject: [PATCH 142/142] Batch 32: rustfmt after merges Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/walk.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 4c35b66bb..1c1b53b4a 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -68,9 +68,7 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl } continue; } - if skip.is_some_and(|s| path == s) - || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) - { + if skip.is_some_and(|s| path == s) || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) { continue; } if depth >= limits.max_depth {