diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fb9785ba6..fc40acb24 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -453,11 +453,11 @@ pub fn try_render( // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); let s: &DevelopSettings = &effective; - // the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU - // Visualize HDR needs the float HDR render: CPU too + // the kernel writes 8-bit output: high-bit-depth exports render on the CPU, and so do soft + // proofs against an output-device profile (its 3D table); Visualize HDR needs the float HDR render: CPU too if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 - || req.proof.is_some() + || req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some()) || req.overlay == lightcraft_pipeline::Overlay::HdrRange { return Err(Fallback::NotTried); diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 7f9405492..b46b7cc1a 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -73,6 +73,13 @@ const FIELDS: &[(&str, usize)] = &[ ("OUT_Y", 3), ("OUT_TRC", 1), ("OUT_GAMMA", 1), + // soft proofing (matrix proofs; `ProofParams`): working → proof RGB, its luma, proof → output, warnings + ("PROOF", 1), + ("PROOF_M", 9), + ("PROOF_Y", 3), + ("PROOF_OUT", 9), + ("PROOF_DEST_WARN", 1), + ("PROOF_DISP_WARN", 1), // first row of a band dispatch (the kernel runs over rows Y0.., see `render`) ("Y0", 1), ]; @@ -233,6 +240,14 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr let (trc, gamma) = fp.out_trc.code(); p.u("OUT_TRC", trc); p.f("OUT_GAMMA", gamma); + if let Some(pp) = fp.proof.as_ref().filter(|pp| pp.table.is_none()) { + p.b("PROOF", true); + p.fs("PROOF_M", pp.to_proof.as_flattened()); + p.fs("PROOF_Y", &pp.luma); + p.fs("PROOF_OUT", pp.proof_to_out.as_flattened()); + p.b("PROOF_DEST_WARN", pp.dest_warning); + p.b("PROOF_DISP_WARN", pp.display_warning); + } if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350f..dcba4ba2b 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -664,8 +664,10 @@ pub fn render( // 5. per-pixel stage let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + // soft proofing as the CPU's `finish` sets it up (an output-device table never gets here: see `render`) + fp.proof = req.proof.map(|pr| pr.params(req.space)); if let Some(d) = &req.display { - fp.for_display(d, None); + fp.for_display(d, req.proof); } let present = Present { clarity: prep.clarity.is_some(), diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d51308ee0..76a6e4071 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -252,6 +252,46 @@ fn out_encode(v: f32) -> f32 { return 1.099 * pow(l, 0.45) - 0.099; } +// The 3×3 matrix stored row by row from field `o`. +fn mat_at(o: u32) -> array, 3> { + return array, 3>( + vec3(pf(o), pf(o + 1u), pf(o + 2u)), + vec3(pf(o + 3u), pf(o + 4u), pf(o + 5u)), + vec3(pf(o + 6u), pf(o + 7u), pf(o + 8u)), + ); +} + +// `finish::gamut_map` (SDR): the chroma scale that brings `r` into 0..1 towards its luminance (1: in gamut) ... +fn gamut_scale(r: vec3, luma: vec3) -> f32 { + let yy = clamp(dot(luma, r), 0.0, 1.0); + var tg = 1.0; + for (var k = 0u; k < 3u; k++) { + let cc = r[k]; + if (cc < 0.0) { + tg = min(tg, yy / max(yy - cc, 1e-9)); + } else if (cc > 1.0) { + tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9)); + } + } + return tg; +} + +// ... and `r` scaled by it. +fn gamut_apply(r: vec3, luma: vec3, tg: f32) -> vec3 { + if (tg < 1.0) { + let yy = clamp(dot(luma, r), 0.0, 1.0); + return yy + (r - yy) * tg; + } + return r; +} + +// `finish::out_of_gamut`: `r` needed desaturating by `t` to fit and isn't just a neutral beyond white or below black. +fn out_of_gamut(r: vec3, t: f32) -> bool { + let lo = min(r.x, min(r.y, r.z)); + let hi = max(r.x, max(r.y, r.z)); + return t < 0.995 && (hi - lo) > 0.02 * max(abs(hi), 1e-3) && hi - lo > 1e-3 && (lo < -1e-3 || (hi > 1.0 && lo < 1.0)); +} + @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) gid: vec3) { let w = pu(F_W); @@ -467,26 +507,27 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } - // --- gamut map to the output space (desaturate towards luminance until in range) - let om = array, 3>( - vec3(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)), - vec3(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)), - vec3(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)), - ); - var r = mul3(om, d); - let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0); - var tg = 1.0; - for (var k = 0u; k < 3u; k++) { - let cc = r[k]; - if (cc < 0.0) { - tg = min(tg, yy / max(yy - cc, 1e-9)); - } else if (cc > 1.0) { - tg = min(tg, (1.0 - yy) / max(cc - yy, 1e-9)); + // --- gamut map to the output space (desaturate towards luminance until in range); + // soft proofing maps into the proof space first and shows that in the output space + // (warn: 0 none, 1 destination gamut, 2 display gamut) + var warn = 0u; + var r: vec3; + if (pu(F_PROOF) != 0u) { + let q0 = mul3(mat_at(F_PROOF_M), d); + let tq = gamut_scale(q0, vec3(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u))); + if (pu(F_PROOF_DEST_WARN) != 0u && out_of_gamut(q0, tq)) { + warn = 1u; } + r = mul3(mat_at(F_PROOF_OUT), gamut_apply(q0, vec3(pf(F_PROOF_Y), pf(F_PROOF_Y + 1u), pf(F_PROOF_Y + 2u)), tq)); + } else { + r = mul3(mat_at(F_OUT_M), d); } - if (tg < 1.0) { - r = yy + (r - yy) * tg; + let oy = vec3(pf(F_OUT_Y), pf(F_OUT_Y + 1u), pf(F_OUT_Y + 2u)); + let tg = gamut_scale(r, oy); + if (pu(F_PROOF) != 0u && pu(F_PROOF_DISP_WARN) != 0u && warn == 0u && out_of_gamut(r, tg)) { + warn = 2u; } + r = gamut_apply(r, oy, tg); // --- encode, curves, grain var e = vec3(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z)); @@ -512,6 +553,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e + k; } + if (warn == 1u) { + e = vec3(1.0, 0.0, 0.0); + } else if (warn == 2u) { + e = vec3(0.0, 0.25, 1.0); + } e = vec3(out_encode(e.x), out_encode(e.y), out_encode(e.z)); out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u); } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a971..51658147b 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -857,3 +857,48 @@ fn windows_of_a_big_photo_share_one_uploaded_source() { lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render"); assert_eq!(lightcraft_gpu::source_uploads() - start, 2); } + +#[test] +fn soft_proofs_match() { + if !gpu() { + return; + } + // Soft proofing against an output space (matrix proofs): into the proof gamut, back to the + // output space, with the destination and display gamut warnings painted alike on both renderers. + // Saturated edits put plenty of colours outside the smaller proof spaces. + use lightcraft_pipeline::{OutputSpace, Proof}; + let src = scene(0, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + for space in [OutputSpace::Srgb, OutputSpace::AdobeRgb, OutputSpace::DisplayP3] { + for (dest_warning, display_warning) in [(false, false), (true, false), (false, true), (true, true)] { + for out in [OutputSpace::Srgb, OutputSpace::Rec2020] { + let mut s = DevelopSettings::default(); + saturated(&mut s); + let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 }); + let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) }; + check(&format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}"), &src, &raw, &s, &req); + } + } + } + // the case is meaningful: proofing sRGB from Rec. 2020 output warns on some pixels, and the GPU paints the same ones + let mut s = DevelopSettings::default(); + saturated(&mut s); + let req = RenderRequest { + space: OutputSpace::Rec2020, + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 }), + ..RenderRequest::fit(640, 640) + }; + let warned = |i: &Rgba8| i.data.iter().filter(|p| p[1] == 0 && p[2] == 0 && p[0] > 200).count(); + let (cpu, gpu) = (render(&src, &raw, &s, &req).image, lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu render").image); + assert!(warned(&cpu) > 100, "{} warned pixels", warned(&cpu)); + assert_eq!(warned(&cpu), warned(&gpu)); + // an output-device profile (a 3D table) stays on the CPU + let n = 2; + let lut = lightcraft_pipeline::ProofLut::new(n, vec![[0.5, 0.5, 0.5, 0.0]; n * n * n]).unwrap(); + let id = lightcraft_pipeline::register_proof_profile(0x5eed_9f00, lut); + let req = RenderRequest { + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: id }), + ..RenderRequest::fit(64, 64) + }; + assert!(lightcraft_gpu::render(&src, &raw, &DevelopSettings::default(), &req, None).is_none(), "declined: the CPU renders it"); +} diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 1d5b15c65..7e4aff13e 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -375,3 +375,42 @@ fn auto_mask_strokes_match_the_whole_render() { }; check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1); } + +/// The margin the loupe's windows carry around what they show at this size (`region::margin_for`: +/// 4.5 % of the long edge). +const OVERLAY_INSET: usize = W * 45 / 1000; + +/// [`check`] for a diagnostic overlay, with the photo as a raw (Visualize HDR needs one), keeping the +/// loupe's margin in from the window's edges. +fn check_overlay(name: &str, s: &DevelopSettings, overlay: crate::Overlay, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let info = SourceInfo { raw: true, ..Default::default() }; + let full_req = RenderRequest { overlay, ..RenderRequest::fit(W, H) }; + let f = render(&scene(), &info, s, &full_req).image; + let w = render(&scene(), &info, s, &RenderRequest { window: Some(win), ..full_req }).image; + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + assert_ne!(f.data, render(&scene(), &info, s, &RenderRequest::fit(W, H)).image.data, "{name}: the overlay draws something"); + let (max, mean) = compare_inset(&f, &w, win, OVERLAY_INSET); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +// Given a diagnostic overlay (Visualize Spots, a Point Color range, Visualize HDR), a window shows +// the same pixels as the whole-frame render, so the loupe can draw them as a zoom window +#[test] +fn diagnostic_overlays_in_a_window_match_the_full_render() { + use crate::Overlay; + let mut spots = DevelopSettings::default(); + spots.detail.sharpen_amount = 60.0; + // a binary threshold: a pixel at the threshold may flip on a one-level difference underneath + check_overlay("spots", &spots, Overlay::Spots(70), MID, 255, 0.05); + for (x, y) in [(0, 0), (W - 400, H - 300)] { + check_overlay("spots at a corner", &spots, Overlay::Spots(70), PixelWindow { x, y, w: 400, h: 300 }, 255, 0.05); + } + let mut pc = DevelopSettings::default(); + pc.point_colors.push(lightcraft_develop::PointColor { lum: 0.6, chroma: 0.08, hue: 60.0, hue_shift: 40.0, ..Default::default() }); + check_overlay("point colour range", &pc, Overlay::PointColorRange(0), MID, 1, 0.1); + let mut hdr = DevelopSettings::default(); + hdr.hdr.enabled = true; + hdr.hdr.max_ev = 3.0; + hdr.light.exposure = 1.5; + check_overlay("hdr range", &hdr, Overlay::HdrRange, MID, 1, 0.1); +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 800e27f7d..ab61040c2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -249,11 +249,16 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } -/// Whether an overlay can be rendered on a region tile without changing what the tile means. -/// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic -/// overlays either need whole-frame context or deliberately replace the loupe. +/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask +/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize +/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high +/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin +/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { - matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. }) + use lightcraft_pipeline::Overlay as O; + match overlay { + O::None | O::Mask { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, + } } /// What a window render of the loupe needs to know about the view it belongs to. diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 93c5f86c1..a300b9329 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -250,6 +250,25 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); } + // Given a diagnostic overlay (Visualize Spots), when I zoom to 1:1, then the visible part is a + // window render carrying the overlay, over a canvas-sized whole frame, as without it + #[test] + fn visualize_spots_at_one_to_one_is_a_window() { + let (mut h, native) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let plain = h.app.region_view.expect("a window render without the overlay"); + let r = h.request("ui.menu.invoke", json!({"id": "view.visualizeSpots"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.visualize_spots); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let spots = h.app.region_view.expect("a window render with the overlay"); + assert_eq!(spots.full.0.max(spots.full.1), native, "cut from the photo's own pixels"); + assert_ne!(spots.settings, plain.settings, "the overlay is part of the window's look"); + } + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { diff --git a/docs/gaps.md b/docs/gaps.md index c1276b2bf..1a5bab309 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-12 · **Change:** minor (gap 4 closed: soft proofing gets zoom windows) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 4 follow-up: diagnostic overlays get zoom windows); previously minor (gap 4 closed: soft proofing gets zoom windows) · **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 @@ -74,6 +74,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 4 follow-up: the diagnostic overlays (Visualize Spots, Point Color range, HDR range) render zoom windows too; soft proofing runs on the GPU | | 2026-10-12 | minor | Gap 4 closed: soft proofing at 1:1 renders a window (mask overlays already did); 1:1 pixels tested equal to export; only diagnostic overlays (Visualize Spots, Point Color range, HDR range) stay whole-frame | | 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 | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 883c3a0bf..2c6abeb4e 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -143,6 +143,8 @@ walks the drive. (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. +- Soft proofing against an output space (`RenderRequest::proof`, with its destination and display gamut + warnings) runs in `finish`; a proof against an output-device profile (its 3D table) renders on the CPU. - Per-stage hybrid: defringe and spot removal (rare, CPU-only for now) download the resampled image, run on the CPU and upload; heuristic mask shapes (Sky, Subject, Background, …) and brushes of more than 4096 dabs are evaluated on the CPU and uploaded; sources over the buffer limit are resampled diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 53a9156af..69fc17d7b 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-12 · **Change:** minor (LR-VIEW-ZOOM ✅: mask overlays and soft proofing get zoom windows; 1:1 equals export) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-VIEW-ZOOM: diagnostic overlays get zoom windows; LRC-DEV-SOFTPROOF on the GPU); previously minor (LR-VIEW-ZOOM ✅: mask overlays and soft proofing get zoom windows; 1:1 equals export) · **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 @@ -129,7 +129,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 | -| 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. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) 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 or showing a diagnostic 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. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| 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. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. 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, 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. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | 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 | @@ -713,7 +713,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | 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-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-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`); on the GPU for output-space proofs (destination and display gamut warnings included; equivalence-tested), an output-device profile's 3D table on the CPU | | 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 | | LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-VIEW-ZOOM: Visualize Spots, Point Color range and Visualize HDR render zoom windows; LRC-DEV-SOFTPROOF: output-space proofs on the GPU | | 2026-10-12 | minor | LR-VIEW-ZOOM 🟡 → ✅: soft proofing gets zoom windows (mask overlays already had them; row text corrected), 1:1 pixels tested equal to the full-size export, slider-drag latency at 1:1 measured on Linux | | 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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4379f48da..6112bbe0d 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-12 · **Change:** minor (zoom row: soft proofing 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 (zoom row: diagnostic overlays sharp at 1:1); previously minor (zoom row: soft proofing sharp at 1:1) · **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 @@ -27,7 +27,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Crop, Upright, transform handles | handles, aspect lock, angle, overlays (O / ⇧O), guided Upright | ✅ all of them; overlay cycle on ⇧O | 85% | crop opacity of cut-out area (15.5) | | Masking tools | brush (A / B, flow, density, auto mask, pressure), linear / radial, range masks, AI masks, pins | brush, linear, radial, colour / luminance range, add / subtract / intersect / invert, masks panel, pins | 60% | brush live preview (#517 / #518), no pen pressure, no depth range, AI masks, Feather / Edge controls (15.5) | | Remove / heal | content-aware remove, heal, clone, visualize spots | heal, clone, auto source, visualize spots, red / pet eye | 65% | content-aware (patch synthesis) remove, generative remove, detect distractions | -| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 | +| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | every view, mask and diagnostic overlays (Visualize Spots, Point Color range, HDR range) and soft proofing included, is sharp at 1:1–8:1 | | Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys | | 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 | @@ -78,6 +78,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Zoom row: diagnostic overlays render zoom windows too | | 2026-10-12 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed | | 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 |