From 7421407d25df8ee938d72085fdff8a43ad46a9a5 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 16:46:28 +0200 Subject: [PATCH 1/5] LR-VIEW-ZOOM: full-size slider-drag drafts when the GPU renders (#330) A slider drag rendered the loupe's whole frame at 0.6 of its size, so the fit view went soft while dragging. On the GPU a canvas-sized frame takes a few ms, so drags now render at full size there; the CPU keeps the 0.6 draft (region::drag_scale, CPU_DRAFT_SCALE). The no-window path's canvas cap now follows an explicit `drafting` flag instead of `scale < 1.0`, so a GPU drag at 1:1 still never renders every pixel of the photo each frame. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 5 ++- crates/ui-egui/src/region.rs | 53 ++++++++++++++++++++--- crates/ui-egui/src/tests_preview_limit.rs | 5 ++- docs/parity-checklist.md | 5 ++- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..28365fbc3 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -436,7 +436,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - let scale = if interacting { 0.6 } else { 1.0 }; + // (`ready` first: `available` would wait for the device while it is still being created) + let gpu = interacting && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); + let scale = crate::region::drag_scale(interacting, gpu); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -451,6 +453,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { native_long, texture_side, draft_scale: scale, + drafting: interacting, windows: !app.ui.soft_proof && overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..acfc87063 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -70,8 +70,11 @@ pub struct ViewSizes { pub native_long: usize, /// The largest texture the GPU takes. pub texture_side: usize, - /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). + /// 1.0, or less while a slider is dragged (a draft of the whole-frame render; see + /// [`drag_scale`]). pub draft_scale: f32, + /// A slider is being dragged: the render is a draft, never above the canvas size. + pub drafting: bool, /// 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. @@ -90,6 +93,15 @@ pub struct LoupePlan { pub window_edge: Option, } +/// Size of a slider drag's whole-frame draft on the CPU, against the render at rest. +pub const CPU_DRAFT_SCALE: f32 = 0.6; + +/// [`ViewSizes::draft_scale`] for the loupe: a drag on the GPU renders at full size (a canvas-sized +/// draft takes a few ms there, issue #330); on the CPU it renders a smaller draft to keep up. +pub fn drag_scale(drafting: bool, gpu: bool) -> f32 { + if drafting && !gpu { CPU_DRAFT_SCALE } else { 1.0 } +} + /// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame /// render is canvas-sized, and the window holds only the pixels on screen at no more than 100 %. pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { @@ -100,7 +112,7 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { // the whole frame at the size it is drawn (soft proof or 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 }; + let long = if v.drafting { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; } @@ -225,7 +237,7 @@ mod tests { const BIG: usize = 16384; fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true } + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, drafting: false, windows: true } } fn auto() -> AppSettings { @@ -276,7 +288,7 @@ mod tests { #[test] fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(24000.0, 2800.0, 6000) }); + let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); assert_eq!(drag.window_edge, still.window_edge); } @@ -286,7 +298,7 @@ mod tests { // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) }; + let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, drafting: draft_scale < 1.0, ..sizes(24000.0, 2800.0, 6000) }; let still = plan(&auto(), no_window(1.0)); let drag = plan(&auto(), no_window(0.6)); assert_eq!(still.window_edge, None); @@ -294,6 +306,33 @@ mod tests { assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); } + // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) + #[test] + fn a_drag_shrinks_the_draft_only_on_the_cpu() { + assert_eq!(drag_scale(true, true), 1.0); + assert_eq!(drag_scale(true, false), CPU_DRAFT_SCALE); + assert_eq!(drag_scale(false, true), 1.0); + assert_eq!(drag_scale(false, false), 1.0); + } + + // Given a drag at fit on the GPU, the draft is as sharp as the render at rest + #[test] + fn a_gpu_drag_at_fit_keeps_the_resolution() { + for drawn in [1400.0, 2800.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft_scale: drag_scale(true, true), drafting: true, ..sizes(drawn, drawn, 6000) }); + assert_eq!(drag, still, "{drawn}"); + } + } + + // Given no window and a drag on the GPU, the draft is still at most the canvas: a drag at 1:1 + // must not render the photo's every pixel each frame + #[test] + fn a_gpu_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft_scale: 1.0, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge <= 2816, "{drag:?}"); + } + // Given the user capped the preview size, the whole-frame render obeys it, and a fit view // stays on it (no original decoded); a zoomed view still gets its sharp window #[test] @@ -326,7 +365,7 @@ mod tests { fn a_drag_at_fit_on_a_large_display_keeps_the_window() { for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(drawn, drawn, 6000) }); + let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(drawn, drawn, 6000) }); assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); } @@ -370,7 +409,7 @@ mod tests { // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) #[test] fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(2800.0, 2800.0, 6000) }); + let p = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(2800.0, 2800.0, 6000) }); assert_eq!(p.window_edge, None); } diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..976d4d0e7 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -423,7 +423,10 @@ mod in_the_loupe { h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); h.settle(SETTLE); let main = rendered_long_edge(&h); - assert!(main <= 1000 && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo"); + // canvas-sized: the whole canvas on the GPU, a 0.6 draft of it on the CPU (#330) + let canvas = h.app.canvas_rect.expect("the loupe was drawn"); + let canvas_long = canvas.width().max(canvas.height()) as usize + crate::state::LOUPE_EDGE_STEP; + assert!(main <= canvas_long && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo"); let region = h.app.region_view.expect("the window is drafted too"); assert_eq!(region.full.0.max(region.full.1), native, "at 100 %, magnified by the GPU"); let (w, hh) = region_tile(&h).expect("a window draft"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..7a03a5c8c 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-VIEW-ZOOM: full-size slider-drag drafts on the GPU, issue #330; 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 @@ -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 slider drag renders the whole frame at full size when the GPU renders (a canvas-sized frame takes a few ms there) and at 0.6 of it on the CPU, so the fit view stays sharp while dragging (`region::drag_scale`, issue #330). 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution when the GPU renders (0.6 draft on the CPU only, issue #330); 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 c0a1a6443a8b5508305cbea2d402ddb5fb627469 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 17:05:28 +0200 Subject: [PATCH 2/5] LR-VIEW-ZOOM: choose the drag draft size from what the render costs (#330) Review follow-up. "A GPU device exists" does not mean the GPU draws the view: soft proofs and the HDR range render on the CPU, and a LUT profile, refined masks or a photo past a GPU limit fall back per render. Those drags had become full-size CPU renders. region::DragScale now picks the draft size when a drag starts and keeps it until the drag ends (no resolution jump mid-gesture): full size where the GPU draws the view (not soft-proofed, no HDR range) and no earlier drag of the same control on this photo went over the 16 ms slider budget at full size; 0.6 otherwise. Full-size drafts are timed as they land (the first, rebuilding stage caches, is left out; the median decides). ViewSizes carries one `draft: Option` instead of a scale and a flag that could disagree. Tests: DragScale unit tests (budget, lock, hostile times) and headless drags for a remembered slow drag and a soft-proofed drag, each checked to fail without its guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/detail.rs | 21 ++- crates/ui-egui/src/region.rs | 199 ++++++++++++++++++---- crates/ui-egui/src/tests_preview_limit.rs | 38 +++++ docs/parity-checklist.md | 4 +- 5 files changed, 227 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..bd825d467 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -336,6 +336,8 @@ pub struct LightcraftApp { pub region_before_view: Option, /// The loupe's render sizes while a pinch or two-finger scroll runs. pub(crate) size_hold: region::SizeHold, + /// The size of a slider drag's whole-frame drafts (full size where they keep up, issue #330). + pub(crate) drag_scale: region::DragScale, /// (photo, look, window frame size) a window was refused for: it reads more than one render holds. pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>, pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>, @@ -439,6 +441,7 @@ impl LightcraftApp { region_view: None, region_before_view: None, size_hold: Default::default(), + drag_scale: Default::default(), window_refused: None, region_tiles: Default::default(), compare_windows: [None; 2], diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 28365fbc3..22173a61d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -436,15 +436,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - // (`ready` first: `available` would wait for the device while it is still being created) - let gpu = interacting && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); - let scale = crate::region::drag_scale(interacting, gpu); + let overlay = view_overlay(app, &d); + // a drag drafts at full size where the GPU draws the view (soft proofs and the HDR range + // render on the CPU; `ready` first: `available` would wait for a device still being created) + let gpu = interacting + && !app.ui.soft_proof + && overlay != lightcraft_pipeline::Overlay::HdrRange + && lightcraft_engine::gpu::ready() + && lightcraft_engine::gpu::available(); + let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); + let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); + if let Some((key, ms)) = shown { + app.drag_scale.observe(key, ms); + } // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; // 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. let sizes = crate::region::ViewSizes { @@ -452,8 +462,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { canvas_long: canvas.width().max(canvas.height()) * ppp, native_long, texture_side, - draft_scale: scale, - drafting: interacting, + draft, windows: !app.ui.soft_proof && overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index acfc87063..bd7c57c94 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -70,11 +70,10 @@ pub struct ViewSizes { pub native_long: usize, /// The largest texture the GPU takes. pub texture_side: usize, - /// 1.0, or less while a slider is dragged (a draft of the whole-frame render; see - /// [`drag_scale`]). - pub draft_scale: f32, - /// A slider is being dragged: the render is a draft, never above the canvas size. - pub drafting: bool, + /// While a slider is dragged, the scale of the whole-frame draft against the render at rest + /// (1.0 or [`CPU_DRAFT_SCALE`], from [`DragScale`]); a draft is never above the canvas size. + /// `None`: no drag. + pub draft: Option, /// 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. @@ -93,13 +92,95 @@ pub struct LoupePlan { pub window_edge: Option, } -/// Size of a slider drag's whole-frame draft on the CPU, against the render at rest. +/// Size of a slider drag's whole-frame draft when full size would be too slow (the CPU renders +/// it, or full-size drafts of this drag went over [`DRAFT_BUDGET_MS`]), against the render at rest. pub const CPU_DRAFT_SCALE: f32 = 0.6; -/// [`ViewSizes::draft_scale`] for the loupe: a drag on the GPU renders at full size (a canvas-sized -/// draft takes a few ms there, issue #330); on the CPU it renders a smaller draft to keep up. -pub fn drag_scale(drafting: bool, gpu: bool) -> f32 { - if drafting && !gpu { CPU_DRAFT_SCALE } else { 1.0 } +/// The slider budget (ms): a drag whose full-size drafts take longer gets smaller drafts next time. +pub const DRAFT_BUDGET_MS: f64 = 16.0; + +/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. +const SLOW_DRAGS_KEPT: usize = 256; + +/// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it +/// ends (a change in the middle would make the resolution jump). A drag renders at full size when +/// the GPU draws the view and no earlier drag of the same control on this photo went over budget +/// at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue #330). Whether the GPU drew a render is +/// not enough on its own: some views (a LUT profile, refined masks, a photo past a GPU limit) fall +/// back to the CPU per render, which only the time a draft took shows. +#[derive(Clone, Debug, Default)] +pub struct DragScale { + /// The drag running now: (photo, control) and its scale. + current: Option<((lightcraft_catalog::PhotoId, String), f32)>, + /// How long the current drag's full-size drafts took (ms). + samples: Vec, + /// Key of the whole-frame render last timed (each render is timed once). + seen: Option, + /// Drags whose full-size drafts went over budget. + slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, +} + +impl DragScale { + /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control + /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame + /// render on screen (a render from before the drag is not one of its drafts). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option) -> Option { + let Some((photo, control)) = drag else { + self.finish(); + return None; + }; + match &self.current { + Some(((p, c), s)) if *p == photo && c == control => return Some(*s), + Some(_) => self.finish(), + None => {} + } + let key = (photo, control.to_string()); + let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + self.current = Some((key, s)); + self.seen = shown; + Some(s) + } + + /// The whole-frame render on screen: `key` identifies it, `ms` is how long it took. Times the + /// current drag's full-size drafts. + pub fn observe(&mut self, key: u64, ms: f64) { + let full = matches!(self.current, Some((_, s)) if s >= 1.0); + if !full || self.seen == Some(key) { + return; + } + self.seen = Some(key); + if ms.is_finite() && ms >= 0.0 && self.samples.len() < 1024 { + self.samples.push(ms); + } + } + + /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + #[cfg(test)] + pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { + self.slow.insert((photo, control.to_string())); + } + + /// The drag ended: remember it if its full-size drafts were over budget. + fn finish(&mut self) { + if let Some((key, _)) = self.current.take() + && over_budget(&self.samples) + { + if self.slow.len() >= SLOW_DRAGS_KEPT { + self.slow.clear(); + } + self.slow.insert(key); + } + self.samples.clear(); + self.seen = None; + } +} + +/// Whether a drag's draft times (ms) are over [`DRAFT_BUDGET_MS`]: the median, leaving out the +/// first draft (it rebuilds the stage caches the others reuse). +fn over_budget(samples: &[f64]) -> bool { + let mut rest: Vec = samples.iter().skip(1).copied().collect(); + rest.sort_by(f64::total_cmp); + rest.get(rest.len() / 2).is_some_and(|median| *median > DRAFT_BUDGET_MS) } /// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame @@ -107,12 +188,12 @@ pub fn drag_scale(drafting: bool, gpu: bool) -> f32 { pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 }; 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 }; + let scale = v.draft.map_or(1.0, |s| if s.is_finite() { s.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 // 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 v.drafting { drawn_long.min(canvas_long) } else { drawn_long }; + let long = if v.draft.is_some() { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; } @@ -237,7 +318,7 @@ mod tests { const BIG: usize = 16384; fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, drafting: false, windows: true } + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } } fn auto() -> AppSettings { @@ -288,7 +369,7 @@ mod tests { #[test] fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); assert_eq!(drag.window_edge, still.window_edge); } @@ -298,9 +379,9 @@ mod tests { // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, drafting: draft_scale < 1.0, ..sizes(24000.0, 2800.0, 6000) }; - let still = plan(&auto(), no_window(1.0)); - let drag = plan(&auto(), no_window(0.6)); + let no_window = |draft: Option| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; + let still = plan(&auto(), no_window(None)); + let drag = plan(&auto(), no_window(Some(0.6))); assert_eq!(still.window_edge, None); assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); @@ -309,27 +390,85 @@ mod tests { // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) #[test] fn a_drag_shrinks_the_draft_only_on_the_cpu() { - assert_eq!(drag_scale(true, true), 1.0); - assert_eq!(drag_scale(true, false), CPU_DRAFT_SCALE); - assert_eq!(drag_scale(false, true), 1.0); - assert_eq!(drag_scale(false, false), 1.0); + use lightcraft_catalog::PhotoId; + let mut gpu = DragScale::default(); + assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + let mut cpu = DragScale::default(); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); } - // Given a drag at fit on the GPU, the draft is as sharp as the render at rest + // Given a drag that started before the GPU was ready, it keeps its size until it ends: the + // resolution never jumps in the middle of a drag #[test] - fn a_gpu_drag_at_fit_keeps_the_resolution() { + fn a_drag_keeps_its_draft_size_until_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None), None); + assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends + assert_eq!(d.scale(drag, false, None), Some(1.0)); + } + + // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, + // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts + // small; other controls and photos keep full size + #[test] + fn a_drag_over_budget_drafts_small_the_next_time() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let exposure = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + d.observe(7, 400.0); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 40.0), (10, 45.0), (11, 9.0)] { + d.observe(key, ms); + d.observe(key, ms); // a render is timed once, however many frames show it + } + assert_eq!(d.scale(None, true, None), None); + assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + } + + // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be + // slow), the next drag stays at full size + #[test] + fn a_drag_within_budget_stays_full_size() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { + d.observe(key, ms); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + // hostile times are ignored, never a panic + for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { + d.observe(key, ms); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + } + + // Given a drag at fit at full size, the draft is as sharp as the render at rest + #[test] + fn a_full_size_drag_at_fit_keeps_the_resolution() { for drawn in [1400.0, 2800.0] { let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: drag_scale(true, true), drafting: true, ..sizes(drawn, drawn, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); assert_eq!(drag, still, "{drawn}"); } } - // Given no window and a drag on the GPU, the draft is still at most the canvas: a drag at 1:1 + // Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 // must not render the photo's every pixel each frame #[test] - fn a_gpu_drag_without_a_window_drafts_at_the_canvas_size() { - let drag = plan(&auto(), ViewSizes { windows: false, draft_scale: 1.0, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); + fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); assert!(drag.main_edge <= 2816, "{drag:?}"); } @@ -365,7 +504,7 @@ mod tests { fn a_drag_at_fit_on_a_large_display_keeps_the_window() { for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(drawn, drawn, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); } @@ -409,7 +548,7 @@ mod tests { // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) #[test] fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(2800.0, 2800.0, 6000) }); + let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); assert_eq!(p.window_edge, None); } @@ -480,7 +619,7 @@ mod tests { #[test] fn hostile_sizes_give_a_usable_plan() { for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { - let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft_scale: v, ..sizes(1.0, 1.0, 6000) }); + let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); } let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 976d4d0e7..076181599 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -433,6 +433,44 @@ mod in_the_loupe { assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame"); } + /// Drag Exposure in the loupe and return the long edge of the whole-frame draft shown, with + /// the largest a small (CPU-sized) draft of the photo as drawn can be. + fn drag_exposure(h: &mut Headless) -> (usize, usize) { + h.app.session.begin_interaction("Exposure").unwrap(); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + h.settle(SETTLE); + let image = h.app.image_rect.expect("the photo was drawn"); + let small = (image.width().max(image.height()) * crate::region::CPU_DRAFT_SCALE) as usize + crate::state::LOUPE_EDGE_STEP; + (rendered_long_edge(h), small) + } + + // Given a drag whose full-size drafts were too slow before (the CPU, a LUT profile, a slow + // GPU), when the same slider is dragged again, then it drafts small (#330) + #[test] + fn a_slow_drag_drafts_small() { + // the GPU device up front (the loupe never waits for it): with one, it is the earlier + // drag's time that makes this drag small + let _ = lightcraft_engine::gpu::available(); + let (mut h, _) = detail(); + let id = h.app.session.active().expect("a photo is open"); + h.app.drag_scale.remember_slow(id, "Exposure"); + let (main, small) = drag_exposure(&mut h); + assert!(main <= small, "the draft is {main} px, a small one at most {small}"); + } + + // Given soft proofing (rendered on the CPU even with a GPU), a drag drafts small (#330) + #[test] + fn a_soft_proofed_drag_drafts_small() { + // the GPU device up front (the loupe never waits for it): with one, this is the case where + // the GPU is there and the render still goes to the CPU + let _ = lightcraft_engine::gpu::available(); + let (mut h, _) = detail(); + h.app.ui.soft_proof = true; + h.settle(SETTLE); + let (main, small) = drag_exposure(&mut h); + assert!(main <= small, "the draft is {main} px, a small one at most {small}"); + } + fn tile(h: &Headless, slot: Slot) -> Option<(usize, usize)> { h.app.renderer.textures.get(&slot).map(|t| (t.size[0], t.size[1])) } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7a03a5c8c..8fc8117b1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 slider drag renders the whole frame at full size when the GPU renders (a canvas-sized frame takes a few ms there) and at 0.6 of it on the CPU, so the fit view stays sharp while dragging (`region::drag_scale`, issue #330). 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 slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); the size is chosen when a drag starts and kept until it ends (`region::DragScale`, issue #330). 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 | @@ -787,7 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution when the GPU renders (0.6 draft on the CPU only, issue #330); status unchanged | +| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); 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 20822fa513d08fda989ec808301510a086b3cb18 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 11 Oct 2026 17:32:53 +0200 Subject: [PATCH 3/5] LR-VIEW-ZOOM: one rule for which renders the GPU draws (#330) The loupe sized its slider-drag drafts by its own copy of try_render's "CPU only" list (soft proof, HDR range). A new CPU-only overlay added to try_render (e.g. #995's sharpening mask) would have left the loupe drafting full size on the CPU for its first drag. lightcraft_gpu::handles is now the single rule: try_render returns NotTried by it and the loupe asks it, so the two can't drift apart. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 32 ++++++++++++++++++++++------- crates/ui-egui/src/panels/detail.rs | 7 +++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fb9785ba6..76fb0f2c1 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -437,6 +437,14 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R try_render(src, info, s, req, stages).ok() } +/// Whether the GPU renders requests of this kind at all (a given render can still fall back to +/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on +/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it, +/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart. +pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool { + depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange +} + /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU /// was not used (callers can ask [`unavailable_reason`]), [`Fallback::Failed`] with the reason /// when the GPU render failed and the CPU must render it. The reason belongs to this call, unlike @@ -453,13 +461,7 @@ 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 - if !enabled() - || req.depth != lightcraft_pipeline::OutputDepth::U8 - || req.proof.is_some() - || req.overlay == lightcraft_pipeline::Overlay::HdrRange - { + if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) { return Err(Fallback::NotTried); } let s = &*lightcraft_pipeline::settings_for(s, req); @@ -533,3 +535,19 @@ pub fn try_render( Err(Fallback::NotTried) } } + +#[cfg(test)] +mod tests { + use lightcraft_pipeline::{OutputDepth, Overlay}; + + // Given a request the 8-bit kernel can't draw (high bit depth, a soft proof, Visualize HDR), + // the GPU doesn't take it; the loupe and diagnostic overlays it can draw stay on the GPU + #[test] + fn the_gpu_handles_8_bit_unproofed_renders_without_the_hdr_range() { + assert!(super::handles(OutputDepth::U8, false, Overlay::None)); + assert!(super::handles(OutputDepth::U8, false, Overlay::Spots(50))); + assert!(!super::handles(OutputDepth::U16, false, Overlay::None)); + assert!(!super::handles(OutputDepth::U8, true, Overlay::None)); + assert!(!super::handles(OutputDepth::U8, false, Overlay::HdrRange)); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 22173a61d..0b17684d3 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -437,11 +437,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); let overlay = view_overlay(app, &d); - // a drag drafts at full size where the GPU draws the view (soft proofs and the HDR range - // render on the CPU; `ready` first: `available` would wait for a device still being created) + // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; + // `ready` first: `available` would wait for a device still being created) let gpu = interacting - && !app.ui.soft_proof - && overlay != lightcraft_pipeline::Overlay::HdrRange + && lightcraft_engine::gpu::handles(lightcraft_pipeline::OutputDepth::U8, app.ui.soft_proof, overlay) && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); From 18f583ca5f83628151c2dc44ffb0fff76192d37b Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Mon, 12 Oct 2026 14:06:23 +0200 Subject: [PATCH 4/5] LR-VIEW-ZOOM: render workers convert their texture pixels, not the UI thread (#330) A 10 MP zoom window's ColorImage conversion ran on the UI thread on every slider step. The pool now hands back the render with its texture pixels; offloaded (browser) results and results presented again on the UI thread are still converted there. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/render.rs | 60 ++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..231ac1447 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -80,6 +80,14 @@ pub trait RenderOffload { fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)>; } +/// A job's result as the pool hands it back: the render and its texture pixels, converted on the +/// worker (`None` for prefetches and failures). A 10 MP zoom window's conversion took a few ms of +/// the UI thread on every slider step. +struct Finished { + result: RenderResult, + color: Option>, +} + struct Queued { slot: Slot, priority: u32, @@ -179,7 +187,7 @@ pub struct Renderer { thumb_inputs: HashMap, usize, u64, bool)>, /// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it). embedded_thumbs: HashMap, usize, u64)>, - pool: JobPool, + pool: JobPool, /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. offload: Option>, queue: Vec, @@ -237,7 +245,7 @@ impl Slot { impl Default for Renderer { fn default() -> Self { - let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; Self::with_worker_threads(threads) } } @@ -372,12 +380,13 @@ impl Renderer { } /// Run `job` and make its image ready for `display` (on the worker thread). - fn run_for(job: impl FnOnce() -> RenderResult, display: &Option>) -> RenderResult { + fn run_for(job: impl FnOnce() -> RenderResult, display: &Option>) -> Finished { let mut r = job(); if let Some(d) = display { lightcraft_engine::display::present(&mut r, d); } - r + let color = r.rendered.as_ref().ok().map(|x| Arc::new(color_image(&x.image))); + Finished { result: r, color } } fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool { @@ -472,7 +481,12 @@ impl Renderer { job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); + self.pool.submit( + slot, + key, + priority, + Box::new(move || Finished { result: lightcraft_engine::memory::in_background(|| job.run()), color: None }), + ); } /// Is a request for `slot` queued or running? @@ -651,14 +665,14 @@ impl Renderer { let inline_ms = 0.0; 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 })); + finished.push((done.slot, done.result.result, done.result.color, if done.ms > 0.0 { done.ms } else { inline_ms })); } if let Some(off) = self.offload.as_mut() { - finished.extend(off.finished()); + finished.extend(off.finished().into_iter().map(|(slot, r, ms)| (slot, r, None, ms))); } self.dispatch(); let mut changed = false; - for (mut slot, mut r, ms) in finished { + for (mut slot, mut r, mut color, ms) in finished { self.completed += 1; // made for another display profile than the one in use: obsolete if r.display.is_some_and(|id| Some(id) != self.display_id()) { @@ -687,7 +701,11 @@ impl Renderer { } // (results from an offload, which doesn't know the display profile: converted here) if let Some(d) = &self.display { + let before = r.display; lightcraft_engine::display::present(&mut r, d); + if r.display != before { + color = None; // converted after the worker made its texture pixels + } } if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { self.pending.remove(&slot); @@ -721,7 +739,7 @@ impl Renderer { self.textures.remove(&Slot::ThumbQuick(id)); } let img = &rendered.image; - let color = std::sync::Arc::new(color_image(img)); + let color = color.unwrap_or_else(|| Arc::new(color_image(img))); let pixels = self.keep_pixels.then(|| color.clone()); let name = format!("{slot:?}"); match self.textures.get_mut(&slot) { @@ -1395,6 +1413,30 @@ mod color_image_tests { assert_eq!(got.pixels, want.pixels); } + // Given a finished render, the worker hands back its texture pixels with it (the UI thread no + // longer converts a 10 MP window on every slider step); a failed render has none + #[test] + fn the_worker_converts_the_texture_pixels() { + let image = lightcraft_raster::Rgba8::from_fn(5, 3, |x, y| [(x * 40) as u8, (y * 80) as u8, 7, 255]); + let result = |rendered| RenderResult { + request_id: 1, + source_key: None, + photo: PhotoId(1), + level: lightcraft_engine::SourceLevel::Preview, + key: 1, + rendered, + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }; + let histogram = Histogram::of_srgb8(&image); + let ok = Renderer::run_for(|| result(Ok(lightcraft_pipeline::Rendered { image: image.clone(), histogram, deep: None })), &None); + assert_eq!(ok.color.map(|c| c.pixels.clone()), Some(color_image(&image).pixels)); + let failed = Renderer::run_for(|| result(Err("unreadable".into())), &None); + assert!(failed.color.is_none()); + } + #[test] fn an_empty_image_is_an_empty_texture() { let img = lightcraft_raster::Rgba8::new(0, 0); From b446a5b8290fb452300f9d8ec7be0610025d7b9a Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Mon, 12 Oct 2026 14:06:23 +0200 Subject: [PATCH 5/5] LR-VIEW-ZOOM: time drag drafts with the frame that shows them, give up early (#330) - A full-size draft's time is its render plus the CPU time of the frame that uploaded it (eframe's cpu_usage, Perf::host_frame_ms): render times alone said 231 of 234 1:1 drafts fit 16 ms while no complete update did (PR #1009 benchmarks). - A drag whose full-size drafts take more than twice the budget drops to 0.6 after three of them instead of at its end (a refined mask ran at 190 ms a step for the whole first drag). - Under a zoom window the hidden whole-frame draft is the 0.6 one, and it is not timed (the frame's time there is the window's upload). Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/main.rs | 3 +- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/detail.rs | 8 +- crates/ui-egui/src/region.rs | 153 +++++++++++++++++++++++----- docs/parity-checklist.md | 5 +- 5 files changed, 141 insertions(+), 31 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index cc71cc06d..5b9afbb83 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -73,7 +73,8 @@ impl eframe::App for App { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0); self.0.ui(ui); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bd825d467..1fa554c06 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -238,6 +238,9 @@ pub struct Perf { pub update_ms: f64, /// The slowest whole update since start. pub max_update_ms: f64, + /// The previous frame's whole time on this thread, painting (texture uploads) included, as + /// the host reports it (eframe's `cpu_usage`); `None` without a host report (headless). + pub host_frame_ms: Option, pub fps: f64, } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0b17684d3..ba14091a7 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -446,9 +446,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); - if let Some((key, ms)) = shown { - app.drag_scale.observe(key, ms); - } // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -468,6 +465,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); } + // time the full-size drafts where they are what shows: under a window the whole-frame draft + // is small anyway, and the frame's time is the window's upload + if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { + app.drag_scale.observe(key, ms, app.perf.host_frame_ms); + } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index bd7c57c94..4fb2e587b 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -97,25 +97,37 @@ pub struct LoupePlan { pub const CPU_DRAFT_SCALE: f32 = 0.6; /// The slider budget (ms): a drag whose full-size drafts take longer gets smaller drafts next time. +/// A draft's time is its render plus the frame that shows it (the texture upload is part of it). pub const DRAFT_BUDGET_MS: f64 = 16.0; +/// Full-size drafts this many times over budget give up on full size in the middle of the drag: +/// a drag at 200 ms a step is worse than one jump in resolution. +const GIVE_UP_FACTOR: f64 = 2.0; + +/// The full-size drafts (after the first) a drag times before it may give up on full size. +const GIVE_UP_AFTER: usize = 3; + /// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. const SLOW_DRAGS_KEPT: usize = 256; /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it -/// ends (a change in the middle would make the resolution jump). A drag renders at full size when -/// the GPU draws the view and no earlier drag of the same control on this photo went over budget -/// at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue #330). Whether the GPU drew a render is -/// not enough on its own: some views (a LUT profile, refined masks, a photo past a GPU limit) fall -/// back to the CPU per render, which only the time a draft took shows. +/// ends (a change in the middle would make the resolution jump), unless its full-size drafts are +/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of +/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] +/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT +/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large +/// draft costs a texture upload too, which only the time a draft took to show tells. #[derive(Clone, Debug, Default)] pub struct DragScale { /// The drag running now: (photo, control) and its scale. current: Option<((lightcraft_catalog::PhotoId, String), f32)>, - /// How long the current drag's full-size drafts took (ms). + /// How long the current drag's full-size drafts took to show (ms). samples: Vec, /// Key of the whole-frame render last timed (each render is timed once). seen: Option, + /// The render time (ms) of a draft that reached the screen this frame: the time of the frame + /// that uploads it is only known on the next one. + pending: Option, /// Drags whose full-size drafts went over budget. slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, } @@ -141,16 +153,36 @@ impl DragScale { Some(s) } - /// The whole-frame render on screen: `key` identifies it, `ms` is how long it took. Times the - /// current drag's full-size drafts. - pub fn observe(&mut self, key: u64, ms: f64) { + /// Once per frame: `key` identifies the whole-frame render on screen and `render_ms` is how + /// long it took; `last_frame_ms` is how long the previous frame took, painting included (the + /// host's report; `None` without one, e.g. headless). Times the current drag's full-size + /// drafts, each as its render plus the frame that uploaded and showed it, and gives up on full + /// size when they are far over budget. + pub fn observe(&mut self, key: u64, render_ms: f64, last_frame_ms: Option) { let full = matches!(self.current, Some((_, s)) if s >= 1.0); - if !full || self.seen == Some(key) { + if !full { + self.pending = None; + return; + } + if let Some(render) = self.pending.take() { + let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + if self.samples.len() < 1024 { + self.samples.push(render + frame); + } + if self.samples.len() > GIVE_UP_AFTER + && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS * GIVE_UP_FACTOR) + && let Some((_, s)) = self.current.as_mut() + { + *s = CPU_DRAFT_SCALE; + return; + } + } + if self.seen == Some(key) { return; } self.seen = Some(key); - if ms.is_finite() && ms >= 0.0 && self.samples.len() < 1024 { - self.samples.push(ms); + if render_ms.is_finite() && render_ms >= 0.0 { + self.pending = Some(render_ms); } } @@ -163,7 +195,7 @@ impl DragScale { /// The drag ended: remember it if its full-size drafts were over budget. fn finish(&mut self) { if let Some((key, _)) = self.current.take() - && over_budget(&self.samples) + && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) { if self.slow.len() >= SLOW_DRAGS_KEPT { self.slow.clear(); @@ -172,15 +204,16 @@ impl DragScale { } self.samples.clear(); self.seen = None; + self.pending = None; } } -/// Whether a drag's draft times (ms) are over [`DRAFT_BUDGET_MS`]: the median, leaving out the -/// first draft (it rebuilds the stage caches the others reuse). -fn over_budget(samples: &[f64]) -> bool { +/// The median of a drag's draft times (ms), leaving out the first draft (it rebuilds the stage +/// caches the others reuse); `None` before a second draft. +fn median_after_first(samples: &[f64]) -> Option { let mut rest: Vec = samples.iter().skip(1).copied().collect(); rest.sort_by(f64::total_cmp); - rest.get(rest.len() / 2).is_some_and(|median| *median > DRAFT_BUDGET_MS) + rest.get(rest.len() / 2).copied() } /// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame @@ -201,13 +234,15 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { // original is not decoded for the whole-frame render let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize }; let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap); - let main_edge = edge_at(scale); let frame_edge = settings.window_frame_edge(drawn_long, v.native_long); // a user who capped the preview size has chosen speed over sharpness at fit let fit = drawn_long <= canvas_long * 1.05; // (against the undrafted whole-frame render: a drag must not switch the window off and on) let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); - LoupePlan { main_edge, window_edge } + // with a window over it, the whole-frame draft is hidden (it feeds the histogram and stands in + // where the window has no pixels yet): the small draft does, and the window keeps the frame time + let scale = if window_edge.is_some() && v.draft.is_some() { scale.min(CPU_DRAFT_SCALE) } else { scale }; + LoupePlan { main_edge: edge_at(scale), window_edge } } /// How long the render sizes stay as they were after a pinch or two-finger scroll stops (s). @@ -422,11 +457,12 @@ mod tests { let mut d = DragScale::default(); let exposure = Some((PhotoId(1), "Exposure")); assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); - d.observe(7, 400.0); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 40.0), (10, 45.0), (11, 9.0)] { - d.observe(key, ms); - d.observe(key, ms); // a render is timed once, however many frames show it + d.observe(7, 400.0, None); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + d.observe(key, ms, None); + d.observe(key, ms, None); // a render is timed once, however many frames show it } + assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); assert_eq!(d.scale(None, true, None), None); assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); @@ -442,18 +478,85 @@ mod tests { let drag = Some((PhotoId(1), "Exposure")); d.scale(drag, true, None); for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { - d.observe(key, ms); + d.observe(key, ms, None); } d.scale(None, true, None); assert_eq!(d.scale(drag, true, None), Some(1.0)); // hostile times are ignored, never a panic for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { - d.observe(key, ms); + d.observe(key, ms, None); } d.scale(None, true, None); assert_eq!(d.scale(drag, true, None), Some(1.0)); } + // Given renders within budget whose frames (the texture upload) take it over, the drag counts + // as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms + #[test] + fn a_drafts_time_includes_the_frame_that_shows_it() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for key in 1..=5 { + d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(12.0)); + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + // the same renders without the host's frame times (headless) stay within budget + let mut headless = DragScale::default(); + headless.scale(drag, true, None); + for key in 1..=6 { + headless.observe(key, 8.0, None); + } + headless.scale(None, true, None); + assert_eq!(headless.scale(drag, true, None), Some(1.0)); + // a hostile frame time counts as nothing, never a panic + let mut hostile = DragScale::default(); + hostile.scale(drag, true, None); + for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { + hostile.observe(key, 8.0, Some(frame)); + } + hostile.scale(None, true, None); + assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + } + + // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), + // the drag drops to the small draft after a few of them instead of at its end, and the next + // drag starts small + #[test] + fn a_drag_far_over_budget_drafts_small_before_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + let mut scales = Vec::new(); + for key in 1..=8 { + d.observe(key, 190.0, None); + scales.push(d.scale(drag, true, None)); + } + // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame + assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); + assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + } + + // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is + // the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees + #[test] + fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { + let zoomed = sizes(24000.0, 2800.0, 6000); + let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); + let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); + assert!(full.window_edge.is_some(), "{full:?}"); + assert_eq!(full, small); + // at fit without a window, full size stays full size + let fit = sizes(2800.0, 2800.0, 6000); + assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); + } + // Given a drag at fit at full size, the draft is as sharp as the render at rest #[test] fn a_full_size_drag_at_fit_keeps_the_resolution() { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8fc8117b1..55044ce82 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 (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, issue #330; 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: full-size slider-drag drafts on the GPU, timed with their frame, issue #330; 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 @@ -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 slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); the size is chosen when a drag starts and kept until it ends (`region::DragScale`, issue #330). 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 slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. 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 | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-VIEW-ZOOM: slider-drag drafts are timed with the frame that shows them, drop to 0.6 mid-drag when far over budget, stay 0.6 under a zoom window; workers convert texture pixels (issue #330); status unchanged | | 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); 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) |