mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 02:59:17 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
76b5377868
commit
7421407d25
@@ -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);
|
||||
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user