mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
18f583ca5f
commit
b446a5b829
@@ -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>) {
|
||||
|
||||
@@ -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<f64>,
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+128
-25
@@ -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<f64>,
|
||||
/// Key of the whole-frame render last timed (each render is timed once).
|
||||
seen: Option<u64>,
|
||||
/// 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<f64>,
|
||||
/// 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<f64>) {
|
||||
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<f64> {
|
||||
let mut rest: Vec<f64> = 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() {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user