From 76f12dcabdd361167bd82187fe9f0c15d6851498 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 03:37:35 +1100 Subject: [PATCH 1/3] Issue #989: fill_box reads its buffer with as_chunks::<3>() (clippy 1.99) Co-Authored-By: Claude Opus 5.5 (1M context) --- 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..5b8cac6b3 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 (px, _) = out.as_chunks::<3>(); + for (d, v) in src.iter_mut().zip(px) { + *d = *v; } } From c78b457c0d0783935ace0afccad16879aca1ac64 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 03:37:35 +1100 Subject: [PATCH 2/3] LR-VIEW-ZOOM: soft proofing at 1:1 renders a zoom window; 1:1 pixels tested equal to export Soft proof is per-pixel, so the After window carries the proof like the whole-frame render under it (the proof key is part of the tile's look). New tests: a 1:1 window of a 24 MP photo equals the full-size export to within one level; soft proofing at 1:1 is a canvas-sized frame plus a native window. profile_slider_drag gains a soft-proof case. Gap 4 closed, checklist row to done with measured Linux latencies. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 5 ++- crates/engine/src/media.rs | 34 +++++++++++++++ crates/ui-egui/src/panels/detail.rs | 26 +++++++----- crates/ui-egui/src/region.rs | 10 ++--- crates/ui-egui/src/tests_preview_limit.rs | 50 +++++++++++++++-------- docs/gaps.md | 7 +++- docs/parity-checklist.md | 5 ++- docs/target-app-parity.md | 11 ++--- docs/ui-parity.md | 5 ++- 9 files changed, 108 insertions(+), 45 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..e7c55966d 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 (full-resolution zoom done) · **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 @@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ 1. Camera colour of our own: measured calibration for the popular models (50–90 h) 2. CR3 for every Canon body (25–45 h) 3. Render-fidelity suite against Lightroom, then tuning (30–60 h) -4. Full-resolution zoom (8–15 h) +4. ~~Full-resolution zoom~~ (done 2026-10-12) 5. AI masks: Subject / Sky / People (40–70 h + model decision) 6. Compressed ORF and HE NEF (28–55 h) 7. Lens profile database of our own (40–80 h) @@ -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 | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; 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/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..156db7d41 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1931,6 +1931,40 @@ mod tests { assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); } + // LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of + // the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and + // at a corner, with tone, colour and detail edits on a camera-sized photo + #[test] + fn one_to_one_window_pixels_are_export_pixels() { + use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow}; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}"); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 0.7; + d.light.contrast = 25.0; + d.color.saturation = 20.0; + s.set_develop(id, d, "Edits").unwrap(); + let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; + assert_eq!((export.width, export.height), (w, h), "a full-size export"); + for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] { + let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image; + assert_eq!((tile.width, tile.height), (win.w, win.h)); + let mut worst = 0u8; + for y in 0..win.h { + for x in 0..win.w { + let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]); + for k in 0..3 { + worst = worst.max(a[k].abs_diff(b[k])); + } + } + } + assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels"); + } + } + #[test] fn has_source_follows_what_the_session_accepted() { let mut s = crate::Session::with_demo(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..800e27f7d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -330,10 +330,14 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt let job = if c.interacting { job.draft() } else { job }; 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); - // 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); + // the After window is proofed like the whole-frame render under it; the Before side is + // never proofed (its whole-frame render isn't either) + let proof = app.ui.soft_proof.then_some(app.ui.proof); + let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof }); + // The overlay and the proof are part of the tile's identity too, so changing mask view/colour + // or the proof never leaves a stale region tile on screen while its replacement renders. + let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) }; + let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41); let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms of this side can't be drawn any more app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); @@ -443,15 +447,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the whole frame at about canvas size; the zoomed part of it is a window render (below) let look = d.hash64(); let overlay = view_overlay(app, &d); - // Diagnostic views that need whole-frame context, soft proofing, or reads too far for one - // window still render the whole frame at the size it is drawn. A mask overlay is window-safe. + // Diagnostic views that need whole-frame context, or reads too far for one window, still + // render the whole frame at the size it is drawn. Mask overlays and soft proofing (both act + // pixel by pixel) are window-safe. let sizes = crate::region::ViewSizes { drawn_long, canvas_long: canvas.width().max(canvas.height()) * ppp, native_long, texture_side, draft_scale: scale, - windows: !app.ui.soft_proof && overlay_supports_window(overlay), + windows: overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { @@ -519,10 +524,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more - // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use - // the same window; diagnostic overlays that need whole-frame context do not. - let windowable_view = - hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect); + // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays and + // soft proofing use the same window; diagnostic overlays that need whole-frame context do not. + let windowable_view = hover_key.is_none() && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect); let window_ctx = WindowCtx { id, overlay, diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..c18ea380e 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -73,8 +73,8 @@ pub struct ViewSizes { /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). pub draft_scale: f32, /// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay - /// that needs whole-frame context, soft proofing, or a read too far for one window). Mask - /// overlays are window-safe. + /// that needs whole-frame context, or a read too far for one window). Mask + /// overlays and soft proofing are window-safe. pub windows: bool, } @@ -97,7 +97,7 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; if !v.windows { - // the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no + // the whole frame at the size it is drawn (a whole-frame diagnostic has no // window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must // not render the photo's every pixel each frame let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long }; @@ -281,7 +281,7 @@ mod tests { assert_eq!(drag.window_edge, still.window_edge); } - // Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the + // Given no window (a whole-frame diagnostic), the view at rest renders the // whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the // windowed path #[test] @@ -332,7 +332,7 @@ mod tests { } } - // Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a + // Given a mode whose picture can't be a window (a whole-frame diagnostic, or a // spot read too far for one render), the whole-frame render is as big as the view #[test] fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..93c5f86c1 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -227,6 +227,29 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); } + // Given soft proofing on at 1:1 (LR-VIEW-ZOOM), the view is a canvas-sized frame plus a + // native window carrying the proof, as without it, instead of a whole-frame render at the + // photo's full size; switching the proof off swaps the window back to the plain look + #[test] + fn soft_proofing_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 proof"); + let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": true, "destWarning": true}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.soft_proof); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let proofed = h.app.region_view.expect("a window render with the proof"); + assert_eq!(proofed.full.0.max(proofed.full.1), native, "cut from the photo's own pixels"); + assert_ne!(proofed.settings, plain.settings, "the proof is part of the window's look"); + h.request("engine.execute", json!({"command": "view.softProof", "params": {"on": false}}), T); + h.settle(SETTLE); + assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); + } + // 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() { @@ -534,10 +557,16 @@ mod in_the_loupe { #[test] #[ignore = "a measurement, not a check"] fn profile_slider_drag() { - for (size, percent) in - [([1400.0, 900.0], 0.0), ([1400.0, 900.0], 100.0), ([1400.0, 900.0], 400.0), ([2800.0, 1700.0], 100.0), ([2800.0, 1700.0], 400.0)] - { + for (size, percent, proof) in [ + ([1400.0, 900.0], 0.0, false), + ([1400.0, 900.0], 100.0, false), + ([1400.0, 900.0], 400.0, false), + ([2800.0, 1700.0], 100.0, false), + ([2800.0, 1700.0], 400.0, false), + ([1400.0, 900.0], 100.0, true), + ] { let (mut h, native) = detail_in(size); + h.app.ui.soft_proof = proof; h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) }; h.settle(SETTLE); h.app.session.begin_interaction("Exposure").unwrap(); @@ -561,9 +590,10 @@ mod in_the_loupe { ms.sort_by(|a, b| a.total_cmp(b)); let (w, hh) = region_tile(&h).unwrap_or((0, 0)); eprintln!( - "PROFILE {}x{} canvas, {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}", + "PROFILE {}x{} canvas, {percent:>4}% (0 = fit){} of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}", size[0], size[1], + if proof { ", soft proof" } else { "" }, ms[ms.len() / 2], ms[ms.len() * 9 / 10], ms[ms.len() - 1], @@ -692,18 +722,6 @@ mod in_the_loupe { assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::>()); } - // Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame - // render is as big as the view, not a canvas-sized upscale - #[test] - fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() { - let (mut h, native) = detail(); - h.app.ui.soft_proof = true; - h.request("engine.execute", json!({"command": "view.zoom100"}), T); - h.settle(SETTLE); - assert_eq!(h.app.region_view, None); - assert_eq!(rendered_long_edge(&h), native); - } - // Given a window the photo's spots make too big for one render (refused), the loupe falls back // to the whole frame at the drawn size, and tries windows again when the look changes #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..c1276b2bf 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 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 @@ -18,7 +18,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 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) | | 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) | +| 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) | | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 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) | @@ -59,6 +59,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like + Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export. - 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float TIFF export (#646), activity stack (#345), JPEG XL export (#247). - 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), @@ -72,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 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 | | 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..53a9156af 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-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. 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-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-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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 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) | | 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..e2d0e4374 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 (gap 4 closed: full-resolution zoom; headline numbers unchanged) · **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 @@ -155,7 +155,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). | Discount | Mainstream | Essentials | Evidence | |---|---:|---:|---| -| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) | +| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714) | | Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) | | File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | | **Product** | **×0.804** | **×0.830** | | @@ -233,13 +233,13 @@ open as previews, and colour on every non-DNG raw is estimated rather than measu **Alpha gate:** all six core workflows (import, cull, develop, save and reopen, batch looks, export) work end to end on macOS; see the table in [roadmap.md](roadmap.md#alpha-gate). The partial rows (CR3 -variants, compressed ORF, HE NEF as previews; capped 100% zoom) degrade but don't block a workflow. +variants, compressed ORF, HE NEF as previews) degrade but don't block a workflow. -**To beta:** ~14 points of readiness, **~340–600 h**: the nine `B` gaps in [gaps.md](gaps.md) +**To beta:** ~14 points of readiness, **~340–600 h**: the `B` gaps in [gaps.md](gaps.md) (~230–430 h), plus burning down the ~92 open core-path user issues (40–80 h) and stability work around them. Beta needs: CR3, ORF and HE NEF decoding from sensor data; measured colour for the popular models and a fidelity suite with -scores; full-resolution zoom; Subject / Sky / People masks; a lens profile database for the +scores; Subject / Sky / People masks; a lens profile database for the popular lenses; tests in CI on macOS, Windows and Linux. ## How hours are calibrated @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 4 (full-resolution zoom) closed: removed from the interaction-fidelity notes, the alpha-gate partial rows and the beta needs; headline numbers and the ×0.95 interaction factor 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..4379f48da 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 (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% | **original-pixel detail at 100% with a mask overlay or soft proofing** (no zoom window there; Detail, Before/After, Compare and Reference have them; P0 gap LR-VIEW-ZOOM) | +| 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 | | 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,5 +78,6 @@ 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: 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 | From 61ec5c596c0eea9acbc536778379dd26c2b1a675 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Wed, 14 Oct 2026 20:02:09 +1100 Subject: [PATCH 3/3] Docs: restore upstream's header notes in gaps and parity checklist Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/gaps.md | 2 +- docs/parity-checklist.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 052dff174..61e0ac7e8 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-14 · **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-14 · **Change:** minor (gap 4 closed: soft proofing gets zoom windows; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) · **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 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 93601c0ac..9c59dede6 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-14 · **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-14 · **Change:** minor (LR-VIEW-ZOOM ✅: mask overlays and soft proofing get zoom windows; 1:1 equals export; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **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