From 14570cfdf3b134bff831d4c1462fe662906f1dea Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 12:16:43 -0400 Subject: [PATCH 01/39] #323: Detail view renders at the size it is shown, not at a hidden 2560 px cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loupe capped every render at the "Preview size" setting (default 2560 px), then stretched the texture over the on-screen rect, so 1:1 and Retina fit views of larger photos looked soft or pixelated. The setting is now a limit: Automatic (default) renders at the drawn size, up to the photo's own pixels and an 8192 px ceiling; 1600–5120 px still cap it. The domain rule is AppSettings::loupe_edge (NaN/zero/huge inputs are clamped). The saved key changes from previewEdge to previewLimit so existing installs, whose 2560 was the old default, move to Automatic. Build Standard-Sized Previews keeps a numeric edge via standard_preview_edge(). Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/headless.rs | 4 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 2 +- crates/ui-egui/src/menus.rs | 8 +- crates/ui-egui/src/panels/detail.rs | 6 +- crates/ui-egui/src/panels/settings.rs | 15 ++- crates/ui-egui/src/state.rs | 37 ++++-- crates/ui-egui/src/tests_preview_limit.rs | 143 ++++++++++++++++++++++ 12 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/src/tests_preview_limit.rs diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 3e9c7556..7f679125 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -829,7 +829,7 @@ "Keywords you add appear here; ⌥1–⌥9 apply them.": "追加したキーワードはここに表示されます(⌥1〜⌥9で適用)", "Landscape": "風景", "Language": "言語", - "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "詳細表示の長辺の上限です。大きいほど鮮明になりますが、描画に時間がかかります", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "自動では、詳細表示を表示サイズ(写真本来の画素数まで)で描画します。サイズを選ぶと上限になります。小さいほど高速です", "Last": "前回", "Last view": "前回の表示", "Lateral Chromatic Aberration": "倍率色収差", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f5b88f4f..56a372f5 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -712,7 +712,7 @@ "Label Set": "Conjunto de rótulos", "Landscape": "Paisagem", "Language": "Idioma", - "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Maior aresta longa em que a exibição Detalhe é renderizada; maior deixa mais nítida em telas grandes, mas mais lenta.", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automático renderiza a exibição Detalhe no tamanho em que é mostrada, até os pixels da própria foto. Escolha um tamanho para limitá-la: menor é mais rápido.", "Last": "Último", "Last GPU fallback": "Último fallback de GPU", "Last commit": "Último commit", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 8e7b1312..1a2ec128 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -809,7 +809,7 @@ "Keywords you add appear here; ⌥1–⌥9 apply them.": "您添加的关键字会显示在此处;按 ⌥1–⌥9 即可应用。", "Landscape": "横向", "Language": "语言", - "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "详细视图渲染的最大长边;在大显示器上越大越清晰,但速度更慢。", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "“自动”按详细视图的显示大小渲染(最高为照片自身像素)。选择一个大小可设上限:越小越快。", "Last": "上次", "Last view": "上次视图", "Lateral Chromatic Aberration": "横向色差", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 71a2bb5f..57170c3f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -809,7 +809,7 @@ "Keywords you add appear here; ⌥1–⌥9 apply them.": "新增的關鍵字會顯示於此;⌥1–⌥9 可套用。", "Landscape": "風景", "Language": "語言", - "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "詳細檢視預覽的最長邊尺寸;較大的尺寸在大螢幕上更清晰,但速度較慢。", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "「自動」會依詳細檢視的顯示大小來渲染(最高為相片本身的像素)。選擇尺寸可設定上限:越小越快。", "Last": "最後", "Last view": "上次檢視", "Lateral Chromatic Aberration": "橫向色差", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 4411c35a..b803a36c 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1882,8 +1882,8 @@ mod tests { h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); assert_eq!(h.app.session.cache_mb, 512); - h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t); - assert_eq!(h.app.ui.settings.preview_edge, 3840); + h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t); + assert_eq!(h.app.ui.settings.preview_limit, 3840); h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); assert_eq!(h.app.ui.settings.memory_mb, 1024); h.step(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 502e31ff..98e9367e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -48,6 +48,8 @@ mod tests_offline; #[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_preview_limit; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 57e0477f..b6deccab 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -360,7 +360,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { vec![ item( "library.buildPreviews", - json!({"size": "standard", "edge": app.ui.settings.preview_edge}), + json!({"size": "standard", "edge": app.ui.settings.standard_preview_edge()}), crate::i18n::tr_format!("Build Standard-Sized Previews ({scope})", scope = crate::i18n::tr(scope)), None, !running, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a4eb6f31..7a210651 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -959,7 +959,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option "automatic".to_string(), + n => format!("{n} px"), + }, + ), ("Photos".to_string(), info["photos"].to_string()), ("Albums".to_string(), info["albums"].to_string()), ]; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 6c1b4550..ca74d623 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -230,7 +230,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { 24.0 }); - let max_edge = app.ui.settings.preview_edge.clamp(512, 8192) as f32; let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); @@ -283,7 +282,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let interacting = app.session.interaction.is_some(); let scale = if interacting { 0.6 } else { 1.0 }; // render at the final size: a click-zoom animation only changes how the result is drawn - let want = (target_rect.width().max(target_rect.height()) * ppp * scale).min(max_edge) as usize; + let native_long = native[0].max(native[1]); + let want = app.ui.settings.loupe_edge(target_rect.width().max(target_rect.height()) * ppp * scale, native_long); let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; @@ -313,7 +313,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); let na = nf.aspect() as f32; let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); - let nw = (nr.width().max(nr.height()) * ppp).min(max_edge) as usize; + let nw = app.ui.settings.loupe_edge(nr.width().max(nr.height()) * ppp, np.width.max(np.height).max(1) as usize); let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index e16d8815..8c7206c3 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -12,7 +12,7 @@ use egui::RichText; use serde_json::{Value, json}; use crate::LightcraftApp; -use crate::state::{GridBadges, PREVIEW_EDGES, StartupView}; +use crate::state::{GridBadges, PREVIEW_LIMITS, StartupView}; use crate::theme::Tokens; use crate::widgets::register; @@ -372,11 +372,18 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }; hint(ui, t, &status); row(ui, t, crate::i18n::tr("Preview size"), |ui| { - let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect(); + let opts: Vec<(u32, String)> = + PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); - choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge); + choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_limit); }); - hint(ui, t, crate::i18n::tr("Largest long edge the Detail view renders at; larger is sharper on big displays but slower.")); + hint( + ui, + t, + crate::i18n::tr( + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.", + ), + ); row(ui, t, crate::i18n::tr("Memory for caches"), |ui| { let auto = crate::i18n::tr_format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b57be4b9..e24c6279 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -140,8 +140,10 @@ pub struct AppSettings { pub confirm_delete: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, - /// Largest long edge (pixels) the loupe renders at. - pub preview_edge: u32, + /// Lowest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is + /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old + /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). + pub preview_limit: u32, /// Edit in External Editor: the application ("" = the system's default for TIFF files). pub external_editor: String, /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). @@ -163,7 +165,7 @@ impl Default for AppSettings { startup_view: StartupView::Last, confirm_delete: false, gpu: true, - preview_edge: 2560, + preview_limit: 0, external_editor: String::new(), memory_mb: 0, film_names: true, @@ -174,8 +176,29 @@ impl Default for AppSettings { } } -/// Preview sizes offered in Settings → Performance. -pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120]; +/// Preview size limits offered in Settings → Performance (0 = Automatic). +pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; + +/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. +pub const LOUPE_EDGE_CEILING: u32 = 8192; + +/// Long edge of Build Standard-Sized Previews when the limit is Automatic. +pub const STANDARD_PREVIEW_EDGE: u32 = 2560; + +impl AppSettings { + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: + /// that size, never above the photo's own `native_long_edge`, the user's limit or the ceiling. + pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize) -> usize { + let ceiling = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; + let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize; + wanted.min(native_long_edge.max(8)).min(ceiling) + } + + /// The edge Build Standard-Sized Previews uses. + pub fn standard_preview_edge(&self) -> u32 { + if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit } + } +} /// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; @@ -685,8 +708,8 @@ impl UiState { self.brush_size = self.brush_size.clamp(0.002, 0.5); self.dialog = None; self.fullscreen = false; - if !crate::state::PREVIEW_EDGES.contains(&self.settings.preview_edge) { - self.settings.preview_edge = AppSettings::default().preview_edge; + if !crate::state::PREVIEW_LIMITS.contains(&self.settings.preview_limit) { + self.settings.preview_limit = AppSettings::default().preview_limit; } match self.settings.startup_view { StartupView::Last => {} diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs new file mode 100644 index 00000000..53476886 --- /dev/null +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -0,0 +1,143 @@ +//! Issue #323: the Detail view must not be softened by a hidden render-size cap. By default the +//! loupe renders at the size it is drawn at (up to the photo's own pixels and a safety ceiling); +//! "Preview size" in Settings only lowers that when the user asks for it. + +use crate::state::{AppSettings, LOUPE_EDGE_CEILING, PREVIEW_LIMITS, STANDARD_PREVIEW_EDGE}; + +fn with_limit(preview_limit: u32) -> AppSettings { + AppSettings { preview_limit, ..Default::default() } +} + +// Given a fresh install +#[test] +fn default_is_automatic() { + assert_eq!(AppSettings::default().preview_limit, 0); + assert_eq!(PREVIEW_LIMITS[0], 0, "Automatic is the first choice"); +} + +// Given Automatic, when a 24 MP photo is shown at 1:1, then the render is the photo's own width +#[test] +fn automatic_renders_a_one_to_one_view_at_native_size() { + assert_eq!(with_limit(0).loupe_edge(6000.0, 6000), 6000); +} + +// Given Automatic, a Retina fit view of 3024 px is not held to the old 2560 px cap +#[test] +fn automatic_does_not_cap_a_fit_view_at_the_old_default() { + assert_eq!(with_limit(0).loupe_edge(3024.0, 6000), 3024); +} + +// Given Automatic, a small photo zoomed far in is not rendered above its own pixels +#[test] +fn never_renders_above_the_photos_own_pixels() { + assert_eq!(with_limit(0).loupe_edge(8000.0, 1000), 1000); +} + +// Given Automatic, a huge zoom on a huge photo stays under the memory/GPU ceiling +#[test] +fn automatic_is_bounded_by_the_ceiling() { + assert_eq!(with_limit(0).loupe_edge(48000.0, 12000), LOUPE_EDGE_CEILING as usize); +} + +// Given the user chose 1600 px, the render never exceeds it +#[test] +fn an_explicit_limit_caps_the_render() { + assert_eq!(with_limit(1600).loupe_edge(6000.0, 6000), 1600); + assert_eq!(with_limit(1600).loupe_edge(900.0, 6000), 900, "and does not raise a small view"); +} + +// Whatever the inputs (hostile or degenerate), the result is a usable size +#[test] +fn degenerate_inputs_give_a_usable_size() { + let s = with_limit(0); + for wanted in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 0.0] { + let e = s.loupe_edge(wanted, 6000); + assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&e), "{wanted}: {e}"); + } + assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0))); + assert!(with_limit(7).loupe_edge(1000.0, 6000) >= 8, "a corrupt limit is clamped"); +} + +// Build Standard-Sized Previews needs a number even when the limit is Automatic +#[test] +fn standard_previews_use_the_limit_or_the_standard_size() { + assert_eq!(with_limit(0).standard_preview_edge(), STANDARD_PREVIEW_EDGE); + assert_eq!(with_limit(3840).standard_preview_edge(), 3840); +} + +// Given a ui.json from before this setting (key previewEdge), when it loads, then the user is on +// Automatic: the old default of 2560 was the bug, and the key no longer means anything +#[test] +fn an_old_saved_preview_edge_does_not_pin_the_cap() { + let s: AppSettings = serde_json::from_str(r#"{"previewEdge": 2560}"#).unwrap(); + assert_eq!(s.preview_limit, 0); +} + +// A saved limit is kept; an unknown one (hand-edited file) falls back to Automatic +#[test] +fn saved_limits_round_trip_and_unknown_ones_are_reset() { + let mut ui = crate::UiState::default(); + ui.settings.preview_limit = 3840; + let back = serde_json::from_str::(&serde_json::to_string(&ui).unwrap()).unwrap().sanitized(); + assert_eq!(back.settings.preview_limit, 3840); + ui.settings.preview_limit = 123; + assert_eq!(ui.sanitized().settings.preview_limit, 0); +} + +// ---- Behaviour in the running UI ------------------------------------------------------------- + +mod in_the_loupe { + use std::time::Duration; + + use serde_json::json; + + use crate::headless::Headless; + use crate::render::Slot; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + const SETTLE: Duration = Duration::from_secs(120); + + /// The demo library in a 1400×900 window, the first photo open in Detail. + fn detail() -> (Headless, usize) { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let native = { + let id = h.app.session.active().expect("a photo is open"); + let p = h.app.session.catalog.photo(id).expect("it is in the catalog"); + p.width.max(p.height) as usize + }; + (h, native) + } + + fn rendered_long_edge(h: &Headless) -> usize { + let t = h.app.renderer.textures.get(&Slot::Main).expect("the loupe rendered"); + t.size[0].max(t.size[1]) + } + + // Given a photo larger than the old 2560 px cap, when I zoom to 1:1, then the loupe holds its pixels + #[test] + fn one_to_one_is_rendered_at_the_photos_own_size() { + let (mut h, native) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let want = native.min(crate::state::LOUPE_EDGE_CEILING as usize); + let got = rendered_long_edge(&h); + assert!(got.abs_diff(want) <= 2, "native {native}: rendered {got}, wanted {want}"); + } + + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that + #[test] + fn an_explicit_limit_still_caps_one_to_one() { + let (mut h, native) = detail(); + assert!(native > 1600, "the demo photo must be larger than the cap for this to mean anything (is {native})"); + h.app.ui.settings.preview_limit = 1600; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(rendered_long_edge(&h) <= 1600, "rendered {}", rendered_long_edge(&h)); + } +} From ca5c212d03e007372bdca2b25caf7382c4bfaa0a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 12:37:15 -0400 Subject: [PATCH 02/39] #323: review fixes: prefetch and stand-ins stay at preview level; render sizes move in 64 px steps An adversarial review found that with Automatic the neighbour prefetch asked for the full source level, whose single cache slot then evicted the open photo's source, and that hover / Before renders and every window-resize pixel produced full-size renders. Prefetch and stand-ins are now capped at the preview source level and the loupe size is rounded up to a 64 px step. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 9 +++++--- crates/ui-egui/src/state.rs | 22 ++++++++++++++++-- crates/ui-egui/src/tests_preview_limit.rs | 27 ++++++++++++++++++++--- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ca74d623..7810b95e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -285,6 +285,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let native_long = native[0].max(native[1]); let want = app.ui.settings.loupe_edge(target_rect.width().max(target_rect.height()) * ppp * scale, native_long); let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap + let se = app.ui.settings.stand_in_edge(want); + let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); @@ -293,7 +296,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // hovering a preset or profile: the photo with that look, shown instead of the loupe render // once it is ready (nothing is committed) let hover_key = match app.hover_preview.clone() { - Some(h) if !interacting => app.session.preview_job(id, rw.max(8), rh.max(8), !crop_tool, &h.settings).map(|job| { + Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { let key = job.key; app.renderer.request(Slot::Hover, job, 105); (key, h.label) @@ -313,7 +316,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); let na = nf.aspect() as f32; let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); - let nw = app.ui.settings.loupe_edge(nr.width().max(nr.height()) * ppp, np.width.max(np.height).max(1) as usize); + let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, np.width.max(np.height).max(1) as usize); let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); @@ -329,7 +332,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash)); if (split || show_before || split_view) - && let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool) + && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) { app.renderer.request(Slot::Before, job, 90); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e24c6279..589d58e6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -182,16 +182,34 @@ pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; /// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. pub const LOUPE_EDGE_CEILING: u32 = 8192; +/// Loupe render sizes move in steps of this many pixels. +pub const LOUPE_EDGE_STEP: usize = 64; + /// Long edge of Build Standard-Sized Previews when the limit is Automatic. pub const STANDARD_PREVIEW_EDGE: u32 = 2560; impl AppSettings { /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: - /// that size, never above the photo's own `native_long_edge`, the user's limit or the ceiling. + /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at + /// every pixel), never above the photo's own `native_long_edge`, the user's limit or the ceiling. pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize) -> usize { let ceiling = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize; - wanted.min(native_long_edge.max(8)).min(ceiling) + let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP; + stepped.min(native_long_edge.max(8)).min(ceiling) + } + + /// Long edge for warming a neighbouring photo: its loupe size but never above the preview + /// source level. A full-size decode would replace the open photo's single full-resolution + /// source in the cache, and nothing is gained by it. + pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize) -> usize { + self.loupe_edge(wanted_px, native_long_edge).min(lightcraft_engine::SourceLevel::Preview.max_edge()) + } + + /// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn + /// over the loupe: capped at the preview source level like the old default. + pub fn stand_in_edge(&self, loupe_edge: usize) -> usize { + loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()) } /// The edge Build Standard-Sized Previews uses. diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 53476886..1fb32a0e 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -24,7 +24,28 @@ fn automatic_renders_a_one_to_one_view_at_native_size() { // Given Automatic, a Retina fit view of 3024 px is not held to the old 2560 px cap #[test] fn automatic_does_not_cap_a_fit_view_at_the_old_default() { - assert_eq!(with_limit(0).loupe_edge(3024.0, 6000), 3024); + assert_eq!(with_limit(0).loupe_edge(3024.0, 6000), 3072, "rounded up to the next 64 px step"); +} + +// Dragging a window edge must not re-render (and re-key the caches) on every pixel +#[test] +fn render_sizes_move_in_steps() { + let s = with_limit(0); + assert_eq!(s.loupe_edge(2945.0, 6000), s.loupe_edge(3008.0, 6000)); + assert_ne!(s.loupe_edge(3008.0, 6000), s.loupe_edge(3009.0, 6000)); +} + +// Neighbour prefetch and hover / before stand-ins stay at the preview level: a full-size decode +// would evict the open photo's single full-resolution source (review of #323) +#[test] +fn prefetch_and_stand_ins_never_ask_for_the_full_source_level() { + use lightcraft_engine::SourceLevel; + let s = with_limit(0); + for wanted in [1000.0, 2560.0, 3024.0, 6000.0, 48000.0] { + assert!(s.prefetch_edge(wanted, 6000) <= SourceLevel::Preview.max_edge(), "{wanted}"); + assert!(s.stand_in_edge(s.loupe_edge(wanted, 6000)) <= SourceLevel::Preview.max_edge()); + } + assert_eq!(s.prefetch_edge(1000.0, 6000), 1024, "a small view is not raised"); } // Given Automatic, a small photo zoomed far in is not rendered above its own pixels @@ -43,7 +64,7 @@ fn automatic_is_bounded_by_the_ceiling() { #[test] fn an_explicit_limit_caps_the_render() { assert_eq!(with_limit(1600).loupe_edge(6000.0, 6000), 1600); - assert_eq!(with_limit(1600).loupe_edge(900.0, 6000), 900, "and does not raise a small view"); + assert_eq!(with_limit(1600).loupe_edge(900.0, 6000), 960, "and does not raise a small view past its step"); } // Whatever the inputs (hostile or degenerate), the result is a usable size @@ -54,7 +75,7 @@ fn degenerate_inputs_give_a_usable_size() { let e = s.loupe_edge(wanted, 6000); assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&e), "{wanted}: {e}"); } - assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0))); + assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0)) && s.prefetch_edge(f32::NAN, 0) >= 8); assert!(with_limit(7).loupe_edge(1000.0, 6000) >= 8, "a corrupt limit is clamped"); } From b358022f14606d9b5faf8a0d4ded2a1d33ad4444 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 12:55:39 -0400 Subject: [PATCH 03/39] #323: pipeline: render a window of the output (RenderRequest::window) A zoomed view needs the pixels it shows at the zoom scale, not a whole-frame texture stretched over them. RenderRequest gains an optional PixelWindow of the virtual full output that max_w x max_h describes; only the window is materialized. It is Frame::window: the crop rectangle narrows to the window (flips as drawn), so sampling, masks, spots, red eye and the lens warp need no change. The post-crop vignette keeps the whole frame's geometry (Frame::view) and dehaze uses the whole frame's airlight (estimated on a 384 px render), so a window matches the same pixels of the full render. The GPU renderer declines windowed requests (CPU for now). Co-Authored-By: Claude Sonnet 5.5 --- apps/lightcraft-web/src/wire.rs | 1 + crates/gpu/src/lib.rs | 3 +- crates/pipeline/src/finish.rs | 11 +- crates/pipeline/src/geometry.rs | 30 ++++++ crates/pipeline/src/lib.rs | 58 ++++++++++- crates/pipeline/src/local.rs | 9 ++ crates/pipeline/src/tests_window.rs | 152 ++++++++++++++++++++++++++++ 7 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 crates/pipeline/src/tests_window.rs diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 0474e2a0..fad1a816 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -108,6 +108,7 @@ impl WireJob { space: lightcraft_engine::pipeline::OutputSpace::Srgb, depth: lightcraft_engine::pipeline::OutputDepth::U8, proof: None, + window: None, } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 5ef3011b..8644a85d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -298,7 +298,8 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R 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 - if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { + // (a windowed render runs on the CPU until the kernels know the window: vignette and airlight) + if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() || req.window.is_some() { return None; } let gpu = device()?; diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index f352004e..6b422ee9 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -190,6 +190,8 @@ pub struct FinishParams { pub w: usize, pub h: usize, pub px_per_long: f64, + /// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed. + pub view: [f32; 4], } impl FinishParams { @@ -255,6 +257,7 @@ impl FinishParams { w, h, px_per_long, + view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]), } } } @@ -344,7 +347,9 @@ pub(crate) fn finish_with( let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); let out_to_norm = fp.out_to_norm; let long = fp.ow.max(fp.oh); - let aspect = w as f32 / h as f32; + let [vx, vy, vw, vh] = fp.view; + // the vignette belongs to the whole output frame, not to the window being drawn + let aspect = vw / vh; let srgb = srgb_lut(); let mut out = vec![T::default(); w * h]; @@ -502,8 +507,8 @@ pub(crate) fn finish_with( // --- vignette (display linear, post-crop) if let Some(v) = vig { - let u = (x as f32 + 0.5) / w as f32 * 2.0 - 1.0; - let vv = (y as f32 + 0.5) / h as f32 * 2.0 - 1.0; + let u = (vx + x as f32 + 0.5) / vw * 2.0 - 1.0; + let vv = (vy + y as f32 + 0.5) / vh * 2.0 - 1.0; let sx = 1.0 + (aspect - 1.0) * v.aspect_mix; let sy = 1.0 + (1.0 / aspect - 1.0) * v.aspect_mix; let (ax, ay) = ((u * sx.max(1.0) / sx.max(sy)).abs(), (vv * sy.max(1.0) / sx.max(sy)).abs()); diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index af45526b..d354283b 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -31,6 +31,18 @@ pub struct Frame { pub flip_v: bool, /// Lens corrections / perspective (None = identity). pub warp: Option, + /// Set when this frame draws only a window of a larger output (see [`Frame::window`]). + pub view: Option, +} + +/// Where a windowed [`Frame`]'s pixels sit in the full output: its offset and the full output's +/// size, for effects defined by the whole frame (post-crop vignette). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewWindow { + pub x: f64, + pub y: f64, + pub full_w: f64, + pub full_h: f64, } impl Frame { @@ -62,6 +74,7 @@ impl Frame { flip_h: apply_crop && s.crop.flip_h, flip_v: apply_crop && s.crop.flip_v, warp: (!warp.is_identity()).then_some(warp), + view: None, }; if apply_crop && s.geometry.constrain_crop { f.constrain_to_image(); @@ -97,6 +110,23 @@ impl Frame { self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t); } + /// This frame narrowed to `win` of its `full_w × full_h` output: the same mapping, but the + /// output is only that window (the crop rectangle shrinks to it; flips are as drawn). + pub fn window(&self, full_w: usize, full_h: usize, win: crate::PixelWindow) -> Frame { + let (fw, fh) = (full_w.max(1) as f64, full_h.max(1) as f64); + let (x0, x1) = (win.x as f64 / fw, (win.x + win.w) as f64 / fw); + let (y0, y1) = (win.y as f64 / fh, (win.y + win.h) as f64 / fh); + // flips happen on the output before it maps onto the crop: undo them for the range + let (x0, x1) = if self.flip_h { (1.0 - x1, 1.0 - x0) } else { (x0, x1) }; + let (y0, y1) = if self.flip_v { (1.0 - y1, 1.0 - y0) } else { (y0, y1) }; + let r = self.crop.rect; + let (rw, rh) = (r.width(), r.height()); + let mut f = self.clone(); + f.crop.rect = Rect { x0: r.x0 + x0 * rw, y0: r.y0 + y0 * rh, x1: r.x0 + x1 * rw, y1: r.y0 + y1 * rh }; + f.view = Some(ViewWindow { x: win.x as f64, y: win.y as f64, full_w: fw, full_h: fh }); + f + } + /// Add automatically estimated lateral CA (`[α_R, α_B]`, see [`crate::optics::estimate_lateral_ca`]). pub fn add_lateral_ca(&mut self, ca: [f64; 2]) { if ca == [0.0; 2] { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 3feaa153..45bc1ff4 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -99,6 +99,31 @@ pub struct RenderRequest { pub depth: OutputDepth, /// Soft proofing (CPU only; see [`Proof`]). pub proof: Option, + /// Render only this window of the output that `max_w × max_h` describes (a zoomed view): + /// the result is that window's pixels, as in the whole render. See [`PixelWindow`]. + pub window: Option, +} + +/// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered +/// without materializing the whole frame. The output frame is the one `max_w × max_h` fits, after +/// crop, straighten and flips, as drawn. Out-of-range windows are clamped into it. Spatial stages +/// (clarity, dehaze, sharpening…) read their neighbours, so ask for a margin around what is kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PixelWindow { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +impl PixelWindow { + /// This window inside a `full_w × full_h` frame: at least one pixel, wholly inside. + pub fn clamped(self, full_w: usize, full_h: usize) -> PixelWindow { + let (fw, fh) = (full_w.max(1), full_h.max(1)); + let x = self.x.min(fw - 1); + let y = self.y.min(fh - 1); + PixelWindow { x, y, w: self.w.clamp(1, fw - x), h: self.h.clamp(1, fh - y) } + } } impl RenderRequest { @@ -112,6 +137,7 @@ impl RenderRequest { space: OutputSpace::Srgb, depth: OutputDepth::U8, proof: None, + window: None, } } } @@ -300,8 +326,13 @@ pub struct Plan<'a> { pub lin_key: u64, /// Red eye / pet eye corrections with their detected pupils, in output pixels. pub eyes: Vec, + /// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame). + pub fixed_air: Option, } +/// Long edge of the small render a windowed render estimates the whole frame's airlight from. +const AIRLIGHT_PROXY_EDGE: usize = 384; + /// Resolve `s` against `src` for `req` (see [`Plan`]). pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> { let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) { @@ -311,8 +342,22 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R visualize::adjust_settings(req.overlay, &mut settings); let s = &*settings; let frame = frame_for(src, info, s, req.apply_crop); - let (w, h) = frame.fit(req.max_w, req.max_h); - let px_per_long = frame.px_per_long(w); + let (full_w, full_h) = frame.fit(req.max_w, req.max_h); + // sizes and scale are those of the whole output; a window only narrows what is drawn + let px_per_long = frame.px_per_long(full_w); + let mut fixed_air = None; + let (frame, w, h) = match req.window { + Some(win) => { + let win = win.clamped(full_w, full_h); + if s.section_enabled("effects") && (s.effects.dehaze != 0.0 || s.masks.iter().any(|m| m.adjust.dehaze != 0.0)) { + let k = (AIRLIGHT_PROXY_EDGE as f64 / full_w.max(full_h) as f64).min(1.0); + let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1)); + fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph)); + } + (frame.window(full_w, full_h, win), win.w, win.h) + } + None => (frame, full_w, full_h), + }; let src_long = src.width.max(src.height); let geo = hash_of((format!("{frame:?}"), w, h)); let (wb_t, wb_tint) = local::effective_wb(info, s); @@ -328,7 +373,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), src_long, )); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes } + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -410,7 +455,10 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Some(p) if p.key == lin_key => p, _ => local::Planes { key: lin_key, ..Default::default() }, }; - let prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes); + let mut prep = local::prepare(lin.clone(), s, frame, px_per_long, req.quality, &mut planes); + if let Some(a) = plan.fixed_air { + prep.air = a; + } lap("prepare", &mut t); if let Some((a, c)) = shared { c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); @@ -510,3 +558,5 @@ mod tests; mod tests_geometry; #[cfg(test)] mod tests_local; +#[cfg(test)] +mod tests_window; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index cedb7cd1..61ec8482 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -328,6 +328,15 @@ pub(crate) fn prepare(img: Arc, s: &DevelopSettings, frame: &Frame, px_p Prepared { img, log_l, base, clarity_blur, texture_blur, dark, chroma_blur, air, masks, px_per_long } } +/// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide): +/// a windowed render must dehaze with the frame's airlight, not that of the pixels it holds. +pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 { + let mut img = frame.sample(src, proxy_w, proxy_h); + white_balance(&mut img, info, s); + let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32; + airlight(&gaussian(&img.map(dark_of), sigma)) +} + /// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel. pub const AIRLIGHT_STEP: usize = 7; diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs new file mode 100644 index 00000000..045b0943 --- /dev/null +++ b/crates/pipeline/src/tests_window.rs @@ -0,0 +1,152 @@ +//! Region rendering (issue #323): a window of the virtual full-size output is rendered on its own +//! and must match the same pixels of the full render, so zoomed views can be sharp at any zoom +//! without materializing the whole frame. + +use lightcraft_develop::{DevelopSettings, controls}; +use lightcraft_geom::{Orientation, Rect}; +use lightcraft_raster::{Rgb32f, Rgba8}; + +use crate::{PixelWindow, RenderRequest, SourceInfo, render}; + +const W: usize = 960; +const H: usize = 640; +/// Context rendered around the pixels a caller keeps (spatial filters read their neighbours). +const HALO: usize = 64; + +fn scene() -> Rgb32f { + lightcraft_scenes::demo_library()[0].render(480, 320) +} + +fn full(s: &DevelopSettings) -> Rgba8 { + render(&scene(), &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image +} + +fn window(s: &DevelopSettings, win: PixelWindow) -> Rgba8 { + let req = RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }; + render(&scene(), &SourceInfo::default(), s, &req).image +} + +/// Largest per-channel difference and mean absolute difference between `win` (rendered for +/// `at`, kept `HALO` px in from its edges) and the same pixels of `full`. +fn compare(full: &Rgba8, win: &Rgba8, at: PixelWindow) -> (u8, f64) { + let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64); + for y in HALO..at.h - HALO { + for x in HALO..at.w - HALO { + let (a, b) = (full.get(at.x + x, at.y + y), win.get(x, y)); + for k in 0..3 { + let d = a[k].abs_diff(b[k]); + max = max.max(d); + sum += d as f64; + n += 1.0; + } + } + } + (max, sum / n) +} + +fn check(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let (f, w) = (full(s), window(s, win)); + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +const MID: PixelWindow = PixelWindow { x: 300, y: 180, w: 400, h: 300 }; + +// Given the window is the whole output, then it is the full render, byte for byte +#[test] +fn the_whole_frame_as_a_window_is_the_full_render() { + let s = DevelopSettings::default(); + let f = full(&s); + let w = window(&s, PixelWindow { x: 0, y: 0, w: f.width, h: f.height }); + assert_eq!(f.data, w.data); +} + +// Given default settings, a window in the middle matches the full render +#[test] +fn a_window_matches_the_full_render() { + check("default", &DevelopSettings::default(), MID, 1, 0.1); +} + +// Given a window at each corner, it matches too (edges of the frame are not special) +#[test] +fn windows_at_the_corners_match() { + let s = DevelopSettings::default(); + for (x, y) in [(0, 0), (W - 400, 0), (0, H - 300), (W - 400, H - 300)] { + check("corner", &s, PixelWindow { x, y, w: 400, h: 300 }, 1, 0.1); + } +} + +// Given crop, straighten and flip, the window still lands on the same pixels +#[test] +fn a_cropped_straightened_flipped_view_matches() { + let mut s = DevelopSettings::default(); + s.crop.geometry.rect = Rect { x0: 0.1, y0: 0.15, x1: 0.85, y1: 0.9 }; + s.crop.geometry.angle = 4.0; + s.crop.flip_h = true; + check("crop+angle+flipH", &s, MID, 3, 0.3); + s.crop.flip_h = false; + s.crop.flip_v = true; + check("crop+angle+flipV", &s, MID, 3, 0.3); +} + +// Given a rotated photo, the same +#[test] +fn rotated_and_mirrored_orientations_match() { + for o in [Orientation::Rotate90, Orientation::Rotate270, Orientation::FlipH, Orientation::Transpose] { + let s = DevelopSettings { orientation: o, ..Default::default() }; + let f = full(&s); + let win = PixelWindow { x: f.width / 4, y: f.height / 4, w: f.width / 2, h: f.height / 2 }; + check(&format!("{o:?}"), &s, win, 3, 0.3); + } +} + +// Given a post-crop vignette, the window shows the vignette of the whole frame, not its own +#[test] +fn the_vignette_belongs_to_the_frame_not_the_window() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "vignette.amount", -90.0); + controls::set(&mut s, "vignette.midpoint", 10.0); + // the vignette is really there: the corner is much darker than without it + let (with, without) = (full(&s), full(&DevelopSettings::default())); + let corner = |i: &Rgba8| i.get(W - 20, H - 20)[1] as i32; + assert!(corner(&without) - corner(&with) > 30, "{} vs {}", corner(&without), corner(&with)); + // …and a window over that corner shows it, at the same place + check("vignette", &s, PixelWindow { x: W - 400, y: H - 300, w: 400, h: 300 }, 2, 0.2); + check("vignette middle", &s, MID, 2, 0.2); +} + +// Given spatial adjustments, the window agrees away from its halo +#[test] +fn spatial_adjustments_agree_inside_the_halo() { + for (id, v) in + [("effects.clarity", 70.0), ("effects.dehaze", 40.0), ("detail.sharpenAmount", 80.0), ("light.shadows", 60.0), ("effects.texture", 60.0)] + { + let mut s = DevelopSettings::default(); + controls::set(&mut s, id, v); + check(id, &s, MID, 6, 0.8); + } +} + +// Given a huge virtual frame (48000 px: an 800 % zoom), only the window is rendered +#[test] +fn a_window_of_a_huge_frame_is_cheap() { + let req = RenderRequest { window: Some(PixelWindow { x: 20_000, y: 12_000, w: 256, h: 192 }), ..RenderRequest::fit(48_000, 32_000) }; + let r = render(&scene(), &SourceInfo::default(), &DevelopSettings::default(), &req); + assert_eq!((r.image.width, r.image.height), (256, 192)); +} + +// Hostile windows (from UI state) are clamped into the frame, never a panic or a huge buffer +#[test] +fn windows_outside_or_degenerate_are_clamped() { + let s = DevelopSettings::default(); + for win in [ + PixelWindow { x: W + 500, y: 5, w: 100, h: 100 }, + PixelWindow { x: 0, y: 0, w: 0, h: 0 }, + PixelWindow { x: usize::MAX, y: usize::MAX, w: usize::MAX, h: usize::MAX }, + PixelWindow { x: 900, y: 600, w: 5000, h: 5000 }, + ] { + let r = window(&s, win); + assert!(r.width >= 1 && r.height >= 1 && r.width <= W && r.height <= H, "{win:?}: {}×{}", r.width, r.height); + } +} From 2079e8e08dea87df1c81257354d4c53599fed567 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 12:57:44 -0400 Subject: [PATCH 04/39] #323: engine region_job and the window geometry for zoomed views Session::region_job renders one PixelWindow of the loupe's frame from the source level that frame size needs, with a key of its own and no cache writes. ui-egui::region decides when a window is needed (the whole-frame render is stretched) and which one: the visible pixels plus a margin, snapped to a 256 px grid so small pans reuse the render, bounded in size, clamped into the frame. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/media.rs | 51 ++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/region.rs | 113 +++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 crates/ui-egui/src/region.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 5d0c5d82..91b7bdb8 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -881,6 +881,32 @@ impl crate::Session { Some(job) } + /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame + /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and + /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. + /// Nothing is cached here (a window is only worth keeping while it is on screen). + pub fn region_job( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + let mut job = self.render_job(id, full_w, full_h, false, apply_crop)?; + job.request.window = Some(window); + job.key = Hasher128::new() + .u64(job.key) + .str("window") + .u64(window.x as u64) + .u64(window.y as u64) + .u64(window.w as u64) + .u64(window.h as u64) + .finish() + .0 as u64; + Some(job) + } + /// The embedded preview of an unedited raw (path, loader), when the app installed a loader. fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { match (&p.source, &self.media.preview_loader) { @@ -1218,6 +1244,31 @@ mod tests { assert_ne!(a.key, b.key); } + // Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale, + // from the original, with a key of its own + #[test] + fn region_jobs_render_a_window_of_the_zoomed_frame() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "demo photos are camera-sized"); + let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 }; + let job = s.region_job(p.id, w, h, win, true).unwrap(); + assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original"); + let img = job.run().rendered.unwrap().image; + assert_eq!((img.width, img.height), (300, 200)); + // other windows, and the whole-frame job, never share a key + let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap(); + let whole = s.render_job(p.id, w, h, false, true).unwrap(); + let again = s.region_job(p.id, w, h, win, true).unwrap(); + assert_ne!(other.key, again.key); + assert_ne!(whole.key, again.key); + assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key"); + // a window never writes the photo's view preview or the thumbnail cache + assert!(again.view_cache.is_none() && again.cache.is_none()); + } + #[test] fn variant_jobs_have_their_own_keys_and_hit_the_cache() { let mut s = crate::Session::with_demo(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 98e9367e..a4e009e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod menubar; pub mod menus; pub mod merge; pub mod panels; +pub mod region; pub mod render; pub mod shortcuts; pub mod softpaint; diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs new file mode 100644 index 00000000..75cc062c --- /dev/null +++ b/crates/ui-egui/src/region.rs @@ -0,0 +1,113 @@ +//! Sharp zoomed views (issue #323). The loupe renders the whole frame in one texture, which can't +//! be as large as a 1:1 or 8:1 view of a big photo. Past that size it also renders just the window +//! of the frame that is on screen, at the zoom scale, and draws it over the whole-frame render. +//! +//! This module is the geometry: when a window is needed and which one. Windows are snapped to a +//! grid and carry a margin, so panning by a few pixels re-uses the render and the spatial stages +//! (clarity, dehaze…) have the context they read around the visible edge. + +use lightcraft_engine::pipeline::PixelWindow; + +/// Windows start and end on multiples of this many pixels of the zoomed frame. +pub const SNAP: usize = 256; +/// Context rendered beyond the visible pixels (a multiple of [`SNAP`] after snapping). +pub const MARGIN: usize = 256; +/// The most a window may span on either axis: a bound on its texture whatever the window size. +pub const MAX_SPAN: usize = 8192; + +/// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of +/// the whole-frame render, which has only `rendered_long` of them. +pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { + drawn_long.is_finite() && drawn_long > rendered_long as f32 * 1.02 + 1.0 +} + +/// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the +/// frame's pixels, as much of the frame as is on screen — is covered: expanded by [`MARGIN`], +/// snapped outwards to the [`SNAP`] grid and clamped into the frame. `None`: nothing is visible. +pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32)) -> Option { + if full_w == 0 || full_h == 0 || [visible.0, visible.1, visible.2, visible.3].iter().any(|v| !v.is_finite()) { + return None; + } + let axis = |lo: f32, hi: f32, full: usize| -> Option<(usize, usize)> { + let (lo, hi) = (lo.max(0.0), hi.min(full as f32)); + if hi <= lo { + return None; + } + let a = (lo as usize).saturating_sub(MARGIN) / SNAP * SNAP; + let b = ((hi.ceil() as usize).saturating_add(MARGIN)).div_ceil(SNAP).saturating_mul(SNAP).min(full); + // a window larger than MAX_SPAN keeps the visible part (centred on it), snapped + if b - a > MAX_SPAN { + let mid = ((lo + hi) / 2.0) as usize; + let a = mid.saturating_sub(MAX_SPAN / 2) / SNAP * SNAP; + return Some((a, (a + MAX_SPAN).min(full))); + } + Some((a, b)) + }; + let (x0, x1) = axis(visible.0, visible.2, full_w)?; + let (y0, y1) = axis(visible.1, visible.3, full_h)?; + Some(PixelWindow { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Given a view drawn at 6000 px and a render of 6000, no window is needed; at 6000 vs 2560 it is + #[test] + fn a_window_is_needed_only_when_the_whole_frame_render_is_stretched() { + assert!(!needed(6000.0, 6000)); + assert!(!needed(6100.0, 6000), "a couple of percent is not worth a second render"); + assert!(needed(6000.0, 2560)); + assert!(needed(48000.0, 8192)); + assert!(!needed(f32::NAN, 100) && !needed(f32::INFINITY, 100)); + } + + // Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid + #[test] + fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0)).unwrap(); + assert!(w.x <= 20_000 - MARGIN && w.x + w.w >= 21_400 + MARGIN, "{w:?}"); + assert!(w.y <= 10_000 - MARGIN && w.y + w.h >= 10_900 + MARGIN, "{w:?}"); + assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); + } + + // Given a small pan, the same window is kept (no re-render); a big pan changes it + #[test] + fn small_pans_keep_the_window() { + let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0)).unwrap(); + let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0)).unwrap(); + assert_eq!(a, b); + let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0)).unwrap(); + assert_ne!(a, c); + } + + // Given a frame smaller than the margin, or a view at its edge, the window is clamped into it + #[test] + fn the_window_stays_inside_the_frame() { + let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0)).unwrap(); + assert_eq!((w.x, w.y), (0, 0)); + let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0)).unwrap(); + assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); + assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); + } + + // Given nothing visible, or hostile numbers, there is no window (never a panic) + #[test] + fn nothing_visible_or_hostile_input_is_no_window() { + assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0)), None); + assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0)), None); + assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0)), None); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0)), None); + } + let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30)).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); + } + + // Given a view of an enormous area, the window is bounded + #[test] + fn a_window_is_bounded() { + let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0)).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); + } +} From e0539db3f57c0c0a248f75d6103fc1b48e925641 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:04:25 -0400 Subject: [PATCH 05/39] #323: the loupe renders the visible window at the zoom scale When the whole-frame render is smaller than the zoomed view (a 1:1 or 8:1 view of a large photo, or a capped Preview size) the loupe also requests a window render (Slot::Region) of the pixels on screen plus a margin, and draws it over the whole-frame render at its place of the frame. Stateless, grid-snapped windows mean small pans reuse the render. Not used in the browser build, while comparing, hovering a look, soft-proofing or showing an overlay. ui.inspect's loupe reports the window. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/control.rs | 8 ++- crates/ui-egui/src/lib.rs | 6 ++ crates/ui-egui/src/panels/detail.rs | 46 +++++++++++++++ crates/ui-egui/src/region.rs | 10 ++++ crates/ui-egui/src/render.rs | 24 ++++++-- crates/ui-egui/src/tests_preview_limit.rs | 72 +++++++++++++++++++++++ 6 files changed, 161 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7456a724..072edc33 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -92,7 +92,13 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), "perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()}, - "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), + "loupe": app.loupe_shown.map(|(p, src)| json!({ + "photo": p.0, + "source": src, + "pending": app.renderer.is_pending(crate::render::Slot::Main), + // zoomed past the whole-frame render: the window rendered at the zoom scale (pixels of the zoomed frame) + "region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})), + })), "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, "notices": app.notices, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4e009e4..3669610c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -198,6 +198,10 @@ pub struct LightcraftApp { /// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small", /// "thumb", "none"). pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>, + /// The window render the loupe asked for last, by job key (see [`region`]); kept for the few + /// windows whose textures can be on screen, and for the inspector. + pub region_view: Option, + pub(crate) region_tiles: std::collections::HashMap, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, /// An import in progress (the import review dialog's batches). @@ -265,6 +269,8 @@ impl LightcraftApp { widgets: vec![], gesture: None, loupe_shown: None, + region_view: None, + region_tiles: Default::default(), merge: merge::MergeState::default(), import: None, scan: None, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 7810b95e..71d11018 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -336,6 +336,42 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { { app.renderer.request(Slot::Before, job, 90); } + // zoomed past what the whole-frame render holds: also render the part on screen at the zoom + // scale (not while comparing, hovering a look or showing an overlay: those draw one image) + let plain_view = !split + && !show_before + && !split_view + && hover_key.is_none() + && !app.ui.soft_proof + && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; + let drawn_long = target_rect.width().max(target_rect.height()) * ppp; + let region_wanted = plain_view && app.renderer.can_render_windows() && crate::region::needed(drawn_long, rw.max(rh)); + if region_wanted { + let (fw, fh) = if aspect >= 1.0 { (drawn_long, drawn_long / aspect) } else { (drawn_long * aspect, drawn_long) }; + let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); + // what is on screen, in pixels of the zoomed frame + let to_px = |v: f32| v * ppp; + let visible = ( + to_px(canvas.left() - target_rect.left()), + to_px(canvas.top() - target_rect.top()), + to_px(canvas.right() - target_rect.left()), + to_px(canvas.bottom() - target_rect.top()), + ); + if let Some(win) = crate::region::window_for(fw, fh, visible) + && let Some(job) = app.session.region_job(id, fw, fh, win, !crop_tool) + { + let job = if interacting { job.draft() } else { job }; + let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win }; + if app.region_tiles.len() > 8 { + app.region_tiles.clear(); + } + app.region_tiles.insert(job.key, view); + app.region_view = Some(view); + app.renderer.request(Slot::Region, job, 99); + } + } else { + app.region_view = None; + } let p = ui.painter_at(canvas); // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame // between photos, and the full render replaces them in place) @@ -400,6 +436,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } + // the window render, drawn over the whole-frame one where it belongs in the frame + if region_wanted + && let Some(tex) = app.renderer.textures.get(&Slot::Region).filter(|t| t.photo == id) + && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.photo == id && Some(v.full) == app.region_view.map(|r| r.full)) + { + let (fw, fh) = (v.full.0 as f32, v.full.1 as f32); + let at = img_rect.min + vec2(v.window.x as f32 / fw * img_rect.width(), v.window.y as f32 / fh * img_rect.height()); + let dst = Rect::from_min_size(at, vec2(v.window.w as f32 / fw * img_rect.width(), v.window.h as f32 / fh * img_rect.height())); + p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } app.loupe_shown = Some((id, shown)); if app.ui.before_after == BeforeAfter::Split { let mid = img_rect.center().x; diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 75cc062c..1c894f96 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -15,6 +15,16 @@ pub const MARGIN: usize = 256; /// The most a window may span on either axis: a bound on its texture whatever the window size. pub const MAX_SPAN: usize = 8192; +/// A window render the loupe asked for: which photo, the size of the zoomed frame it is a window +/// of, and the window. The texture that comes back is drawn at this place of the frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RegionView { + pub photo: lightcraft_catalog::PhotoId, + pub key: u64, + pub full: (usize, usize), + pub window: PixelWindow, +} + /// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of /// the whole-frame render, which has only `rendered_long` of them. pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 5c1c1bbb..b4940ea5 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -30,6 +30,9 @@ pub enum Slot { /// A thumbnail's stand-in (embedded preview of an unedited raw). ThumbQuick(PhotoId), Main, + /// The loupe zoomed past what `Main` can hold: the window of the frame that is on screen, + /// rendered at the zoom scale and drawn over `Main` (see [`crate::region`]). + Region, /// The loupe's stand-in until `Main` has the photo. Preview, Before, @@ -137,6 +140,14 @@ pub struct Renderer { #[cfg(not(target_arch = "wasm32"))] const IDLE_TRIM: std::time::Duration = std::time::Duration::from_secs(3); +impl Slot { + /// An interactive view of the open photo: slider drags redo only the stages they feed, and a + /// draft that finishes late still replaces older pixels. + pub fn is_view(self) -> bool { + matches!(self, Slot::Main | Slot::Region | Slot::Before | Slot::Hover) + } +} + impl Default for Renderer { fn default() -> Self { let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; @@ -219,6 +230,12 @@ impl Renderer { } true } + /// Whether windows of the frame can be rendered here: not in the browser build, whose workers + /// rebuild the request from a wire format that has no window yet. + pub fn can_render_windows(&self) -> bool { + self.offload.is_none() + } + /// Run jobs through `offload` from now on (instead of the job pool / inline). pub fn set_offload(&mut self, offload: Box) { self.offload = Some(offload); @@ -247,8 +264,7 @@ impl Renderer { job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); // (an offload keeps its own per-view stage caches; the flag tells it to) - let job = - if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; + let job = if slot.is_view() { job.with_stages(self.stages.entry(slot).or_default().clone()) } else { job }; if self.offload.is_some() { self.seq += 1; self.queue.retain(|q| q.slot != slot); @@ -452,7 +468,7 @@ impl Renderer { // Ignore obsolete pixels, failures and decoded sources, even for the same render key; // an interactive view still takes a superseded draft that is newer than what it shows. if self.request_ids.get(&slot) != Some(&r.request_id) { - let newer_draft = matches!(slot, Slot::Main | Slot::Before | Slot::Hover) + let newer_draft = slot.is_view() && self.request_ids.contains_key(&slot) && r.request_id >= self.epoch && self.shown_ids.get(&slot).is_none_or(|shown| r.request_id > *shown) @@ -464,7 +480,7 @@ impl Renderer { self.request_ids.remove(&slot); session.accept(&r); } - if matches!(slot, Slot::Main | Slot::Before | Slot::Hover) { + if slot.is_view() { self.shown_ids.insert(slot, r.request_id); } if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 1fb32a0e..c17b5157 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -161,4 +161,76 @@ mod in_the_loupe { h.settle(SETTLE); assert!(rendered_long_edge(&h) <= 1600, "rendered {}", rendered_long_edge(&h)); } + + fn region_tile(h: &Headless) -> Option<(usize, usize)> { + h.app.renderer.textures.get(&Slot::Region).map(|t| (t.size[0], t.size[1])) + } + + // Given a photo that fits the whole-frame render at 1:1, then no second render is made + #[test] + fn no_window_render_while_the_whole_frame_render_is_sharp_enough() { + let (mut h, _) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert_eq!(region_tile(&h), None); + } + + // Given the user capped the preview at 1600 px, when I zoom to 1:1, then the visible part is + // rendered on its own at the zoom scale and drawn over the whole-frame render + #[test] + fn a_capped_one_to_one_view_gets_a_sharp_window() { + let (mut h, native) = detail(); + h.app.ui.settings.preview_limit = 1600; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let (w, hh) = region_tile(&h).expect("a window render"); + // it covers what is on screen (the 1400×900 window less its chrome), and is not the frame + assert!(w >= 1300 && hh >= 700, "{w}×{hh}"); + assert!(w < native && hh < native, "{w}×{hh} of a {native} px frame"); + let region = h.app.region_view.expect("the window is described for the inspector"); + assert_eq!(region.full.0.max(region.full.1), native, "1:1 means the frame is the photo's own size"); + } + + // Given 800 % zoom (a frame of 48000 px), the window is rendered and bounded + #[test] + fn eight_to_one_renders_only_what_is_visible() { + let (mut h, native) = detail(); + h.app.ui.zoom = crate::state::Zoom::Percent(800.0); + h.settle(SETTLE); + let (w, hh) = region_tile(&h).expect("a window render"); + assert!(w.max(hh) <= crate::region::MAX_SPAN && w < native * 8 / 2, "{w}×{hh}"); + // …and panning far away asks for another window + let before = h.app.region_view.unwrap().window; + h.app.ui.pan = (0.9, 0.9); + h.settle(SETTLE); + assert_ne!(h.app.region_view.unwrap().window, before); + } + + // The window is drawn where it belongs: its pixels agree with the whole-frame render at the + // same place of the frame (a shift, a flip or a wrong scale would not) + #[test] + fn the_window_lines_up_with_the_whole_frame_render() { + let (mut h, _) = detail(); + h.app.renderer.keep_pixels = true; + h.app.ui.settings.preview_limit = 1600; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let view = h.app.region_view.expect("a window render"); + let tile = h.app.renderer.textures.get(&Slot::Region).and_then(|t| t.pixels.clone()).expect("tile pixels"); + let main = h.app.renderer.textures.get(&Slot::Main).and_then(|t| t.pixels.clone()).expect("main pixels"); + let (mut sum, mut n) = (0.0f32, 0.0f32); + for j in (8..tile.size[1] - 8).step_by(37) { + for i in (8..tile.size[0] - 8).step_by(37) { + // the same point of the frame in the whole-frame render + let u = (view.window.x + i) as f32 / view.full.0 as f32; + let v = (view.window.y + j) as f32 / view.full.1 as f32; + let (mx, my) = + (((u * main.size[0] as f32) as usize).min(main.size[0] - 1), ((v * main.size[1] as f32) as usize).min(main.size[1] - 1)); + let (a, b) = (tile.pixels[j * tile.size[0] + i], main.pixels[my * main.size[0] + mx]); + sum += (0..3).map(|k| (a.to_array()[k] as f32 - b.to_array()[k] as f32).abs()).sum::() / 3.0; + n += 1.0; + } + } + assert!(sum / n < 6.0, "mean difference {:.2} / 255 between the window and the frame render", sum / n); + } } From 244088498d390ada4e4d30ffc0dd897d8b926277 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:23:53 -0400 Subject: [PATCH 06/39] #323: windows: noise reduction and mask edge refinement follow the whole frame's size An adversarial review found local::denoise and Refine Edges scaled with the window's own size, so their strength changed with pan and zoom. Frame::output_long gives the whole output's long edge to both; tests on a noisy source cover them. Co-Authored-By: Claude Sonnet 5.5 --- crates/pipeline/src/geometry.rs | 7 +++++ crates/pipeline/src/lib.rs | 2 +- crates/pipeline/src/masks.rs | 2 +- crates/pipeline/src/tests_window.rs | 48 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index d354283b..c99bde45 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -110,6 +110,13 @@ impl Frame { self.crop.rect = Rect::from_center(r.center(), r.width() * t, r.height() * t); } + /// Long edge, in pixels, of the whole output this frame draws (its own size unless it is a + /// window of a larger one): what size-relative effects (noise reduction, edge refinement) + /// must scale with, so a window matches the same pixels of the whole render. + pub fn output_long(&self, w: usize, h: usize) -> usize { + self.view.map_or(w.max(h), |v| v.full_w.max(v.full_h) as usize) + } + /// This frame narrowed to `win` of its `full_w × full_h` output: the same mapping, but the /// output is only that window (the crop rectangle shrinks to it; flips are as drawn). pub fn window(&self, full_w: usize, full_h: usize, win: crate::PixelWindow) -> Frame { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 45bc1ff4..dcd454c6 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -446,7 +446,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende // Without a cache the resampled buffer is ours: work on it in place. let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) }; lin_cpu(&mut img, info, &plan); - local::denoise(&mut img, s, src_long, w.max(h)); + local::denoise(&mut img, s, src_long, frame.output_long(w, h)); Arc::new(img) } }; diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 96911e8b..8d1c76a3 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -60,7 +60,7 @@ pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, l // Refine Edges: the mask's edges follow the photo's (window up to ~4 % of the long edge, // the width of a soft brush edge or gradient) let k = (m.refine / 100.0).clamp(0.0, 1.0) as f32; - let sigma = (0.04 * w.max(h) as f32 * k).max(1.0); + let sigma = (0.04 * frame.output_long(w, h) as f32 * k).max(1.0); let refined = guided_cross(log_l, &alpha, sigma, 0.02); for (a, r) in alpha.data.iter_mut().zip(&refined.data) { *a += (r - *a) * k.sqrt(); diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 045b0943..1825398e 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -150,3 +150,51 @@ fn windows_outside_or_degenerate_are_clamped() { assert!(r.width >= 1 && r.height >= 1 && r.width <= W && r.height <= H, "{win:?}: {}×{}", r.width, r.height); } } + +/// A textured, noisy source: what noise reduction and edge refinement act on. +fn noisy_scene() -> Rgb32f { + let base = scene(); + Rgb32f::from_fn(base.width, base.height, |x, y| { + let mut v = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841); + v ^= v >> 13; + v = v.wrapping_mul(0x5bd1_e995); + v ^= v >> 15; + let n = 1.0 + 0.25 * ((v & 0xffff) as f32 / 32768.0 - 1.0); + base.data[y * base.width + x].map(|c| c * n) + }) +} + +fn check_noisy(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let src = noisy_scene(); + let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(W, H)).image; + let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +// Given noise reduction, its strength follows the whole frame's size, not the window's +#[test] +fn noise_reduction_does_not_depend_on_the_window() { + for (id, v) in [("detail.nrLuminance", 80.0), ("detail.nrColor", 80.0)] { + let mut s = DevelopSettings::default(); + controls::set(&mut s, id, v); + check_noisy(id, &s, MID, 3, 0.15); + } +} + +// Given a mask with Refine Edges, the refinement width follows the whole frame's size +#[test] +fn mask_edge_refinement_does_not_depend_on_the_window() { + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shape = MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 1, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.5, ..Default::default() }, + refine: 100.0, + ..Default::default() + }); + check_noisy("refine", &s, MID, 3, 0.2); +} From f8f091436a539654cad2d4e2d74a845a1dac7579 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:29:42 -0400 Subject: [PATCH 07/39] #323: review fixes: no window renders during drags, current-look tiles only, margin by zoom An adversarial review found that a slider drag at any zoom started a window render (the 0.6-scale draft looked stretched), that a tile made for an earlier look kept being drawn and that the tile table was cleared mid-drag, that the fixed 256 px margin was far below the wide stages' kernels at depth, that spot removal reads outside the window, and that the window texture stayed resident after zooming out. Window renders now wait for the drag to end, a tile is drawn only while photo, zoom and look match, the margin grows with the zoom (768 px cap) and the window is capped at 6144 px, photos with spots keep the whole-frame render, the texture and stages are freed when unneeded, and wasm never asks. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 29 +++++---- crates/ui-egui/src/region.rs | 79 ++++++++++++++++++++--- crates/ui-egui/src/render.rs | 8 ++- crates/ui-egui/src/tests_preview_limit.rs | 43 ++++++++++++ 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 71d11018..d3db9d40 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -337,7 +337,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen at the zoom - // scale (not while comparing, hovering a look or showing an overlay: those draw one image) + // scale (not while comparing, hovering a look or showing an overlay: those draw one image; and + // not during a drag, whose draft is what shows until it ends) let plain_view = !split && !show_before && !split_view @@ -345,10 +346,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; let drawn_long = target_rect.width().max(target_rect.height()) * ppp; - let region_wanted = plain_view && app.renderer.can_render_windows() && crate::region::needed(drawn_long, rw.max(rh)); - if region_wanted { + let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long); + let region_possible = + plain_view && app.renderer.can_render_windows() && crate::region::windows_allowed(&d) && crate::region::needed(drawn_long, settled_edge); + let look = d.hash64(); + let mut region_full = None; + if region_possible { let (fw, fh) = if aspect >= 1.0 { (drawn_long, drawn_long / aspect) } else { (drawn_long * aspect, drawn_long) }; let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); + region_full = Some((fw, fh)); // what is on screen, in pixels of the zoomed frame let to_px = |v: f32| v * ppp; let visible = ( @@ -357,20 +363,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { to_px(canvas.right() - target_rect.left()), to_px(canvas.bottom() - target_rect.top()), ); - if let Some(win) = crate::region::window_for(fw, fh, visible) + if !interacting + && let Some(win) = crate::region::window_for(fw, fh, visible) && let Some(job) = app.session.region_job(id, fw, fh, win, !crop_tool) { - let job = if interacting { job.draft() } else { job }; - let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win }; - if app.region_tiles.len() > 8 { - app.region_tiles.clear(); - } + let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win, settings: look }; + // windows of other photos, looks and zooms can't be drawn any more + app.region_tiles.retain(|_, v| v.is_current(id, (fw, fh), look)); app.region_tiles.insert(job.key, view); app.region_view = Some(view); app.renderer.request(Slot::Region, job, 99); } } else { app.region_view = None; + app.region_tiles.clear(); + app.renderer.release(Slot::Region); } let p = ui.painter_at(canvas); // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame @@ -437,9 +444,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } // the window render, drawn over the whole-frame one where it belongs in the frame - if region_wanted + if let Some(full) = region_full && let Some(tex) = app.renderer.textures.get(&Slot::Region).filter(|t| t.photo == id) - && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.photo == id && Some(v.full) == app.region_view.map(|r| r.full)) + && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.is_current(id, full, look)) { let (fw, fh) = (v.full.0 as f32, v.full.1 as f32); let at = img_rect.min + vec2(v.window.x as f32 / fw * img_rect.width(), v.window.y as f32 / fh * img_rect.height()); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 1c894f96..8a56dd61 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -10,10 +10,10 @@ use lightcraft_engine::pipeline::PixelWindow; /// Windows start and end on multiples of this many pixels of the zoomed frame. pub const SNAP: usize = 256; -/// Context rendered beyond the visible pixels (a multiple of [`SNAP`] after snapping). -pub const MARGIN: usize = 256; +/// The most context rendered beyond the visible pixels (a multiple of [`SNAP`]). +pub const MAX_MARGIN: usize = 768; /// The most a window may span on either axis: a bound on its texture whatever the window size. -pub const MAX_SPAN: usize = 8192; +pub const MAX_SPAN: usize = 6144; /// A window render the loupe asked for: which photo, the size of the zoomed frame it is a window /// of, and the window. The texture that comes back is drawn at this place of the frame. @@ -23,6 +23,30 @@ pub struct RegionView { pub key: u64, pub full: (usize, usize), pub window: PixelWindow, + /// Hash of the develop settings it was rendered with. + pub settings: u64, +} + +impl RegionView { + /// Whether this window's pixels still belong in a loupe showing `photo` with `settings` in a + /// frame of `full` pixels (they are drawn until the next window arrives, if so). + pub fn is_current(&self, photo: lightcraft_catalog::PhotoId, full: (usize, usize), settings: u64) -> bool { + self.photo == photo && self.full == full && self.settings == settings + } +} + +/// Whether windows can be rendered for these settings. Spot removal reads its source patch from +/// outside the spot, and an automatic one is chosen from the pixels at hand: neither is the same +/// in a window as in the whole frame, so those photos stay on the whole-frame render. +pub fn windows_allowed(s: &lightcraft_develop::DevelopSettings) -> bool { + s.spots.is_empty() +} + +/// Context to render around the visible pixels of a frame `full_long` pixels along: enough for the +/// wide stages (base, clarity and dehaze planes have sigmas up to 0.02 of it) up to a cap. +pub fn margin_for(full_long: usize) -> usize { + let want = (full_long as f64 * 0.045).min(MAX_MARGIN as f64) as usize; + want.div_ceil(SNAP).max(1) * SNAP } /// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of @@ -32,19 +56,20 @@ pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { } /// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the -/// frame's pixels, as much of the frame as is on screen — is covered: expanded by [`MARGIN`], -/// snapped outwards to the [`SNAP`] grid and clamped into the frame. `None`: nothing is visible. +/// frame's pixels, as much of the frame as is on screen — is covered: expanded by a margin, +/// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame. `None`: nothing is visible. pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32)) -> Option { if full_w == 0 || full_h == 0 || [visible.0, visible.1, visible.2, visible.3].iter().any(|v| !v.is_finite()) { return None; } + let margin = margin_for(full_w.max(full_h)); let axis = |lo: f32, hi: f32, full: usize| -> Option<(usize, usize)> { let (lo, hi) = (lo.max(0.0), hi.min(full as f32)); if hi <= lo { return None; } - let a = (lo as usize).saturating_sub(MARGIN) / SNAP * SNAP; - let b = ((hi.ceil() as usize).saturating_add(MARGIN)).div_ceil(SNAP).saturating_mul(SNAP).min(full); + let a = (lo as usize).saturating_sub(margin) / SNAP * SNAP; + let b = ((hi.ceil() as usize).saturating_add(margin)).div_ceil(SNAP).saturating_mul(SNAP).min(full); // a window larger than MAX_SPAN keeps the visible part (centred on it), snapped if b - a > MAX_SPAN { let mid = ((lo + hi) / 2.0) as usize; @@ -76,8 +101,9 @@ mod tests { #[test] fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0)).unwrap(); - assert!(w.x <= 20_000 - MARGIN && w.x + w.w >= 21_400 + MARGIN, "{w:?}"); - assert!(w.y <= 10_000 - MARGIN && w.y + w.h >= 10_900 + MARGIN, "{w:?}"); + let m = margin_for(48_000); + assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); + assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); assert_eq!((w.x % SNAP, w.y % SNAP, w.w % SNAP, w.h % SNAP), (0, 0, 0, 0)); } @@ -101,6 +127,41 @@ mod tests { assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); } + // Given a deeper zoom, the margin grows with the wide stages' kernels (3σ of the base, clarity + // and dehaze planes are 0.045 of the frame), up to a cap that bounds the window + #[test] + fn the_margin_grows_with_the_zoom_up_to_a_cap() { + assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); + assert!(margin_for(8000) > margin_for(3000)); + assert_eq!(margin_for(48_000), MAX_MARGIN); + assert_eq!(margin_for(0), SNAP); + assert!(margin_for(usize::MAX) <= MAX_MARGIN); + for l in [100, 3000, 8000, 48_000, 400_000] { + assert_eq!(margin_for(l) % SNAP, 0); + } + } + + // Given a tile rendered for other settings, another photo or another zoom, it is not drawn + #[test] + fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { + use lightcraft_catalog::PhotoId; + let v = RegionView { photo: PhotoId(1), key: 7, full: (6000, 4000), window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, settings: 11 }; + assert!(v.is_current(PhotoId(1), (6000, 4000), 11)); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 11), "another photo"); + assert!(!v.is_current(PhotoId(1), (6000, 4001), 11), "another zoom"); + assert!(!v.is_current(PhotoId(1), (6000, 4000), 12), "another look (an edit, an undo)"); + } + + // Given a photo with spot removal, no window render: a spot reads pixels beyond the window + #[test] + fn photos_with_spots_stay_on_the_whole_frame_render() { + use lightcraft_develop::{DevelopSettings, Spot}; + let mut s = DevelopSettings::default(); + assert!(windows_allowed(&s)); + s.spots.push(Spot::default()); + assert!(!windows_allowed(&s)); + } + // Given nothing visible, or hostile numbers, there is no window (never a panic) #[test] fn nothing_visible_or_hostile_input_is_no_window() { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index b4940ea5..37e4b5c2 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -233,7 +233,13 @@ impl Renderer { /// Whether windows of the frame can be rendered here: not in the browser build, whose workers /// rebuild the request from a wire format that has no window yet. pub fn can_render_windows(&self) -> bool { - self.offload.is_none() + self.offload.is_none() && !cfg!(target_arch = "wasm32") + } + + /// Free a view slot's texture and stage cache (the view is no longer shown). + pub fn release(&mut self, slot: Slot) { + self.textures.remove(&slot); + self.stages.remove(&slot); } /// Run jobs through `offload` from now on (instead of the job pool / inline). diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index c17b5157..d02de2ec 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -233,4 +233,47 @@ mod in_the_loupe { } assert!(sum / n < 6.0, "mean difference {:.2} / 255 between the window and the frame render", sum / n); } + + // Given a Fit view, a slider drag (whose Main draft is at 0.6 scale) does not start window renders + #[test] + fn a_slider_drag_at_fit_makes_no_window_render() { + let (mut h, _) = detail(); + h.app.session.begin_interaction("Exposure").unwrap(); + for _ in 0..5 { + h.step(); + } + assert_eq!(h.app.region_view, None); + assert_eq!(region_tile(&h), None); + } + + // Given a zoomed view with a window render, when I zoom back to Fit, then the window's texture is freed + #[test] + fn the_window_texture_is_freed_when_it_is_not_needed() { + let (mut h, _) = detail(); + h.app.ui.settings.preview_limit = 1600; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(region_tile(&h).is_some()); + h.request("engine.execute", json!({"command": "view.zoomFit"}), T); + h.settle(SETTLE); + assert_eq!(region_tile(&h), None); + assert_eq!(h.app.region_view, None); + } + + // Given a zoomed view, when the photo is edited, then the old window (made with the old look) + // is no longer current and is replaced by one for the new look + #[test] + fn an_edit_replaces_the_window() { + let (mut h, _) = detail(); + h.app.ui.settings.preview_limit = 1600; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let first = h.app.region_view.expect("a window"); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.0}}), T); + h.settle(SETTLE); + let second = h.app.region_view.expect("a window"); + assert_ne!(first.settings, second.settings); + assert_ne!(first.key, second.key); + assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second.key), "the new window arrived"); + } } From dc5bff6947a0138c3350183edef5bab28646dd8a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:39:20 -0400 Subject: [PATCH 08/39] #323: 100 % zoom is the photo's own pixels as shown (after crop and rotation) fit_rect measured Percent zoom against the uncropped, unrotated photo, so a cropped photo at 1:1 showed stretched and a rotated one used the wrong axis. The loupe, its neighbour prefetch and the compare view now use the frame's cropped-and-rotated size, which also stops a small crop being rendered above its own pixels. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/compare.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 16 +++++-- crates/ui-egui/src/tests_zoom.rs | 71 ++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/tests_zoom.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3669610c..8bc930fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,8 @@ mod tests_scroll; mod tests_switch_library; #[cfg(test)] mod tests_unsaved; +#[cfg(test)] +mod tests_zoom; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index fd0d3305..8e252e90 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -159,7 +159,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo photo.embedded_lens.as_ref(), ); let aspect = frame.aspect() as f32; - let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; + let native = super::detail::output_px(&frame); let mut img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan); if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index d3db9d40..8a9efc95 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -115,7 +115,7 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], } } Zoom::Percent(p) => { - // full-resolution pixels at p% (the photo's native width) + // the photo's own pixels at p% (its width as shown: cropped and rotated) let w = img_px[0] as f32 * p / 100.0 / ppp; (w, w / aspect) } @@ -187,6 +187,14 @@ pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: true } +/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what +/// 100 % zoom and the render size limit are measured against), at least 1 × 1. +pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { + let (w, h) = frame.native_size(); + let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 }; + [px(w), px(h)] +} + /// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { let t = if *anim { 0.22 } else { 0.0 }; @@ -230,7 +238,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { 24.0 }); - let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; + let native = output_px(&frame); // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom); @@ -315,8 +323,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); let na = nf.aspect() as f32; - let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); - let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, np.width.max(np.height).max(1) as usize); + let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); + let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1)); let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); diff --git a/crates/ui-egui/src/tests_zoom.rs b/crates/ui-egui/src/tests_zoom.rs new file mode 100644 index 00000000..b9050607 --- /dev/null +++ b/crates/ui-egui/src/tests_zoom.rs @@ -0,0 +1,71 @@ +//! 100 % means the photo's own pixels as it is shown: after the crop and the user's rotation. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The demo library in a 1400×900 window at 1 px per point, the first photo open in Detail. +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn edit(h: &mut Headless, f: impl FnOnce(&mut lightcraft_develop::DevelopSettings)) { + let id = h.app.session.active().expect("a photo is open"); + let mut s = (*h.app.session.develop_of(id).expect("settings")).clone(); + f(&mut s); + h.app.session.set_develop(id, s, "Test").expect("edit"); +} + +fn at_100(h: &mut Headless) -> (f32, f32) { + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let r = h.app.image_rect.expect("the loupe drew the photo"); + (r.width(), r.height()) +} + +fn size_of(h: &Headless) -> (f32, f32) { + let id = h.app.session.active().expect("a photo is open"); + let p = h.app.session.catalog.photo(id).expect("photo"); + (p.width as f32, p.height as f32) +} + +// Given an uncropped photo, 100 % is its pixel size +#[test] +fn an_uncropped_photo_at_100_is_its_pixel_size() { + let mut h = detail(); + let (nw, nh) = size_of(&h); + let (w, hh) = at_100(&mut h); + assert!((w - nw).abs() < 1.5 && (hh - nh).abs() < 1.5, "{w}×{hh} for {nw}×{nh}"); +} + +// Given a crop to half the width and a third of the height, 100 % is the cropped pixels +#[test] +fn a_cropped_photo_at_100_is_the_crops_pixel_size() { + let mut h = detail(); + let (nw, nh) = size_of(&h); + edit(&mut h, |s| s.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.75, y1: 0.2 + 1.0 / 3.0 }); + let (w, hh) = at_100(&mut h); + assert!((w - nw / 2.0).abs() < 1.5 && (hh - nh / 3.0).abs() < 1.5, "{w}×{hh} for a crop of {}×{}", nw / 2.0, nh / 3.0); +} + +// Given a photo rotated a quarter turn, 100 % is its pixel size with the axes swapped +#[test] +fn a_rotated_photo_at_100_is_its_pixel_size_swapped() { + let mut h = detail(); + let (nw, nh) = size_of(&h); + edit(&mut h, |s| s.orientation = lightcraft_geom::Orientation::Rotate90); + let (w, hh) = at_100(&mut h); + assert!((w - nh).abs() < 1.5 && (hh - nw).abs() < 1.5, "{w}×{hh} for {nh}×{nw}"); +} From eebea486d5564c8b2ae3b464f9bea58b7f2d0de7 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:43:24 -0400 Subject: [PATCH 09/39] #323: windows: spots render in a window; deep-zoom regression tests for the wide stages Spot removal reads outside the spot and its automatic source depends on the pixels at hand. A windowed render now fixes automatic sources from a fixed-size render of the whole frame and grows the window it works on to hold every spot that touches the requested one and the patch it reads (bounded at 8192 px), cutting the result back to the request, so photos with spots get sharp windows too. The wide stages (dehaze, clarity, highlights/shadows) are checked at depth on hard-edged blocks: the cut kernels cost at most a few levels, so they are not computed from a whole-frame proxy. Co-Authored-By: Claude Sonnet 5.5 --- crates/pipeline/src/lib.rs | 33 +++++- crates/pipeline/src/spots.rs | 104 +++++++++++++++++++ crates/pipeline/src/tests_window.rs | 121 +++++++++++++++++++++- crates/ui-egui/src/panels/detail.rs | 3 +- crates/ui-egui/src/region.rs | 17 --- crates/ui-egui/src/tests_preview_limit.rs | 19 ++++ 6 files changed, 273 insertions(+), 24 deletions(-) diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index dcd454c6..1c869a5a 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -328,6 +328,10 @@ pub struct Plan<'a> { pub eyes: Vec, /// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame). pub fixed_air: Option, + /// A windowed render works on a larger window when spots reach into it (see + /// [`spots::window_for_spots`]); this is the requested window inside the rendered one, which + /// the result is cut to. + pub keep: Option, } /// Long edge of the small render a windowed render estimates the whole frame's airlight from. @@ -340,12 +344,22 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R Cow::Owned(o) => Cow::Owned(upright::resolve(src, info, &o).into_owned()), }; visualize::adjust_settings(req.overlay, &mut settings); + // a window must not choose a spot's source from the pixels it happens to hold: fix them + // from a fixed-size render of the whole frame first + if req.window.is_some() && settings.spots.iter().any(|sp| sp.source_offset.is_none()) { + let picked: Vec> = + settings.spots.iter().map(|sp| sp.source_offset.or_else(|| spots::pick_source(src, info, &settings, sp, None))).collect(); + for (sp, o) in settings.to_mut().spots.iter_mut().zip(picked) { + sp.source_offset = o; + } + } let s = &*settings; let frame = frame_for(src, info, s, req.apply_crop); let (full_w, full_h) = frame.fit(req.max_w, req.max_h); // sizes and scale are those of the whole output; a window only narrows what is drawn let px_per_long = frame.px_per_long(full_w); let mut fixed_air = None; + let mut keep = None; let (frame, w, h) = match req.window { Some(win) => { let win = win.clamped(full_w, full_h); @@ -354,7 +368,12 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1)); fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph)); } - (frame.window(full_w, full_h, win), win.w, win.h) + // (spots grow the rendered window; only 8-bit renders are cut back to the request) + let work = if req.depth == OutputDepth::U8 { spots::window_for_spots(&s.spots, &frame, full_w, full_h, px_per_long, win) } else { win }; + if work != win { + keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win }); + } + (frame.window(full_w, full_h, work), work.w, work.h) } None => (frame, full_w, full_h), }; @@ -373,7 +392,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), src_long, )); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air } + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes, fixed_air, keep } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -472,11 +491,19 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende } let image = finish::finish(&prep, s, frame, info, req.space, req.proof); lap("finish", &mut t); - let histogram = Histogram::of_srgb8(&image); + let cut = |i: &Rgba8| match plan.keep { + Some(k) => i.crop(k.x, k.y, k.w, k.h), + None => i.clone(), + }; + let histogram = match plan.keep { + Some(_) => Histogram::of_srgb8(&cut(&image)), + None => Histogram::of_srgb8(&image), + }; lap("histogram", &mut t); let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); + let image = if plan.keep.is_some() { cut(&image) } else { image }; Rendered { image, histogram, deep: None } } diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index f9edff72..b81a9102 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -106,6 +106,67 @@ pub fn pick_source( Some(Point::new(n1.x - n0.x, n1.y - n0.y)) } +/// The most a window grows to take in the spots that touch it. +const MAX_SPOT_SPAN: usize = 8192; + +/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot that +/// reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it +/// is given, so a spot cut by the window's edge, or whose source lies outside it, would come out +/// different from the same spot in the whole render. Spots with an automatic source (`None`) +/// search up to 4.2 radii around themselves. Growth stops at [`MAX_SPOT_SPAN`] pixels. +pub fn window_for_spots(spots: &[Spot], frame: &Frame, full_w: usize, full_h: usize, ppl: f64, win: crate::PixelWindow) -> crate::PixelWindow { + if spots.is_empty() { + return win; + } + let to_out = frame.norm_to_out(full_w, full_h); + let origin = to_out.apply(Point::new(0.0, 0.0)); + // (target box, source box) of each spot, in output pixels: [x0, y0, x1, y1] + let boxes: Vec<([f64; 4], [f64; 4])> = spots + .iter() + .filter_map(|spot| { + let r = (spot.size * ppl).max(1.0); + let pts: Vec<_> = spot.points.iter().map(|p| to_out.apply(*p)).collect(); + let first = pts.first()?; + let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y); + for p in &pts { + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + // the box `apply` works in: the stroke plus a radius, plus a radius of padding + let t = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r]; + let s = match spot.source_offset { + Some(o) => { + let b = to_out.apply(o); + let (dx, dy) = (b.x - origin.x, b.y - origin.y); + [t[0] + dx, t[1] + dy, t[2] + dx, t[3] + dy] + } + None => [t[0] - 4.2 * r, t[1] - 4.2 * r, t[2] + 4.2 * r, t[3] + 4.2 * r], + }; + Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite())) + }) + .collect(); + let (fw, fh) = (full_w as f64, full_h as f64); + let mut r = [win.x as f64, win.y as f64, (win.x + win.w) as f64, (win.y + win.h) as f64]; + let touches = |r: &[f64; 4], b: &[f64; 4]| b[0] < r[2] && b[2] > r[0] && b[1] < r[3] && b[3] > r[1]; + for _ in 0..=boxes.len() { + let mut grown = r; + for (t, s) in &boxes { + if touches(&r, t) { + for b in [t, s] { + grown = [grown[0].min(b[0]), grown[1].min(b[1]), grown[2].max(b[2]), grown[3].max(b[3])]; + } + } + } + let grown = [grown[0].max(0.0), grown[1].max(0.0), grown[2].min(fw), grown[3].min(fh)]; + if grown == r || grown[2] - grown[0] > MAX_SPOT_SPAN as f64 || grown[3] - grown[1] > MAX_SPOT_SPAN as f64 { + break; + } + r = grown; + } + let (x, y) = (r[0].floor() as usize, r[1].floor() as usize); + crate::PixelWindow { x, y, w: (r[2].ceil() as usize).saturating_sub(x).max(1), h: (r[3].ceil() as usize).saturating_sub(y).max(1) } + .clamped(full_w, full_h) +} + /// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit. pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { if spots.is_empty() { @@ -248,4 +309,47 @@ mod tests { assert!(img.get(25, 50)[0] > 0.8); assert!(img.get(5, 50)[0] < 0.2); } + + fn spot_at(x: f64, y: f64, size: f64, offset: Option) -> Spot { + Spot { points: vec![Point::new(x, y)], size, source_offset: offset, ..Default::default() } + } + + #[test] + fn a_window_grows_to_hold_the_spots_that_touch_it_and_their_sources() { + use crate::PixelWindow; + let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(4000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + // none: the window is left alone + assert_eq!(window_for_spots(&[], &frame, 4000, 3000, ppl, win), win); + // a spot far away is no business of this window + let far = spot_at(0.9, 0.9, 0.01, Some(Point::new(0.02, 0.0))); + assert_eq!(window_for_spots(&[far], &frame, 4000, 3000, ppl, win), win); + // a spot inside it, reading from 300 px to the right, widens it to the source + let near = spot_at(0.30, 0.38, 0.01, Some(Point::new(0.075, 0.0))); + let g = window_for_spots(&[near], &frame, 4000, 3000, ppl, win); + assert!(g.x <= win.x && g.x + g.w >= 1200 + 300 + 80 && g.y <= win.y && g.y + g.h >= win.y + win.h, "{g:?}"); + // a spot that only reaches in with its edge counts too + let edge = spot_at(0.24, 0.38, 0.01, Some(Point::new(-0.05, 0.0))); + assert!(window_for_spots(&[edge], &frame, 4000, 3000, ppl, win).x < win.x); + } + + #[test] + fn spot_windows_are_bounded_and_survive_hostile_spots() { + use crate::PixelWindow; + let frame = Frame::new(40_000, 30_000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(40_000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + // a spot whose source is a long way off would need an enormous window: not granted + let wide = spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0))); + let g = window_for_spots(&[wide], &frame, 40_000, 30_000, ppl, win); + assert!(g.w <= MAX_SPOT_SPAN && g.h <= MAX_SPOT_SPAN, "{g:?}"); + for bad in [f64::NAN, f64::INFINITY, 1e300, -1e300] { + let s = spot_at(bad, bad, bad, Some(Point::new(bad, bad))); + let g = window_for_spots(&[s], &frame, 40_000, 30_000, ppl, win); + assert_eq!(g, win); + } + let empty = Spot { points: vec![], ..Default::default() }; + assert_eq!(window_for_spots(&[empty], &frame, 40_000, 30_000, ppl, win), win); + } } diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 1825398e..45f3baca 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -29,9 +29,14 @@ fn window(s: &DevelopSettings, win: PixelWindow) -> Rgba8 { /// Largest per-channel difference and mean absolute difference between `win` (rendered for /// `at`, kept `HALO` px in from its edges) and the same pixels of `full`. fn compare(full: &Rgba8, win: &Rgba8, at: PixelWindow) -> (u8, f64) { + compare_inset(full, win, at, HALO) +} + +/// [`compare`] keeping `inset` px in from the window's edges. +fn compare_inset(full: &Rgba8, win: &Rgba8, at: PixelWindow, inset: usize) -> (u8, f64) { let (mut max, mut sum, mut n) = (0u8, 0f64, 0f64); - for y in HALO..at.h - HALO { - for x in HALO..at.w - HALO { + for y in inset..at.h - inset { + for x in inset..at.w - inset { let (a, b) = (full.get(at.x + x, at.y + y), win.get(x, y)); for k in 0..3 { let d = a[k].abs_diff(b[k]); @@ -198,3 +203,115 @@ fn mask_edge_refinement_does_not_depend_on_the_window() { }); check_noisy("refine", &s, MID, 3, 0.2); } + +// ---- Deep zoom: the wide stages' kernels are far wider than any margin ------------------------- + +/// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware +/// filters and the dark channel have something to be wrong about. +fn blocks() -> Rgb32f { + Rgb32f::from_fn(600, 400, |x, y| { + let cell = |size: usize, k: usize| ((x / size * 7 + y / size * 13 + k) % 5) as f32 / 4.0; + let l = 0.02 + 0.5 * cell(97, 1) + 0.25 * cell(31, 2) + 0.1 * cell(11, 3); + [l * 1.1, l, l * 0.8] + }) +} + +const BIG_W: usize = 7680; +const BIG_H: usize = 5120; + +/// A window of a 7680 × 5120 frame against the same pixels of the whole render. The wide stages +/// (dehaze, clarity, highlights/shadows base) have XX/// can't contain them, so they come from the whole frame at reduced size. +fn check_big(name: &str, s: &DevelopSettings, max_ok: u8, mean_ok: f64) { + let src = blocks(); + let f = render(&src, &SourceInfo::default(), s, &RenderRequest::fit(BIG_W, BIG_H)).image; + let win = PixelWindow { x: 3000, y: 1800, w: 512, h: 384 }; + let w = render(&src, &SourceInfo::default(), s, &RenderRequest { window: Some(win), ..RenderRequest::fit(BIG_W, BIG_H) }).image; + let (max, mean) = compare(&f, &w, win); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +#[test] +fn dehaze_at_depth_matches_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "effects.dehaze", 60.0); + check_big("dehaze", &s, 6, 0.6); +} + +#[test] +fn clarity_at_depth_matches_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "effects.clarity", 80.0); + check_big("clarity", &s, 6, 0.6); +} + +#[test] +fn highlights_and_shadows_at_depth_match_the_whole_render() { + let mut s = DevelopSettings::default(); + controls::set(&mut s, "light.shadows", 80.0); + controls::set(&mut s, "light.highlights", -80.0); + check_big("shadows+highlights", &s, 6, 0.6); +} + +// ---- Spot removal reads outside the spot ------------------------------------------------------- + +/// Like [`check`], over every pixel of the window (a spot at its edge must be right too). +fn check_whole(name: &str, s: &DevelopSettings, win: PixelWindow, max_ok: u8, mean_ok: f64) { + let (f, w) = (full(s), window(s, win)); + let (max, mean) = compare_inset(&f, &w, win, 0); + assert_eq!((w.width, w.height), (win.w, win.h), "{name}: the window's own size"); + assert!(max <= max_ok && mean <= mean_ok, "{name}: max {max} (≤ {max_ok}), mean {mean:.3} (≤ {mean_ok})"); +} + +fn with_spot(mode: lightcraft_develop::SpotMode, at: (f64, f64), offset: Option<(f64, f64)>) -> DevelopSettings { + use lightcraft_develop::Spot; + use lightcraft_geom::Point; + let mut s = DevelopSettings::default(); + s.spots.push(Spot { + mode, + points: vec![Point::new(at.0, at.1)], + size: 0.04, + feather: 30.0, + opacity: 100.0, + source_offset: offset.map(|(x, y)| Point::new(x, y)), + }); + s +} + +// Given a spot whose source patch lies outside the window, the window still shows it healed +#[test] +fn a_spot_with_its_source_outside_the_window_matches_the_whole_render() { + use lightcraft_develop::SpotMode; + // the target is at x ≈ 0.36 of the frame; its source is 0.12 of the width to the right + for mode in [SpotMode::Clone, SpotMode::Heal] { + let s = with_spot(mode, (0.40, 0.5), Some((0.12, 0.05))); + // a window that holds the target (x ≈ 384) but not the source (x ≈ 499..) + check_whole(&format!("{mode:?}"), &s, PixelWindow { x: 250, y: 200, w: 200, h: 200 }, 1, 0.1); + } +} + +// Given a spot just outside the window whose feathered edge reaches in, the window shows it +#[test] +fn a_spot_overlapping_the_window_edge_matches_the_whole_render() { + let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.30, 0.5), Some((-0.1, 0.0))); + check_whole("edge", &s, PixelWindow { x: 300, y: 200, w: 240, h: 240 }, 1, 0.1); +} + +// Given a spot with an automatic source, two windows over the same spot choose the same source +#[test] +fn an_automatic_spot_source_does_not_depend_on_the_window() { + let s = with_spot(lightcraft_develop::SpotMode::Heal, (0.5, 0.5), None); + // windows so tight that the candidate sources can't all be inside them + let (a, b) = (PixelWindow { x: 430, y: 280, w: 110, h: 90 }, PixelWindow { x: 450, y: 290, w: 110, h: 90 }); + let (ia, ib) = (window(&s, a), window(&s, b)); + let (mut max, mut n) = (0u8, 0); + for y in 0..80 { + for x in 0..90 { + let (p, q) = (ia.get(x + 20, y + 10), ib.get(x, y)); + for k in 0..3 { + max = max.max(p[k].abs_diff(q[k])); + n += 1; + } + } + } + assert!(n > 0 && max <= 2, "overlapping windows disagree by up to {max}"); +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8a9efc95..ed080e14 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -355,8 +355,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; let drawn_long = target_rect.width().max(target_rect.height()) * ppp; let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long); - let region_possible = - plain_view && app.renderer.can_render_windows() && crate::region::windows_allowed(&d) && crate::region::needed(drawn_long, settled_edge); + let region_possible = plain_view && app.renderer.can_render_windows() && crate::region::needed(drawn_long, settled_edge); let look = d.hash64(); let mut region_full = None; if region_possible { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8a56dd61..d2ab9763 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -35,13 +35,6 @@ impl RegionView { } } -/// Whether windows can be rendered for these settings. Spot removal reads its source patch from -/// outside the spot, and an automatic one is chosen from the pixels at hand: neither is the same -/// in a window as in the whole frame, so those photos stay on the whole-frame render. -pub fn windows_allowed(s: &lightcraft_develop::DevelopSettings) -> bool { - s.spots.is_empty() -} - /// Context to render around the visible pixels of a frame `full_long` pixels along: enough for the /// wide stages (base, clarity and dehaze planes have sigmas up to 0.02 of it) up to a cap. pub fn margin_for(full_long: usize) -> usize { @@ -152,16 +145,6 @@ mod tests { assert!(!v.is_current(PhotoId(1), (6000, 4000), 12), "another look (an edit, an undo)"); } - // Given a photo with spot removal, no window render: a spot reads pixels beyond the window - #[test] - fn photos_with_spots_stay_on_the_whole_frame_render() { - use lightcraft_develop::{DevelopSettings, Spot}; - let mut s = DevelopSettings::default(); - assert!(windows_allowed(&s)); - s.spots.push(Spot::default()); - assert!(!windows_allowed(&s)); - } - // Given nothing visible, or hostile numbers, there is no window (never a panic) #[test] fn nothing_visible_or_hostile_input_is_no_window() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index d02de2ec..4ee9efc0 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -276,4 +276,23 @@ mod in_the_loupe { assert_ne!(first.key, second.key); assert_eq!(h.app.renderer.textures.get(&Slot::Region).map(|t| t.key), Some(second.key), "the new window arrived"); } + + // Given a photo with spot removal, a zoomed view still gets its sharp window + #[test] + fn a_photo_with_spots_gets_a_window_too() { + let (mut h, _) = detail(); + let id = h.app.session.active().unwrap(); + let mut s = (*h.app.session.develop_of(id).unwrap()).clone(); + s.spots.push(lightcraft_develop::Spot { + points: vec![lightcraft_geom::Point::new(0.5, 0.5)], + size: 0.01, + source_offset: Some(lightcraft_geom::Point::new(0.05, 0.0)), + ..Default::default() + }); + h.app.session.set_develop(id, s, "Spot").unwrap(); + h.app.ui.settings.preview_limit = 1600; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(region_tile(&h).is_some(), "a window render"); + } } From 4b83f8bb5e7039776d89072298664d4d023b303f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:44:14 -0400 Subject: [PATCH 10/39] #323: windows cross the Web Worker boundary WireJob carries the request's window (an old job without one has none), and the browser build asks for window renders like the desktop; windows there are capped at 3072 px a side to respect wasm memory. Co-Authored-By: Claude Sonnet 5.5 --- apps/lightcraft-web/src/wire.rs | 37 ++++++++++++++++++++++++++++- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/region.rs | 2 +- crates/ui-egui/src/render.rs | 6 ----- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index fad1a816..81a76e6c 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -53,6 +53,9 @@ pub struct WireJob { /// The request's diagnostic overlay (`Overlay::to_parts`). #[serde(default)] pub overlay: (u8, f64), + /// Render only this window `[x, y, w, h]` of the output `max_w × max_h` describes (a zoomed view). + #[serde(default)] + pub window: Option<[usize; 4]>, } impl WireJob { @@ -83,6 +86,7 @@ impl WireJob { thumb_cached, stages: job.stages.is_some().then(|| view.to_string()), overlay: job.request.overlay.to_parts(), + window: job.request.window.map(|w| [w.x, w.y, w.w, w.h]), } } @@ -108,7 +112,7 @@ impl WireJob { space: lightcraft_engine::pipeline::OutputSpace::Srgb, depth: lightcraft_engine::pipeline::OutputDepth::U8, proof: None, - window: None, + window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }), } } @@ -349,6 +353,36 @@ mod tests { assert!(reopened.contains(&new)); } + // Issue #323: a worker given a zoomed view's window renders that window, not the whole frame + #[test] + fn a_window_job_crosses_the_wire_and_renders_the_same_window() { + use lightcraft_engine::pipeline::PixelWindow; + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + let win = PixelWindow { x: 300, y: 200, w: 160, h: 120 }; + let job = s.region_job(id, 1920, 1280, win, true).unwrap(); + let wire = WireJob::from_job(&job, false, "Region"); + let wire: WireJob = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap(); + assert_eq!(wire.request().window, Some(win)); + let mut core = WorkerCore::default(); + let a = core.render(&wire, None).unwrap(); + let b = job.run().rendered.unwrap(); + assert_eq!((a.image.width, a.image.height), (160, 120)); + assert_eq!(a.image.data, b.image.data); + } + + // a job from before windows existed has none + #[test] + fn a_job_without_a_window_field_has_no_window() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + let wire = WireJob::from_job(&s.thumb_job(id, 128).unwrap(), false, "Thumb"); + let mut v = serde_json::to_value(&wire).unwrap(); + v.as_object_mut().unwrap().remove("window"); + let old: WireJob = serde_json::from_value(v).unwrap(); + assert_eq!(old.request().window, None); + } + #[test] fn wire_job_renders_like_the_engine() { let mut s = Session::with_demo(); @@ -394,6 +428,7 @@ mod tests { thumb_cached: false, stages: Some("Main".into()), overlay: (0, 0.0), + window: None, }; let mut core = WorkerCore::default(); assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str())); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ed080e14..fb6d9115 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -355,7 +355,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; let drawn_long = target_rect.width().max(target_rect.height()) * ppp; let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long); - let region_possible = plain_view && app.renderer.can_render_windows() && crate::region::needed(drawn_long, settled_edge); + let region_possible = plain_view && crate::region::needed(drawn_long, settled_edge); let look = d.hash64(); let mut region_full = None; if region_possible { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index d2ab9763..2b961c95 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -13,7 +13,7 @@ pub const SNAP: usize = 256; /// The most context rendered beyond the visible pixels (a multiple of [`SNAP`]). pub const MAX_MARGIN: usize = 768; /// The most a window may span on either axis: a bound on its texture whatever the window size. -pub const MAX_SPAN: usize = 6144; +pub const MAX_SPAN: usize = if cfg!(target_arch = "wasm32") { 3072 } else { 6144 }; /// A window render the loupe asked for: which photo, the size of the zoomed frame it is a window /// of, and the window. The texture that comes back is drawn at this place of the frame. diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 37e4b5c2..e955d8d4 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -230,12 +230,6 @@ impl Renderer { } true } - /// Whether windows of the frame can be rendered here: not in the browser build, whose workers - /// rebuild the request from a wire format that has no window yet. - pub fn can_render_windows(&self) -> bool { - self.offload.is_none() && !cfg!(target_arch = "wasm32") - } - /// Free a view slot's texture and stage cache (the view is no longer shown). pub fn release(&mut self, slot: Slot) { self.textures.remove(&slot); From 05618b2ad45df4944879added9abcc641ba34440 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 13:53:08 -0400 Subject: [PATCH 11/39] #323: windows on the GPU; Auto Mask strokes grow the window; dab references at f32 The GPU renderer renders windows (no more CPU fallback): the vignette gets the window's offset and the whole output's size (VIG_VIEW), the airlight and noise reduction scale come from the whole frame, and a window grown for spots or strokes is cut back after the readback. equivalence.rs renders every settings case as a window plus a corner vignette and a spot reading from outside, within the existing 1 LSB. Auto Mask strokes join spots in growing the window (they read the pixel under each dab). An Auto Mask dab's reference pixel is chosen from its position at f32 precision, like the GPU kernel: an f64 rounding error that depended on the window's start could put a boundary dab on either pixel. Co-Authored-By: Claude Sonnet 5.5 --- crates/gpu/src/lib.rs | 3 +- crates/gpu/src/params.rs | 3 ++ crates/gpu/src/render.rs | 17 ++++-- crates/gpu/src/wgsl/finish.wgsl | 9 ++-- crates/gpu/tests/equivalence.rs | 41 ++++++++++++++ crates/pipeline/src/lib.rs | 4 +- crates/pipeline/src/masks.rs | 8 +-- crates/pipeline/src/spots.rs | 83 ++++++++++++++++++++++++----- crates/pipeline/src/tests_window.rs | 26 +++++++++ 9 files changed, 168 insertions(+), 26 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8644a85d..5ef3011b 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -298,8 +298,7 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R 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 - // (a windowed render runs on the CPU until the kernels know the window: vignette and airlight) - if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() || req.window.is_some() { + if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { return None; } let gpu = device()?; diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 112e8909..7f940549 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -57,6 +57,8 @@ const FIELDS: &[(&str, usize)] = &[ ("VIG_POWER", 1), ("VIG_HL", 1), ("VIG_STYLE", 1), + // the window's offset in the whole output and the whole output's size (pixels) + ("VIG_VIEW", 4), ("GRAIN", 1), ("GRAIN_AMT", 1), ("GRAIN_SC", 1), @@ -211,6 +213,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.f("VIG_ASPECT_MIX", v.aspect_mix); p.f("VIG_POWER", v.power); p.f("VIG_HL", v.highlights); + p.fs("VIG_VIEW", &fp.view); p.u( "VIG_STYLE", match v.style { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index bb30bc14..c3841861 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -579,7 +579,11 @@ pub fn render( Some(p) if p.key == plan.lin_key => p, _ => Planes { key: plan.lin_key, ..Default::default() }, }; - let prep = prepare(&mut cx, &lin, &plan, req, &mut planes); + let mut prep = prepare(&mut cx, &lin, &plan, req, &mut planes); + // a window dehazes with the whole frame's airlight + if let Some(a) = plan.fixed_air { + prep.air = a; + } lap("planes", &mut t, &mut cx); if let Some(c) = stages { c.put(Entry { src: src.clone(), geo: plan.geo, sampled, lin: Some((plan.lin_key, lin.clone())), planes }); @@ -650,7 +654,10 @@ pub fn render( fail(FailKind::Fatal, format!("the GPU returned an incomplete image ({unwritten} of {n} pixels unwritten)")); return None; } - let histogram = Histogram::of_srgb8(&image); + let histogram = match plan.keep { + Some(k) => Histogram::of_srgb8(&image.crop(k.x, k.y, k.w, k.h)), + None => Histogram::of_srgb8(&image), + }; lap("histogram", &mut t, &mut cx); // An entirely black result (upstream work that did not run would give that too): redo it on // the CPU, which costs time only for the rare photo that really is black. @@ -669,6 +676,10 @@ pub fn render( } }); lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); + // spots grew the window the render worked on: cut it back to the request + if let Some(k) = plan.keep { + image = image.crop(k.x, k.y, k.w, k.h); + } Some(Rendered { image, histogram, deep: None }) } @@ -713,7 +724,7 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho fn denoise(cx: &mut Cx<'_>, img: Buf, plan: &Plan<'_>) -> Buf { let (w, h) = (plan.w, plan.h); let n = w * h; - let (lum, col) = local::nr_params(&plan.settings, plan.src_long, w.max(h)); + let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h)); let mut img = img; if let Some(nr) = lum { let l = cx.gpu.buffer(n); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index edd7114c..d51308ee 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -432,14 +432,15 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // --- vignette (display linear, post-crop) if (pu(F_VIG) != 0u) { - let fw = f32(w); - let fh = f32(h); + // the vignette belongs to the whole output frame, not to the window being drawn + let fw = pf(F_VIG_VIEW + 2u); + let fh = pf(F_VIG_VIEW + 3u); let aspect = fw / fh; let amount = pf(F_VIG_AMOUNT); let mixa = pf(F_VIG_ASPECT_MIX); let power = pf(F_VIG_POWER); - let u = (f32(x) + 0.5) / fw * 2.0 - 1.0; - let vv = (f32(y) + 0.5) / fh * 2.0 - 1.0; + let u = (pf(F_VIG_VIEW) + f32(x) + 0.5) / fw * 2.0 - 1.0; + let vv = (pf(F_VIG_VIEW + 1u) + f32(y) + 0.5) / fh * 2.0 - 1.0; let sx = 1.0 + (aspect - 1.0) * mixa; let sy = 1.0 + (1.0 / aspect - 1.0) * mixa; let ax = abs(u * max(sx, 1.0) / max(sx, sy)); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 00cac8bc..b4b52e21 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -660,3 +660,44 @@ fn tint_directions_match_cpu_for_raw_and_rendered_sources() { } } } + +/// Issue #323: windows of a zoomed frame. The GPU renders the window like the CPU does: the +/// vignette and airlight belong to the whole frame, spots grow the window they work on. +#[test] +fn windows_match() { + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 960, 640); + let info = SourceInfo { raw: true, ..Default::default() }; + let win = PixelWindow { x: 1200, y: 800, w: 640, h: 480 }; + let req = RenderRequest { window: Some(win), ..RenderRequest::fit(3000, 2000) }; + let mut cases = cases(); + cases.push(("vignette at the corner", |s| { + s.vignette.amount = -80.0; + s.vignette.midpoint = 10.0; + })); + cases.push(("spot reading from outside", |s| { + s.spots.push(Spot { + points: vec![Point::new(0.45, 0.45)], + size: 0.03, + feather: 30.0, + opacity: 100.0, + source_offset: Some(Point::new(0.15, 0.0)), + ..Default::default() + }); + })); + for (name, edit) in cases { + let mut s = DevelopSettings::default(); + edit(&mut s); + let name = format!("window: {name}"); + check(&name, &src, &info, &s, &req); + } + // the corner window (where the vignette is strong) + let mut s = DevelopSettings::default(); + s.vignette.amount = -80.0; + s.vignette.midpoint = 10.0; + let corner = RenderRequest { window: Some(PixelWindow { x: 2360, y: 1520, w: 640, h: 480 }), ..RenderRequest::fit(3000, 2000) }; + check("window: vignette corner", &src, &info, &s, &corner); +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 1c869a5a..057fb42f 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -329,7 +329,7 @@ pub struct Plan<'a> { /// The whole frame's dehaze airlight, for a windowed render (which can't see the whole frame). pub fixed_air: Option, /// A windowed render works on a larger window when spots reach into it (see - /// [`spots::window_for_spots`]); this is the requested window inside the rendered one, which + /// [`spots::window_for_reads`]); this is the requested window inside the rendered one, which /// the result is cut to. pub keep: Option, } @@ -369,7 +369,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph)); } // (spots grow the rendered window; only 8-bit renders are cut back to the request) - let work = if req.depth == OutputDepth::U8 { spots::window_for_spots(&s.spots, &frame, full_w, full_h, px_per_long, win) } else { win }; + let work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win }; if work != win { keep = Some(PixelWindow { x: win.x - work.x, y: win.y - work.y, ..win }); } diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 8d1c76a3..aae261c5 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -336,11 +336,13 @@ pub fn auto_similarity(l: f32, ch: [f32; 3], rl: f32, rch: [f32; 3]) -> f32 { 1.0 - smooth(0.5, 1.0, (dl * dl + dc * dc).sqrt()) } -/// The output pixel a dab at `d` samples its reference colour from. +/// The output pixel a dab at `d` samples its reference colour from. The position is taken at f32 +/// precision (like the GPU kernel): a dab on a pixel boundary must not fall on either side of it +/// by an f64 rounding error, which depends on where the window of a zoomed view starts. #[inline] pub fn dab_pixel(d: Point, w: usize, h: usize) -> usize { - let x = (d.x.floor().max(0.0) as usize).min(w - 1); - let y = (d.y.floor().max(0.0) as usize).min(h - 1); + let x = ((d.x as f32).floor().max(0.0) as usize).min(w - 1); + let y = ((d.y as f32).floor().max(0.0) as usize).min(h - 1); y * w + x } diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index b81a9102..4c90dec5 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -109,19 +109,38 @@ pub fn pick_source( /// The most a window grows to take in the spots that touch it. const MAX_SPOT_SPAN: usize = 8192; -/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot that -/// reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it +/// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot (or +/// Auto Mask stroke) that reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it /// is given, so a spot cut by the window's edge, or whose source lies outside it, would come out /// different from the same spot in the whole render. Spots with an automatic source (`None`) /// search up to 4.2 radii around themselves. Growth stops at [`MAX_SPOT_SPAN`] pixels. -pub fn window_for_spots(spots: &[Spot], frame: &Frame, full_w: usize, full_h: usize, ppl: f64, win: crate::PixelWindow) -> crate::PixelWindow { - if spots.is_empty() { +pub fn window_for_reads( + s: &lightcraft_develop::DevelopSettings, + frame: &Frame, + full_w: usize, + full_h: usize, + ppl: f64, + win: crate::PixelWindow, +) -> crate::PixelWindow { + let spots = &s.spots; + let autos: Vec<&lightcraft_develop::BrushStroke> = s + .masks + .iter() + .flat_map(|m| &m.components) + .filter_map(|c| match &c.shape { + lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes), + _ => None, + }) + .flatten() + .filter(|st| st.auto_mask) + .collect(); + if spots.is_empty() && autos.is_empty() { return win; } let to_out = frame.norm_to_out(full_w, full_h); let origin = to_out.apply(Point::new(0.0, 0.0)); // (target box, source box) of each spot, in output pixels: [x0, y0, x1, y1] - let boxes: Vec<([f64; 4], [f64; 4])> = spots + let mut boxes: Vec<([f64; 4], [f64; 4])> = spots .iter() .filter_map(|spot| { let r = (spot.size * ppl).max(1.0); @@ -144,6 +163,21 @@ pub fn window_for_spots(spots: &[Spot], frame: &Frame, full_w: usize, full_h: us Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite())) }) .collect(); + // Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre, and + // refine the stroke over a few radii: a stroke reaching into the window needs all of that + for st in autos { + let r = (st.size * ppl).max(0.5); + let pts: Vec<_> = st.points.iter().map(|p| to_out.apply(*p)).collect(); + let Some(first) = pts.first() else { continue }; + let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y); + for p in &pts { + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + let b = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r]; + if b.iter().all(|v| v.is_finite()) { + boxes.push((b, b)); + } + } let (fw, fh) = (full_w as f64, full_h as f64); let mut r = [win.x as f64, win.y as f64, (win.x + win.w) as f64, (win.y + win.h) as f64]; let touches = |r: &[f64; 4], b: &[f64; 4]| b[0] < r[2] && b[2] > r[0] && b[1] < r[3] && b[3] > r[1]; @@ -310,6 +344,10 @@ mod tests { assert!(img.get(5, 50)[0] < 0.2); } + fn with_spots(spots: Vec) -> DevelopSettings { + DevelopSettings { spots, ..Default::default() } + } + fn spot_at(x: f64, y: f64, size: f64, offset: Option) -> Spot { Spot { points: vec![Point::new(x, y)], size, source_offset: offset, ..Default::default() } } @@ -321,17 +359,17 @@ mod tests { let ppl = frame.px_per_long(4000); let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; // none: the window is left alone - assert_eq!(window_for_spots(&[], &frame, 4000, 3000, ppl, win), win); + assert_eq!(window_for_reads(&with_spots(vec![]), &frame, 4000, 3000, ppl, win), win); // a spot far away is no business of this window let far = spot_at(0.9, 0.9, 0.01, Some(Point::new(0.02, 0.0))); - assert_eq!(window_for_spots(&[far], &frame, 4000, 3000, ppl, win), win); + assert_eq!(window_for_reads(&with_spots(vec![far]), &frame, 4000, 3000, ppl, win), win); // a spot inside it, reading from 300 px to the right, widens it to the source let near = spot_at(0.30, 0.38, 0.01, Some(Point::new(0.075, 0.0))); - let g = window_for_spots(&[near], &frame, 4000, 3000, ppl, win); + let g = window_for_reads(&with_spots(vec![near]), &frame, 4000, 3000, ppl, win); assert!(g.x <= win.x && g.x + g.w >= 1200 + 300 + 80 && g.y <= win.y && g.y + g.h >= win.y + win.h, "{g:?}"); // a spot that only reaches in with its edge counts too let edge = spot_at(0.24, 0.38, 0.01, Some(Point::new(-0.05, 0.0))); - assert!(window_for_spots(&[edge], &frame, 4000, 3000, ppl, win).x < win.x); + assert!(window_for_reads(&with_spots(vec![edge]), &frame, 4000, 3000, ppl, win).x < win.x); } #[test] @@ -342,14 +380,35 @@ mod tests { let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; // a spot whose source is a long way off would need an enormous window: not granted let wide = spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0))); - let g = window_for_spots(&[wide], &frame, 40_000, 30_000, ppl, win); + let g = window_for_reads(&with_spots(vec![wide]), &frame, 40_000, 30_000, ppl, win); assert!(g.w <= MAX_SPOT_SPAN && g.h <= MAX_SPOT_SPAN, "{g:?}"); for bad in [f64::NAN, f64::INFINITY, 1e300, -1e300] { let s = spot_at(bad, bad, bad, Some(Point::new(bad, bad))); - let g = window_for_spots(&[s], &frame, 40_000, 30_000, ppl, win); + let g = window_for_reads(&with_spots(vec![s]), &frame, 40_000, 30_000, ppl, win); assert_eq!(g, win); } let empty = Spot { points: vec![], ..Default::default() }; - assert_eq!(window_for_spots(&[empty], &frame, 40_000, 30_000, ppl, win), win); + assert_eq!(window_for_reads(&with_spots(vec![empty]), &frame, 40_000, 30_000, ppl, win), win); + } + + // Given an Auto Mask stroke reaching into the window, the window grows to hold all of it + #[test] + fn a_window_grows_to_hold_an_auto_mask_stroke() { + use crate::PixelWindow; + use lightcraft_develop::{BrushStroke, Mask, MaskComponent, MaskOp, MaskShape}; + let frame = Frame::new(4000, 3000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(4000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + let stroke = + |auto| BrushStroke { points: vec![Point::new(0.30, 0.38), Point::new(0.5, 0.38)], size: 0.01, auto_mask: auto, ..Default::default() }; + let mask = |auto| Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke(auto)] } }], + ..Default::default() + }; + let plain = DevelopSettings { masks: vec![mask(false)], ..Default::default() }; + assert_eq!(window_for_reads(&plain, &frame, 4000, 3000, ppl, win), win, "a plain stroke reads nothing around it"); + let auto = DevelopSettings { masks: vec![mask(true)], ..Default::default() }; + let g = window_for_reads(&auto, &frame, 4000, 3000, ppl, win); + assert!(g.x + g.w >= 2000 && g.x <= 1200, "{g:?}"); } } diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 45f3baca..0ed5e81e 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -315,3 +315,29 @@ fn an_automatic_spot_source_does_not_depend_on_the_window() { } assert!(n > 0 && max <= 2, "overlapping windows disagree by up to {max}"); } + +// Given Auto Mask strokes that run in and out of the window, the window shows the whole render's mask +#[test] +fn auto_mask_strokes_match_the_whole_render() { + use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let st = |pts: &[(f64, f64)], auto_mask, erase| BrushStroke { + points: pts.iter().map(|p| Point::new(p.0, p.1)).collect(), + size: 0.06, + feather: 40.0, + flow: 80.0, + auto_mask, + erase, + ..Default::default() + }; + let strokes = vec![st(&[(0.2, 0.55), (0.5, 0.6), (0.8, 0.5)], true, false), st(&[(0.5, 0.58)], true, true)]; + let s = DevelopSettings { + masks: vec![Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes } }], + adjust: LocalAdjustments { exposure: 1.0, saturation: -50.0, ..Default::default() }, + ..Default::default() + }], + ..Default::default() + }; + check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1); +} From a1d907bf0ff7e2d2fc181362420adfb60c5ab567 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 14:15:48 -0400 Subject: [PATCH 12/39] #323: a tight crop at its own pixels reads the source level its pixels need An adversarial review found that sizing the loupe render from the cropped frame (previous commit) left the source level chosen from the output edge alone: a crop whose own long edge is under 2560 px was drawn from the 2560 px preview of the whole photo, soft though sized right. The level now follows the pixels the output needs along the source's long edge (output edge / crop fraction); grid thumbnails keep their cheap level. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/media.rs | 45 +++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 91b7bdb8..b936a53e 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -104,6 +104,20 @@ impl SourceLevel { } } +/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows +/// only part of the photo, so its pixels come from a source that many times larger (the source +/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview). +fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize { + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let r = settings.crop.geometry.rect; + let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) }; + if !shown.is_finite() || shown < 1.0 { + return out_long; + } + let needed = out_long as f64 * w.max(h) / shown; + if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long } +} + /// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; @@ -718,9 +732,13 @@ impl crate::Session { thumb_bucket: Option, ) -> Option { let p = self.catalog.photo(id)?.clone(); - let level = SourceLevel::for_size(max_w.max(max_h)); - let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() }; + let level = SourceLevel::for_size(if thumb_bucket.is_some() { + max_w.max(max_h) // (grid thumbnails of tight crops stay cheap) + } else { + source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h)) + }); + let source = self.media.source_ref(&p, level); let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; // the photo id is part of the key: two photos with the same settings and size must not // share a result (a view slot showing photo A would otherwise look current for photo B) @@ -884,7 +902,7 @@ impl crate::Session { /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. - /// Nothing is cached here (a window is only worth keeping while it is on screen). + /// Nothing is cached here (a window is only worth keeping while it is on screen). pub fn region_job( &mut self, id: PhotoId, @@ -1269,6 +1287,27 @@ mod tests { assert!(again.view_cache.is_none() && again.cache.is_none()); } + // Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview + #[test] + fn a_tight_crop_reads_the_source_level_its_pixels_need() { + let mut s = crate::Session::with_demo(); + let p = s.catalog.photos().next().unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(long > 2560); + let id = p.id; + let mut d = (*s.develop_of(id).unwrap()).clone(); + // the uncropped photo at 1600 px: the preview is plenty + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview); + // a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 }; + s.set_develop(id, d, "Crop").unwrap(); + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full); + // …but with the crop tool open (the whole photo shown) it does not + assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview); + // and a crop shown small enough still reads the preview + assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); + } + #[test] fn variant_jobs_have_their_own_keys_and_hit_the_cache() { let mut s = crate::Session::with_demo(); From ff9c3fb2ec09cbd294be3a4b53c18f5dd10ebb45 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 14:15:48 -0400 Subject: [PATCH 13/39] #323: windows: refuse what cannot fit, grow Auto Mask windows by the dabs near them Review findings: window growth for spots was capped silently and then rendered wrongly; Auto Mask growth took the whole stroke's bounding box; hidden masks grew windows; the wasm window memory cap was not enforced. window_for_reads_checked returns None when what a window reads would need more than 8192 px (4096 on wasm) along an axis, Session::region_job returns None then (the loupe keeps the whole-frame render), Auto Mask growth is per dab near the request (once, not chained along the stroke) and ignores hidden masks. Docs say what the limits are. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/media.rs | 37 +++++++++++++- crates/pipeline/src/spots.rs | 95 ++++++++++++++++++++++++++++-------- 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b936a53e..667e0301 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -902,7 +902,8 @@ impl crate::Session { /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. - /// Nothing is cached here (a window is only worth keeping while it is on screen). + /// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no + /// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold. pub fn region_job( &mut self, id: PhotoId, @@ -912,6 +913,18 @@ impl crate::Session { apply_crop: bool, ) -> Option { let mut job = self.render_job(id, full_w, full_h, false, apply_crop)?; + // what the window's spots and Auto Mask strokes read must fit in one render: else the + // caller keeps the whole-frame render (a window alone would come out wrong) + let p = self.catalog.photo(id)?; + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + p.width.max(1) as usize, + p.height.max(1) as usize, + &job.settings, + apply_crop, + p.embedded_lens.as_ref(), + ); + let ppl = frame.px_per_long(full_w); + lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?; job.request.window = Some(window); job.key = Hasher128::new() .u64(job.key) @@ -1308,6 +1321,28 @@ mod tests { assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); } + // a window whose spot reads from further away than a render can hold is refused + #[test] + fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize * 8, p.height as usize * 8); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.spots.push(lightcraft_develop::Spot { + points: vec![lightcraft_geom::Point::new(0.2, 0.5)], + size: 0.01, + source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)), + ..Default::default() + }); + s.set_develop(id, d, "Spot").unwrap(); + let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 }; + assert!(s.region_job(id, w, h, win, true).is_none()); + // a window elsewhere is fine + assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); + } + #[test] fn variant_jobs_have_their_own_keys_and_hit_the_cache() { let mut s = crate::Session::with_demo(); diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 4c90dec5..c50bcf79 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -107,13 +107,16 @@ pub fn pick_source( } /// The most a window grows to take in the spots that touch it. -const MAX_SPOT_SPAN: usize = 8192; +const MAX_SPOT_SPAN: usize = if cfg!(target_arch = "wasm32") { 4096 } else { 8192 }; +/// Strokes with more dabs than this are not examined dab by dab (the render would be too slow anyway). +const MAX_DABS_CONSIDERED: usize = 200_000; /// `win` (a window of the `full_w × full_h` output of `frame`) grown to hold, for every spot (or /// Auto Mask stroke) that reaches into it, the whole spot and the patch it reads from: [`apply`] works on the pixels it /// is given, so a spot cut by the window's edge, or whose source lies outside it, would come out /// different from the same spot in the whole render. Spots with an automatic source (`None`) -/// search up to 4.2 radii around themselves. Growth stops at [`MAX_SPOT_SPAN`] pixels. +/// search up to 4.2 radii around themselves. Growth that would pass [`MAX_SPOT_SPAN`] pixels is not +/// done (the window comes back as asked; see [`window_for_reads_checked`] to know). pub fn window_for_reads( s: &lightcraft_develop::DevelopSettings, frame: &Frame, @@ -122,10 +125,25 @@ pub fn window_for_reads( ppl: f64, win: crate::PixelWindow, ) -> crate::PixelWindow { + window_for_reads_checked(s, frame, full_w, full_h, ppl, win).unwrap_or(win) +} + +/// [`window_for_reads`], or `None` when holding everything the window reads would take more than +/// [`MAX_SPOT_SPAN`] pixels along an axis: a caller that can show something else (the loupe's +/// whole-frame render) must, because the window alone would come out wrong. +pub fn window_for_reads_checked( + s: &lightcraft_develop::DevelopSettings, + frame: &Frame, + full_w: usize, + full_h: usize, + ppl: f64, + win: crate::PixelWindow, +) -> Option { let spots = &s.spots; let autos: Vec<&lightcraft_develop::BrushStroke> = s .masks .iter() + .filter(|m| m.visible) .flat_map(|m| &m.components) .filter_map(|c| match &c.shape { lightcraft_develop::MaskShape::Brush { strokes } => Some(strokes), @@ -135,12 +153,12 @@ pub fn window_for_reads( .filter(|st| st.auto_mask) .collect(); if spots.is_empty() && autos.is_empty() { - return win; + return Some(win); } let to_out = frame.norm_to_out(full_w, full_h); let origin = to_out.apply(Point::new(0.0, 0.0)); // (target box, source box) of each spot, in output pixels: [x0, y0, x1, y1] - let mut boxes: Vec<([f64; 4], [f64; 4])> = spots + let boxes: Vec<([f64; 4], [f64; 4])> = spots .iter() .filter_map(|spot| { let r = (spot.size * ppl).max(1.0); @@ -163,24 +181,35 @@ pub fn window_for_reads( Some((t, s)).filter(|(t, s)| t.iter().chain(s).all(|v| v.is_finite())) }) .collect(); - // Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre, and - // refine the stroke over a few radii: a stroke reaching into the window needs all of that + // Auto Mask strokes weigh each dab by its similarity to the pixel under the dab's centre and + // refine the stroke over a couple of radii: each dab matters to pixels within 2.5 radii + let mut dab_boxes: Vec<[f64; 4]> = Vec::new(); for st in autos { - let r = (st.size * ppl).max(0.5); - let pts: Vec<_> = st.points.iter().map(|p| to_out.apply(*p)).collect(); - let Some(first) = pts.first() else { continue }; - let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y); - for p in &pts { - (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + let dabs = crate::masks::brush_dabs(st, frame, full_w, full_h); + let r = dabs.r; + if dabs.dabs.len() > MAX_DABS_CONSIDERED { + continue; } - let b = [x0 - 2.0 * r, y0 - 2.0 * r, x1 + 2.0 * r, y1 + 2.0 * r]; - if b.iter().all(|v| v.is_finite()) { - boxes.push((b, b)); + for d in &dabs.dabs { + let b = [d.x - 2.5 * r, d.y - 2.5 * r, d.x + 2.5 * r, d.y + 2.5 * r]; + if b.iter().all(|v| v.is_finite()) { + dab_boxes.push(b); + } } } let (fw, fh) = (full_w as f64, full_h as f64); let mut r = [win.x as f64, win.y as f64, (win.x + win.w) as f64, (win.y + win.h) as f64]; let touches = |r: &[f64; 4], b: &[f64; 4]| b[0] < r[2] && b[2] > r[0] && b[1] < r[3] && b[3] > r[1]; + // a dab reaching the requested window needs its surroundings, once: the pixels that brings in + // are only read, not drawn on (unlike spots, whose patches other spots may read) + let asked = r; + for b in dab_boxes.iter().filter(|b| touches(&asked, b)) { + r = [r[0].min(b[0]), r[1].min(b[1]), r[2].max(b[2]), r[3].max(b[3])]; + } + r = [r[0].max(0.0), r[1].max(0.0), r[2].min(fw), r[3].min(fh)]; + if r[2] - r[0] > MAX_SPOT_SPAN as f64 || r[3] - r[1] > MAX_SPOT_SPAN as f64 { + return None; + } for _ in 0..=boxes.len() { let mut grown = r; for (t, s) in &boxes { @@ -191,14 +220,19 @@ pub fn window_for_reads( } } let grown = [grown[0].max(0.0), grown[1].max(0.0), grown[2].min(fw), grown[3].min(fh)]; - if grown == r || grown[2] - grown[0] > MAX_SPOT_SPAN as f64 || grown[3] - grown[1] > MAX_SPOT_SPAN as f64 { + if grown == r { break; } + if grown[2] - grown[0] > MAX_SPOT_SPAN as f64 || grown[3] - grown[1] > MAX_SPOT_SPAN as f64 { + return None; + } r = grown; } let (x, y) = (r[0].floor() as usize, r[1].floor() as usize); - crate::PixelWindow { x, y, w: (r[2].ceil() as usize).saturating_sub(x).max(1), h: (r[3].ceil() as usize).saturating_sub(y).max(1) } - .clamped(full_w, full_h) + Some( + crate::PixelWindow { x, y, w: (r[2].ceil() as usize).saturating_sub(x).max(1), h: (r[3].ceil() as usize).saturating_sub(y).max(1) } + .clamped(full_w, full_h), + ) } /// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit. @@ -409,6 +443,29 @@ mod tests { assert_eq!(window_for_reads(&plain, &frame, 4000, 3000, ppl, win), win, "a plain stroke reads nothing around it"); let auto = DevelopSettings { masks: vec![mask(true)], ..Default::default() }; let g = window_for_reads(&auto, &frame, 4000, 3000, ppl, win); - assert!(g.x + g.w >= 2000 && g.x <= 1200, "{g:?}"); + // the dabs near the window, not the whole stroke (it runs on to x = 2000) + assert!(g.x <= 1000 && g.x + g.w > 1400 && g.x + g.w < 1700, "{g:?}"); + // a window at the far end of a long stroke does not grow back along all of it + let far = window_for_reads(&auto, &frame, 4000, 3000, ppl, PixelWindow { x: 1900, ..win }); + assert!(far.x > 1500, "{far:?}"); + // a hidden mask reads nothing + let hidden = DevelopSettings { masks: vec![Mask { visible: false, ..auto.masks[0].clone() }], ..Default::default() }; + assert_eq!(window_for_reads(&hidden, &frame, 4000, 3000, ppl, win), win); + } + + // Given a window that would have to grow past the cap, there is no answer (never a wrong one) + #[test] + fn a_window_that_cannot_hold_what_it_reads_is_refused() { + use crate::PixelWindow; + let frame = Frame::new(40_000, 30_000, &DevelopSettings::default(), true); + let ppl = frame.px_per_long(40_000); + let win = PixelWindow { x: 1000, y: 1000, w: 400, h: 300 }; + let far = with_spots(vec![spot_at(0.03, 0.04, 0.01, Some(Point::new(0.6, 0.0)))]); + assert_eq!(window_for_reads_checked(&far, &frame, 40_000, 30_000, ppl, win), None); + let near = with_spots(vec![spot_at(0.03, 0.04, 0.01, Some(Point::new(0.02, 0.0)))]); + assert!(window_for_reads_checked(&near, &frame, 40_000, 30_000, ppl, win).is_some()); + // a spot that doesn't touch the window is no business of it, however far it reads + let elsewhere = with_spots(vec![spot_at(0.9, 0.9, 0.01, Some(Point::new(-0.8, 0.0)))]); + assert_eq!(window_for_reads_checked(&elsewhere, &frame, 40_000, 30_000, ppl, win), Some(win)); } } From 930d14aacd94df06dca364f3a82e8448cce2634d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 15:11:07 -0400 Subject: [PATCH 14/39] #323: fix the preview_limit doc comment (largest, not lowest) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 589d58e6..f7ba3cb6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -140,7 +140,7 @@ pub struct AppSettings { pub confirm_delete: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, - /// Lowest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is + /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). pub preview_limit: u32, From 63cfc6ab70337e3da7682b1f21313197a173217b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:04:23 -0400 Subject: [PATCH 15/39] #323: parity, GPU and control docs: sharp zoomed views by window render Co-Authored-By: Claude Sonnet 5.5 --- docs/control-protocol.md | 2 +- docs/gpu-pipeline.md | 6 +++++- docs/parity.md | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4f02dee8..f23382b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at the zoom scale), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index a32814ef..d1aca9e2 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -171,7 +171,11 @@ bounds **mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB** per channel. Measured (App three mask sets incl. CPU-evaluated shapes, spots + defringe), all 8 orientations ± crop, embedded DNG lens data, display-referred sources, draft quality, full-size renders; the 24 MP export in `render_bench` also differs by max 1 LSB. Cached (slider-drag) GPU renders are bit-identical to -fresh ones. The tests skip (pass with a note) when no adapter exists. +fresh ones. `windows_match` runs the settings cases again as a window of a 3000 × 2000 frame +(`RenderRequest::window`, the zoomed loupe, issue #323) plus a vignette in a corner and a spot reading +from outside the window: the vignette geometry arrives as the `VIG_VIEW` parameter, the airlight and +noise-reduction size come from the whole frame, and a window that spots or Auto Mask strokes grew is +cut back after the readback. The tests skip (pass with a note) when no adapter exists. Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on Metal and running-sum order in the box filters — all far below one 8-bit step. diff --git a/docs/parity.md b/docs/parity.md index b9c5eed5..e02f5369 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -71,6 +71,8 @@ Take the first one nobody is working on. colour-filter layout, fixed in #85 by reading the file's own tag). 4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`), then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it. +4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for Before/After, compare, overlays and + soft proofing; check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). 5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). 6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR): @@ -183,7 +185,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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). Missing: native-resolution detail beyond the configured whole-image preview size (2,560 px default); no visible-region tiles | +| 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). Past what one whole-frame render holds (the photo's own pixels, 8192 px ceiling, or the Preview size cap) the loupe also renders just the window on screen at the zoom scale (`RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`; issue #323), so 1:1 and 2:1–8:1 are sharp at any photo size. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (capped at 3072 px a side there), and are not made during a slider drag (the draft shows) nor while comparing, hovering a look, soft-proofing or showing an overlay. 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 | | 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 | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | @@ -478,7 +480,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the size the loupe is drawn at, up to the photo's own pixels and an 8192 px ceiling; or a 1600–5120 px cap; issue #323), memory budget, thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | | LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | From b7f632f5c0781292d73fe6fd0b6f70030afe8d52 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:20:16 -0400 Subject: [PATCH 16/39] #323: no loupe texture is larger than the GPU allows The Automatic preview size let the loupe texture reach 8192 px on every platform. The browser build draws with egui_glow, which panics on a texture larger than the GPU takes, and many WebGL devices allow only 2048 or 4096. Every render size the loupe asks for (loupe, neighbour prefetch, hover and Before stand-ins, compare tiles, the second window) and every zoom window is now clamped to the context's max_texture_side (2048 when the host reports none, 512 at the least). Headless drivers can pretend a small GPU for tests. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/headless.rs | 4 ++ crates/ui-egui/src/panels/compare.rs | 3 +- crates/ui-egui/src/panels/detail.rs | 18 ++++-- crates/ui-egui/src/panels/second.rs | 3 +- crates/ui-egui/src/region.rs | 55 +++++++++++----- crates/ui-egui/src/state.rs | 20 +++--- crates/ui-egui/src/tests_preview_limit.rs | 76 ++++++++++++++++++----- 7 files changed, 134 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index b803a36c..41a55651 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -107,6 +107,8 @@ impl HeadlessView { pub struct Headless { pub app: LightcraftApp, pub view: HeadlessView, + /// The largest texture the pretend GPU takes (what a WebGL device may report: 2048). + pub max_texture_side: usize, /// Logical size (points) and scale. pub size: egui::Vec2, pub pixels_per_point: f32, @@ -126,6 +128,7 @@ impl Headless { Headless { app, view: HeadlessView::new(), + max_texture_side: 16384, size: egui::vec2(size[0], size[1]), pixels_per_point, time: 0.0, @@ -148,6 +151,7 @@ impl Headless { /// Run one frame. pub fn step(&mut self) { let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events)); + raw.max_texture_side = Some(self.max_texture_side); self.app.raw_input_hook(&mut raw); let app = &mut self.app; let commands = self.view.run(raw, |ui| { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 8e252e90..77ee4242 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -168,7 +168,8 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo super::detail::pan_image(app, img_area, img, resp.drag_delta()); } } - let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) as usize; + let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp) + .min(2560.0_f32.min(super::detail::texture_side(ui.ctx()) as f32)) as usize; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { app.renderer.request(slot, job, 60); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index fb6d9115..8242848b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -187,6 +187,12 @@ pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: true } +/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 when it doesn't, the +/// smallest limit WebGL devices have). +pub(crate) fn texture_side(ctx: &egui::Context) -> usize { + ctx.input(|i| i.raw.max_texture_side).unwrap_or(2048) +} + /// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what /// 100 % zoom and the render size limit are measured against), at least 1 × 1. pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { @@ -239,6 +245,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 24.0 }); let native = output_px(&frame); + let texture_side = texture_side(ui.ctx()); // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom); @@ -291,10 +298,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let scale = if interacting { 0.6 } else { 1.0 }; // 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 want = app.ui.settings.loupe_edge(target_rect.width().max(target_rect.height()) * ppp * scale, native_long); + let want = app.ui.settings.loupe_edge(target_rect.width().max(target_rect.height()) * ppp * scale, native_long, texture_side); let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap - let se = app.ui.settings.stand_in_edge(want); + let se = app.ui.settings.stand_in_edge(want, texture_side); let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; @@ -324,7 +331,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); let na = nf.aspect() as f32; let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan); - let nw = app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1)); + let nw = + app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side); let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); @@ -354,7 +362,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; let drawn_long = target_rect.width().max(target_rect.height()) * ppp; - let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long); + let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long, texture_side); let region_possible = plain_view && crate::region::needed(drawn_long, settled_edge); let look = d.hash64(); let mut region_full = None; @@ -371,7 +379,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { to_px(canvas.bottom() - target_rect.top()), ); if !interacting - && let Some(win) = crate::region::window_for(fw, fh, visible) + && let Some(win) = crate::region::window_for(fw, fh, visible, crate::region::max_span(texture_side)) && let Some(job) = app.session.region_job(id, fw, fh, win, !crop_tool) { let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win, settings: look }; diff --git a/crates/ui-egui/src/panels/second.rs b/crates/ui-egui/src/panels/second.rs index 775a922d..ac239bcd 100644 --- a/crates/ui-egui/src/panels/second.rs +++ b/crates/ui-egui/src/panels/second.rs @@ -37,7 +37,8 @@ fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; // a render sized for this window let ppp = ui.ctx().pixels_per_point(); - let (w, h) = (((area.width() * ppp) as usize).clamp(64, 4096), ((area.height() * ppp) as usize).clamp(64, 4096)); + let side = super::detail::texture_side(ui.ctx()).clamp(64, 4096); + let (w, h) = (((area.width() * ppp) as usize).clamp(64, side), ((area.height() * ppp) as usize).clamp(64, side)); if let Some(job) = app.session.loupe_job(id, w, h, true) && app.renderer.textures.get(&Slot::Second).is_none_or(|t| t.key != job.key) && !app.renderer.is_pending(Slot::Second) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 2b961c95..f59cb1f9 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -42,6 +42,13 @@ pub fn margin_for(full_long: usize) -> usize { want.div_ceil(SNAP).max(1) * SNAP } +/// The widest a window may be on a host whose GPU textures are at most `texture_side` px: a +/// window is one texture, and a texture over the limit makes some backends (egui_glow, i.e. the +/// browser build) panic. A multiple of [`SNAP`], at least one grid step, at most [`MAX_SPAN`]. +pub fn max_span(texture_side: usize) -> usize { + (texture_side.min(MAX_SPAN) / SNAP * SNAP).max(SNAP) +} + /// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of /// the whole-frame render, which has only `rendered_long` of them. pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { @@ -50,8 +57,9 @@ pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { /// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the /// frame's pixels, as much of the frame as is on screen — is covered: expanded by a margin, -/// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame. `None`: nothing is visible. -pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32)) -> Option { +/// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame, +/// at most `max_span` ([`max_span`]) wide or high. `None`: nothing is visible. +pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), max_span: usize) -> Option { if full_w == 0 || full_h == 0 || [visible.0, visible.1, visible.2, visible.3].iter().any(|v| !v.is_finite()) { return None; } @@ -64,10 +72,10 @@ pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32)) - let a = (lo as usize).saturating_sub(margin) / SNAP * SNAP; let b = ((hi.ceil() as usize).saturating_add(margin)).div_ceil(SNAP).saturating_mul(SNAP).min(full); // a window larger than MAX_SPAN keeps the visible part (centred on it), snapped - if b - a > MAX_SPAN { + if b - a > max_span { let mid = ((lo + hi) / 2.0) as usize; - let a = mid.saturating_sub(MAX_SPAN / 2) / SNAP * SNAP; - return Some((a, (a + MAX_SPAN).min(full))); + let a = mid.saturating_sub(max_span / 2) / SNAP * SNAP; + return Some((a, (a + max_span).min(full))); } Some((a, b)) }; @@ -93,7 +101,7 @@ mod tests { // Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid #[test] fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { - let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0)).unwrap(); + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); let m = margin_for(48_000); assert!(w.x + m <= 20_000 && w.x + w.w >= 21_400 + m, "{w:?}"); assert!(w.y + m <= 10_000 && w.y + w.h >= 10_900 + m, "{w:?}"); @@ -103,19 +111,19 @@ mod tests { // Given a small pan, the same window is kept (no re-render); a big pan changes it #[test] fn small_pans_keep_the_window() { - let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0)).unwrap(); - let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0)).unwrap(); + let a = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), MAX_SPAN).unwrap(); + let b = window_for(48_000, 32_000, (20_040.0, 10_030.0, 21_440.0, 10_930.0), MAX_SPAN).unwrap(); assert_eq!(a, b); - let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0)).unwrap(); + let c = window_for(48_000, 32_000, (26_000.0, 10_000.0, 27_400.0, 10_900.0), MAX_SPAN).unwrap(); assert_ne!(a, c); } // Given a frame smaller than the margin, or a view at its edge, the window is clamped into it #[test] fn the_window_stays_inside_the_frame() { - let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0)).unwrap(); + let w = window_for(3000, 2000, (-500.0, -500.0, 900.0, 700.0), MAX_SPAN).unwrap(); assert_eq!((w.x, w.y), (0, 0)); - let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0)).unwrap(); + let w = window_for(3000, 2000, (2500.0, 1500.0, 9000.0, 9000.0), MAX_SPAN).unwrap(); assert!(w.x + w.w <= 3000 && w.y + w.h <= 2000, "{w:?}"); assert_eq!((w.x + w.w, w.y + w.h), (3000, 2000)); } @@ -145,23 +153,36 @@ mod tests { assert!(!v.is_current(PhotoId(1), (6000, 4000), 12), "another look (an edit, an undo)"); } + // Given a GPU that allows 2048 px textures, no window is wider than that + #[test] + fn a_window_fits_the_texture_side() { + for tex in [512, 2048, 4096] { + let span = max_span(tex); + assert!(span <= tex.max(SNAP) && span.is_multiple_of(SNAP), "{tex}: {span}"); + let w = window_for(48_000, 32_000, (20_000.0, 10_000.0, 21_400.0, 10_900.0), span).unwrap(); + assert!(w.w <= span && w.h <= span, "{tex}: {w:?}"); + } + assert_eq!(max_span(100_000), MAX_SPAN); + assert_eq!(max_span(0), SNAP); + } + // Given nothing visible, or hostile numbers, there is no window (never a panic) #[test] fn nothing_visible_or_hostile_input_is_no_window() { - assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0)), None); - assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0)), None); - assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0)), None); + assert_eq!(window_for(1000, 1000, (2000.0, 0.0, 3000.0, 100.0), MAX_SPAN), None); + assert_eq!(window_for(1000, 1000, (10.0, 10.0, 10.0, 90.0), MAX_SPAN), None); + assert_eq!(window_for(0, 1000, (0.0, 0.0, 10.0, 10.0), MAX_SPAN), None); for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0)), None); + assert_eq!(window_for(1000, 1000, (v, 0.0, 10.0, 10.0), MAX_SPAN), None); } - let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30)).unwrap(); + let w = window_for(usize::MAX / 2, usize::MAX / 2, (0.0, 0.0, 1e30, 1e30), MAX_SPAN).unwrap(); assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN, "{w:?}"); } // Given a view of an enormous area, the window is bounded #[test] fn a_window_is_bounded() { - let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0)).unwrap(); + let w = window_for(100_000, 100_000, (0.0, 0.0, 90_000.0, 90_000.0), MAX_SPAN).unwrap(); assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f7ba3cb6..ca479460 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -182,6 +182,9 @@ pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; /// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. pub const LOUPE_EDGE_CEILING: u32 = 8192; +/// The smallest GPU texture side the loupe plans for, whatever the host reports. +pub const MIN_TEXTURE_SIDE: usize = 512; + /// Loupe render sizes move in steps of this many pixels. pub const LOUPE_EDGE_STEP: usize = 64; @@ -191,9 +194,12 @@ pub const STANDARD_PREVIEW_EDGE: u32 = 2560; impl AppSettings { /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at - /// every pixel), never above the photo's own `native_long_edge`, the user's limit or the ceiling. - pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize) -> usize { - let ceiling = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; + /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling + /// or `texture_side`, the largest texture the GPU allows (egui_glow, i.e. the browser build, + /// panics on a bigger one, and many WebGL devices allow only 2048 or 4096). + pub fn loupe_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { + let limit = if self.preview_limit == 0 { LOUPE_EDGE_CEILING } else { self.preview_limit.clamp(512, LOUPE_EDGE_CEILING) } as usize; + let ceiling = limit.min(texture_side.max(MIN_TEXTURE_SIDE)); let wanted = if wanted_px.is_nan() { 8.0 } else { wanted_px.clamp(8.0, ceiling as f32) } as usize; let stepped = wanted.div_ceil(LOUPE_EDGE_STEP) * LOUPE_EDGE_STEP; stepped.min(native_long_edge.max(8)).min(ceiling) @@ -202,14 +208,14 @@ impl AppSettings { /// Long edge for warming a neighbouring photo: its loupe size but never above the preview /// source level. A full-size decode would replace the open photo's single full-resolution /// source in the cache, and nothing is gained by it. - pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize) -> usize { - self.loupe_edge(wanted_px, native_long_edge).min(lightcraft_engine::SourceLevel::Preview.max_edge()) + pub fn prefetch_edge(&self, wanted_px: f32, native_long_edge: usize, texture_side: usize) -> usize { + self.loupe_edge(wanted_px, native_long_edge, texture_side).min(lightcraft_engine::SourceLevel::Preview.max_edge()) } /// Long edge for the hover (preset / profile) and Before renders, which are stand-ins drawn /// over the loupe: capped at the preview source level like the old default. - pub fn stand_in_edge(&self, loupe_edge: usize) -> usize { - loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()) + pub fn stand_in_edge(&self, loupe_edge: usize, texture_side: usize) -> usize { + loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE)) } /// The edge Build Standard-Sized Previews uses. diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 4ee9efc0..1b5cc9d5 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -4,6 +4,9 @@ use crate::state::{AppSettings, LOUPE_EDGE_CEILING, PREVIEW_LIMITS, STANDARD_PREVIEW_EDGE}; +/// A texture side no GPU we run on refuses (see the tests of small ones below). +const BIG: usize = 16384; + fn with_limit(preview_limit: u32) -> AppSettings { AppSettings { preview_limit, ..Default::default() } } @@ -18,21 +21,21 @@ fn default_is_automatic() { // Given Automatic, when a 24 MP photo is shown at 1:1, then the render is the photo's own width #[test] fn automatic_renders_a_one_to_one_view_at_native_size() { - assert_eq!(with_limit(0).loupe_edge(6000.0, 6000), 6000); + assert_eq!(with_limit(0).loupe_edge(6000.0, 6000, BIG), 6000); } // Given Automatic, a Retina fit view of 3024 px is not held to the old 2560 px cap #[test] fn automatic_does_not_cap_a_fit_view_at_the_old_default() { - assert_eq!(with_limit(0).loupe_edge(3024.0, 6000), 3072, "rounded up to the next 64 px step"); + assert_eq!(with_limit(0).loupe_edge(3024.0, 6000, BIG), 3072, "rounded up to the next 64 px step"); } // Dragging a window edge must not re-render (and re-key the caches) on every pixel #[test] fn render_sizes_move_in_steps() { let s = with_limit(0); - assert_eq!(s.loupe_edge(2945.0, 6000), s.loupe_edge(3008.0, 6000)); - assert_ne!(s.loupe_edge(3008.0, 6000), s.loupe_edge(3009.0, 6000)); + assert_eq!(s.loupe_edge(2945.0, 6000, BIG), s.loupe_edge(3008.0, 6000, BIG)); + assert_ne!(s.loupe_edge(3008.0, 6000, BIG), s.loupe_edge(3009.0, 6000, BIG)); } // Neighbour prefetch and hover / before stand-ins stay at the preview level: a full-size decode @@ -42,29 +45,55 @@ fn prefetch_and_stand_ins_never_ask_for_the_full_source_level() { use lightcraft_engine::SourceLevel; let s = with_limit(0); for wanted in [1000.0, 2560.0, 3024.0, 6000.0, 48000.0] { - assert!(s.prefetch_edge(wanted, 6000) <= SourceLevel::Preview.max_edge(), "{wanted}"); - assert!(s.stand_in_edge(s.loupe_edge(wanted, 6000)) <= SourceLevel::Preview.max_edge()); + assert!(s.prefetch_edge(wanted, 6000, BIG) <= SourceLevel::Preview.max_edge(), "{wanted}"); + assert!(s.stand_in_edge(s.loupe_edge(wanted, 6000, BIG), BIG) <= SourceLevel::Preview.max_edge()); } - assert_eq!(s.prefetch_edge(1000.0, 6000), 1024, "a small view is not raised"); + assert_eq!(s.prefetch_edge(1000.0, 6000, BIG), 1024, "a small view is not raised"); } // Given Automatic, a small photo zoomed far in is not rendered above its own pixels #[test] fn never_renders_above_the_photos_own_pixels() { - assert_eq!(with_limit(0).loupe_edge(8000.0, 1000), 1000); + assert_eq!(with_limit(0).loupe_edge(8000.0, 1000, BIG), 1000); } // Given Automatic, a huge zoom on a huge photo stays under the memory/GPU ceiling #[test] fn automatic_is_bounded_by_the_ceiling() { - assert_eq!(with_limit(0).loupe_edge(48000.0, 12000), LOUPE_EDGE_CEILING as usize); + assert_eq!(with_limit(0).loupe_edge(48000.0, 12000, BIG), LOUPE_EDGE_CEILING as usize); } // Given the user chose 1600 px, the render never exceeds it #[test] fn an_explicit_limit_caps_the_render() { - assert_eq!(with_limit(1600).loupe_edge(6000.0, 6000), 1600); - assert_eq!(with_limit(1600).loupe_edge(900.0, 6000), 960, "and does not raise a small view past its step"); + assert_eq!(with_limit(1600).loupe_edge(6000.0, 6000, BIG), 1600); + assert_eq!(with_limit(1600).loupe_edge(900.0, 6000, BIG), 960, "and does not raise a small view past its step"); +} + +// Given a browser whose GPU allows 2048 px textures (egui_glow panics on a bigger one), no render +// the loupe asks for is larger than that: the loupe, its neighbours' prefetch and the stand-ins +#[test] +fn nothing_is_rendered_larger_than_the_gpu_texture_side() { + let s = with_limit(0); + for tex in [512, 2048, 4096, 8192] { + for wanted in [100.0, 2000.0, 6000.0, 48000.0, f32::INFINITY] { + let e = s.loupe_edge(wanted, 12000, tex); + assert!(e <= tex, "loupe {e} > {tex}"); + assert!(s.prefetch_edge(wanted, 12000, tex) <= tex); + assert!(s.stand_in_edge(e, tex) <= tex); + } + } + assert_eq!(s.loupe_edge(6000.0, 6000, 2048), 2048); +} + +// A host that reports nothing sensible gets the smallest safe sizes, never a panic or zero +#[test] +fn a_nonsense_texture_side_is_clamped() { + let s = with_limit(0); + for tex in [0, 1, 7] { + let e = s.loupe_edge(1000.0, 6000, tex); + assert!((8..=512).contains(&e), "{tex}: {e}"); + } } // Whatever the inputs (hostile or degenerate), the result is a usable size @@ -72,11 +101,11 @@ fn an_explicit_limit_caps_the_render() { fn degenerate_inputs_give_a_usable_size() { let s = with_limit(0); for wanted in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 0.0] { - let e = s.loupe_edge(wanted, 6000); + let e = s.loupe_edge(wanted, 6000, BIG); assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&e), "{wanted}: {e}"); } - assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0)) && s.prefetch_edge(f32::NAN, 0) >= 8); - assert!(with_limit(7).loupe_edge(1000.0, 6000) >= 8, "a corrupt limit is clamped"); + assert!((8..=LOUPE_EDGE_CEILING as usize).contains(&s.loupe_edge(1000.0, 0, BIG)) && s.prefetch_edge(f32::NAN, 0, BIG) >= 8); + assert!(with_limit(7).loupe_edge(1000.0, 6000, BIG) >= 8, "a corrupt limit is clamped"); } // Build Standard-Sized Previews needs a number even when the limit is Automatic @@ -295,4 +324,23 @@ mod in_the_loupe { h.settle(SETTLE); assert!(region_tile(&h).is_some(), "a window render"); } + + // Given a browser GPU that takes only 2048 px textures (egui_glow panics on a bigger one), when + // I zoom to 1:1 and 8:1, then no texture the loupe holds is larger than that + #[test] + fn no_loupe_texture_exceeds_the_gpu_limit() { + let (mut h, _) = detail(); + h.max_texture_side = 2048; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + for zoom in [crate::state::Zoom::Fit, crate::state::Zoom::Percent(100.0), crate::state::Zoom::Percent(800.0)] { + h.app.ui.zoom = zoom; + h.settle(SETTLE); + for (slot, t) in &h.app.renderer.textures { + assert!(t.size[0] <= 2048 && t.size[1] <= 2048, "{zoom:?} {slot:?}: {}×{}", t.size[0], t.size[1]); + } + let main = h.app.renderer.textures.get(&Slot::Main).expect("loupe"); + assert!(main.size[0].max(main.size[1]) >= 700, "still a real render, not a thumbnail: {:?}", main.size); + } + } } From 3255cda67ea59eea13504a91858e53707328ab40 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:29:30 -0400 Subject: [PATCH 17/39] #323: the loupe renders the canvas, plus the visible window at no more than 100 % At 8:1 on a 24 MP photo every drag frame rendered the whole 24 MP frame, and a Retina fit view (about 2800 px) decoded the original of every photo stepped to. A new render plan (region::plan, pure and tested) makes the cost follow the canvas, not the zoom: - the whole-frame render is canvas-sized and never above the preview source level, the preview size limit or the texture side (a drag drafts that); - once the view is zoomed past it (by more than 25 %) the visible window is rendered, cut from a frame that is at most the photo's own pixels, so beyond 100 % the GPU magnifies it instead of a bigger render being made; this also ends the duplicated second render above 100 %; - a window is drafted during a drag and a draft of an older value still draws; - a user who capped the preview size keeps the whole-frame render at fit; - no window where the picture on screen isn't the frame's own aspect (an unsupported raw's embedded JPEG cropped differently, #280). Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 52 ++++--- crates/ui-egui/src/region.rs | 176 +++++++++++++++++++++- crates/ui-egui/src/state.rs | 9 ++ crates/ui-egui/src/tests_preview_limit.rs | 42 ++++-- 4 files changed, 243 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8242848b..9c494e98 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -298,7 +298,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let scale = if interacting { 0.6 } else { 1.0 }; // 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 want = app.ui.settings.loupe_edge(target_rect.width().max(target_rect.height()) * ppp * scale, native_long, texture_side); + 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 plan = crate::region::plan( + &app.ui.settings, + crate::region::ViewSizes { + drawn_long, + canvas_long: canvas.width().max(canvas.height()) * ppp, + native_long, + texture_side, + draft_scale: scale, + }, + ); + let want = plan.main_edge; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); @@ -352,39 +364,39 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { { app.renderer.request(Slot::Before, job, 90); } - // zoomed past what the whole-frame render holds: also render the part on screen at the zoom - // scale (not while comparing, hovering a look or showing an overlay: those draw one image; and - // not during a drag, whose draft is what shows until it ends) + // zoomed past what the whole-frame render holds: also render the part on screen, at no more + // than 100 % (the GPU magnifies beyond that). Not while comparing, hovering a look or showing + // an overlay (those draw one image), nor where the shown picture isn't the frame's own (an + // unsupported raw's embedded JPEG with another crop: window coordinates are the frame's). let plain_view = !split && !show_before && !split_view && hover_key.is_none() && !app.ui.soft_proof - && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None; - let drawn_long = target_rect.width().max(target_rect.height()) * ppp; - let settled_edge = app.ui.settings.loupe_edge(drawn_long, native_long, texture_side); - let region_possible = plain_view && crate::region::needed(drawn_long, settled_edge); + && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None + && crate::region::same_aspect(display_aspect, aspect); let look = d.hash64(); let mut region_full = None; - if region_possible { - let (fw, fh) = if aspect >= 1.0 { (drawn_long, drawn_long / aspect) } else { (drawn_long * aspect, drawn_long) }; + if let Some(frame_edge) = plan.window_edge.filter(|_| plain_view) { + let (fw, fh) = if aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / aspect) } else { (frame_edge as f32 * aspect, frame_edge as f32) }; let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); region_full = Some((fw, fh)); - // what is on screen, in pixels of the zoomed frame - let to_px = |v: f32| v * ppp; + // what is on screen, in pixels of the window's frame (the drawn frame, scaled) + let to_px = ppp * frame_edge as f32 / drawn_long.max(1.0); let visible = ( - to_px(canvas.left() - target_rect.left()), - to_px(canvas.top() - target_rect.top()), - to_px(canvas.right() - target_rect.left()), - to_px(canvas.bottom() - target_rect.top()), + to_px * (canvas.left() - target_rect.left()), + to_px * (canvas.top() - target_rect.top()), + to_px * (canvas.right() - target_rect.left()), + to_px * (canvas.bottom() - target_rect.top()), ); - if !interacting - && let Some(win) = crate::region::window_for(fw, fh, visible, crate::region::max_span(texture_side)) + if let Some(win) = crate::region::window_for(fw, fh, visible, crate::region::max_span(texture_side)) && let Some(job) = app.session.region_job(id, fw, fh, win, !crop_tool) { + // a drag renders drafts of the window, as it does of the whole frame + let job = if interacting { job.draft() } else { job }; let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms can't be drawn any more - app.region_tiles.retain(|_, v| v.is_current(id, (fw, fh), look)); + app.region_tiles.retain(|_, v| v.is_current(id, (fw, fh), look, interacting)); app.region_tiles.insert(job.key, view); app.region_view = Some(view); app.renderer.request(Slot::Region, job, 99); @@ -461,7 +473,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the window render, drawn over the whole-frame one where it belongs in the frame if let Some(full) = region_full && let Some(tex) = app.renderer.textures.get(&Slot::Region).filter(|t| t.photo == id) - && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.is_current(id, full, look)) + && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.is_current(id, full, look, interacting)) { let (fw, fh) = (v.full.0 as f32, v.full.1 as f32); let at = img_rect.min + vec2(v.window.x as f32 / fw * img_rect.width(), v.window.y as f32 / fh * img_rect.height()); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index f59cb1f9..96c882e7 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -30,8 +30,11 @@ pub struct RegionView { impl RegionView { /// Whether this window's pixels still belong in a loupe showing `photo` with `settings` in a /// frame of `full` pixels (they are drawn until the next window arrives, if so). - pub fn is_current(&self, photo: lightcraft_catalog::PhotoId, full: (usize, usize), settings: u64) -> bool { - self.photo == photo && self.full == full && self.settings == settings + /// + /// While a slider is dragged (`drafting`) a window made for an earlier value of it still + /// belongs: drafts follow the drag, and waiting for an exact match would show none. + pub fn is_current(&self, photo: lightcraft_catalog::PhotoId, full: (usize, usize), settings: u64, drafting: bool) -> bool { + self.photo == photo && self.full == full && (drafting || self.settings == settings) } } @@ -49,6 +52,61 @@ pub fn max_span(texture_side: usize) -> usize { (texture_side.min(MAX_SPAN) / SNAP * SNAP).max(SNAP) } +/// A window render is worth it only when the whole-frame render is this much smaller than the +/// zoomed frame: a little softness (a Retina fit view of 2800 px from the 2560 px preview) is not +/// worth decoding the original for. +pub const WINDOW_RATIO: f32 = 1.25; + +/// How big the loupe's view of the photo is, and what the host allows. +#[derive(Clone, Copy, Debug)] +pub struct ViewSizes { + /// Long edge of the photo as drawn (physical px): the zoomed frame. + pub drawn_long: f32, + /// Long edge of the canvas it is drawn in (physical px). + pub canvas_long: f32, + /// The photo's own long edge as shown (after crop and rotation). + 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). + pub draft_scale: f32, +} + +/// What the loupe renders for a view: the whole frame at about canvas size, and, once the view is +/// zoomed past what that holds, the window on screen at no more than 100 %. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LoupePlan { + /// Long edge of the whole-frame render ([`Slot::Main`](crate::render::Slot::Main)): never more + /// than the canvas shows, the user's preview limit, the texture side or the preview source level. + pub main_edge: usize, + /// Long edge of the zoomed frame the window is cut from (never above the photo's own pixels: + /// beyond 100 % the GPU magnifies the window), or `None`: the whole-frame render is enough. + pub window_edge: Option, +} + +/// 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 { + 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 main_edge = settings + .loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side) + .min(lightcraft_engine::SourceLevel::Preview.max_edge()); + 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; + let window_edge = (frame_edge as f32 > main_edge as f32 / scale * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); + LoupePlan { main_edge, window_edge } +} + +/// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel +/// size is off by): a window is a part of the frame, so it can only be placed over a picture that +/// is the frame (an unsupported raw's embedded JPEG may be cropped differently). +pub fn same_aspect(shown: f32, frame: f32) -> bool { + shown.is_finite() && frame.is_finite() && frame > 0.0 && (shown / frame - 1.0).abs() <= 0.02 +} + /// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of /// the whole-frame render, which has only `rendered_long` of them. pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { @@ -87,6 +145,110 @@ pub fn window_for(full_w: usize, full_h: usize, visible: (f32, f32, f32, f32), m #[cfg(test)] mod tests { use super::*; + use crate::state::AppSettings; + + 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 } + } + + fn auto() -> AppSettings { + AppSettings::default() + } + + // Given a fit view on a Retina Mac (about 2800 px), the preview is enough: no original decoded + #[test] + fn a_retina_fit_view_needs_no_window() { + let p = plan(&auto(), sizes(2800.0, 2800.0, 6000)); + assert_eq!(p.main_edge, 2560, "the preview source level"); + assert_eq!(p.window_edge, None); + } + + // Given a 4K display, the fit view is 1.5x the preview: the window makes it sharp + #[test] + fn a_large_fit_view_gets_a_window() { + let p = plan(&auto(), sizes(3840.0, 3840.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(3840))); + } + + // Given 1:1 on a 24 MP photo in a 2800 px canvas, the whole frame stays canvas-sized and the + // window is cut from the photo's own pixels + #[test] + fn one_to_one_renders_the_canvas_and_a_native_window() { + let p = plan(&auto(), sizes(6000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); + } + + // Given 800 %, the window is still at 100 % (the GPU magnifies it): the cost does not grow + #[test] + fn beyond_one_to_one_the_window_stays_at_native_resolution() { + let p = plan(&auto(), sizes(48000.0, 2800.0, 6000)); + assert_eq!((p.main_edge, p.window_edge), (2560, Some(6000))); + let q = plan(&auto(), sizes(480.0 * 100.0, 2800.0, 6000)); + assert_eq!(q.window_edge, Some(6000)); + } + + // Given a small photo zoomed in, the whole-frame render already holds all its pixels + #[test] + fn a_small_photo_needs_no_window() { + let p = plan(&auto(), sizes(8000.0, 1400.0, 1000)); + assert_eq!((p.main_edge, p.window_edge), (1000, None)); + } + + // Given a slider drag at 400 % (drafts at 0.6 scale), the window plan does not change: it is + // the whole-frame draft that gets cheaper + #[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) }); + assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); + assert_eq!(drag.window_edge, still.window_edge); + } + + // 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] + fn a_preview_limit_caps_the_whole_frame_render_and_spares_the_fit_view() { + let s = AppSettings { preview_limit: 1600, ..Default::default() }; + let fit = plan(&s, sizes(2800.0, 2800.0, 6000)); + assert_eq!((fit.main_edge, fit.window_edge), (1600, None)); + let zoomed = plan(&s, sizes(6000.0, 2800.0, 6000)); + assert_eq!((zoomed.main_edge, zoomed.window_edge), (1600, Some(6000))); + } + + // Given a GPU with 2048 px textures, the whole-frame render obeys it, the window frame need not + // (a window is cut to the texture side separately) + #[test] + fn the_texture_side_caps_the_whole_frame_render_only() { + let p = plan(&auto(), ViewSizes { texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (2048, Some(6000))); + } + + // Given a picture that is not the frame (an embedded JPEG cropped differently), no window + #[test] + fn a_window_needs_a_picture_with_the_frames_aspect() { + assert!(same_aspect(1.5, 1.5) && same_aspect(1.51, 1.5)); + assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); + } + + // 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) }); + assert_eq!(p.window_edge, None); + } + + // Hostile numbers give a plan, never a panic + #[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) }); + assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); + } + let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); + assert!(p.main_edge >= 8); + } // Given a view drawn at 6000 px and a render of 6000, no window is needed; at 6000 vs 2560 it is #[test] @@ -147,10 +309,12 @@ mod tests { fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { use lightcraft_catalog::PhotoId; let v = RegionView { photo: PhotoId(1), key: 7, full: (6000, 4000), window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, settings: 11 }; - assert!(v.is_current(PhotoId(1), (6000, 4000), 11)); - assert!(!v.is_current(PhotoId(2), (6000, 4000), 11), "another photo"); - assert!(!v.is_current(PhotoId(1), (6000, 4001), 11), "another zoom"); - assert!(!v.is_current(PhotoId(1), (6000, 4000), 12), "another look (an edit, an undo)"); + assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); + assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); + assert!(!v.is_current(PhotoId(1), (6000, 4000), 12, false), "another look (an edit, an undo)"); + assert!(v.is_current(PhotoId(1), (6000, 4000), 12, true), "a drag's drafts follow the value"); + assert!(!v.is_current(PhotoId(2), (6000, 4000), 12, true), "…of this photo only"); } // Given a GPU that allows 2048 px textures, no window is wider than that diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ca479460..c7ff1e86 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -218,6 +218,15 @@ impl AppSettings { loupe_edge.min(lightcraft_engine::SourceLevel::Preview.max_edge()).min(texture_side.max(MIN_TEXTURE_SIDE)) } + /// Long edge of the zoomed frame a window render is cut from, for a photo drawn `drawn_long` + /// px along its long edge: as drawn, but never above the photo's own pixels (beyond 100 % the + /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about + /// the whole-frame render (see [`crate::region::plan`]). + pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { + let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, LOUPE_EDGE_CEILING as f32) } as usize; + wanted.min(native_long_edge.max(8)).min(LOUPE_EDGE_CEILING as usize) + } + /// The edge Build Standard-Sized Previews uses. pub fn standard_preview_edge(&self) -> u32 { if self.preview_limit == 0 { STANDARD_PREVIEW_EDGE } else { self.preview_limit } diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 1b5cc9d5..c0245494 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -169,15 +169,20 @@ mod in_the_loupe { t.size[0].max(t.size[1]) } - // Given a photo larger than the old 2560 px cap, when I zoom to 1:1, then the loupe holds its pixels + // Given a photo larger than the canvas, when I zoom to 1:1, then the whole frame stays about + // canvas-sized (a drag redoes that, not 24 MP) and the pixels on screen come from a window + // cut from the photo's own pixels #[test] - fn one_to_one_is_rendered_at_the_photos_own_size() { + fn one_to_one_is_a_canvas_sized_frame_plus_a_native_window() { let (mut h, native) = detail(); h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); - let want = native.min(crate::state::LOUPE_EDGE_CEILING as usize); - let got = rendered_long_edge(&h); - assert!(got.abs_diff(want) <= 2, "native {native}: rendered {got}, wanted {want}"); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let region = h.app.region_view.expect("a window render"); + assert_eq!(region.full.0.max(region.full.1), native, "the window is cut from the photo's own pixels"); + let (w, hh) = region_tile(&h).expect("its texture"); + assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}"); } // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that @@ -195,13 +200,12 @@ mod in_the_loupe { h.app.renderer.textures.get(&Slot::Region).map(|t| (t.size[0], t.size[1])) } - // Given a photo that fits the whole-frame render at 1:1, then no second render is made + // Given a fit view, the whole-frame render is all there is: no second render, no original decoded #[test] - fn no_window_render_while_the_whole_frame_render_is_sharp_enough() { - let (mut h, _) = detail(); - h.request("engine.execute", json!({"command": "view.zoom100"}), T); - h.settle(SETTLE); + fn a_fit_view_makes_no_window_render() { + let (h, _) = detail(); assert_eq!(region_tile(&h), None); + assert_eq!(h.app.region_view, None); } // Given the user capped the preview at 1600 px, when I zoom to 1:1, then the visible part is @@ -343,4 +347,22 @@ mod in_the_loupe { assert!(main.size[0].max(main.size[1]) >= 700, "still a real render, not a thumbnail: {:?}", main.size); } } + + // Given 400 % on a big photo, when a slider is dragged, then the work per frame follows the + // canvas, not the zoom: a draft of the whole frame at canvas scale and a draft of the window + #[test] + fn a_slider_drag_when_zoomed_in_renders_canvas_sized_drafts() { + let (mut h, native) = detail(); + h.app.ui.zoom = crate::state::Zoom::Percent(400.0); + h.settle(SETTLE); + 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 main = rendered_long_edge(&h); + assert!(main <= 1000 && 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"); + assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame"); + } } From 83d0304ce6e7107cd388e7068ff141e10b3130e8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:31:08 -0400 Subject: [PATCH 18/39] #323: the per-view stage caches are counted against the memory budget The loupe's, the zoom window's and the before / hover renders' stage caches hold decoded-image sized buffers that the engine's budget did not count. A quarter of the memory budget is now theirs: each frame the renderer clears what exceeds it, the least reusable first (hover, before, compare, second window, the zoom window) and the open photo's own view last (stage_trim_order, pure and tested). The inspector's memory report shows the budget and how often caches were trimmed. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/render.rs | 138 ++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index e955d8d4..4231e61b 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -82,6 +82,38 @@ struct Queued { job: RenderJob, } +/// Which stage caches to clear, in order, so that the rest fit `budget` bytes: the cheapest to +/// rebuild and least reused first (hover, before, compare, second window, the zoom window), the +/// open photo's own view last. Empty caches are never named. The budget is hard. +pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec { + let rank = |s: Slot| match s { + Slot::Hover => 0, + Slot::Before => 1, + Slot::Compare(_) => 2, + Slot::Second => 3, + Slot::Region => 4, + Slot::Main => 6, + _ => 5, + }; + let mut held: usize = sizes.iter().map(|(_, b)| *b).fold(0, usize::saturating_add); + let mut order: Vec<(Slot, usize)> = sizes.iter().copied().filter(|(_, b)| *b > 0).collect(); + order.sort_by_key(|(s, _)| rank(*s)); + let mut out = Vec::new(); + for (slot, bytes) in order { + if held <= budget { + break; + } + held = held.saturating_sub(bytes); + out.push(slot); + } + out +} + +/// The share of the memory budget the per-view stage caches (the loupe's, the zoom window's, the +/// before / hover renders') may hold together: they are the decoded-image sized buffers that the +/// engine's own caches do not count. +const STAGE_BUDGET_SHARE: usize = 4; + /// Variant thumbnails kept as textures (LRU). pub const VARIANT_TEXTURES: usize = 96; /// Priority of variant thumbnail jobs: below on-screen grid thumbnails and the loupe. @@ -131,6 +163,11 @@ pub struct Renderer { variant_used: HashMap, /// Frames polled so far. frame: u64, + /// Stage caches cleared to stay in budget (see [`stage_trim_order`]). + stages_trimmed: u64, + /// A budget other than the memory budget's share (tests). + #[cfg(test)] + stage_budget_override: Option, /// Since when nothing has been pending, and whether the GPU pool was trimmed since. #[cfg(not(target_arch = "wasm32"))] idle: Option<(std::time::Instant, bool)>, @@ -184,6 +221,9 @@ impl Renderer { prefetched: HashMap::new(), variant_used: HashMap::new(), frame: 0, + stages_trimmed: 0, + #[cfg(test)] + stage_budget_override: None, #[cfg(not(target_arch = "wasm32"))] idle: None, } @@ -445,6 +485,7 @@ impl Renderer { self.preview_generation = Some((Arc::downgrade(cache), generation)); self.catalog_rev = session.catalog.revision; self.frame += 1; + self.trim_stages(); // wasm: run one job per frame on this thread, timed with the host clock #[cfg(target_arch = "wasm32")] let inline_ms = { @@ -583,6 +624,26 @@ impl Renderer { } } + /// Bytes the per-view stage caches (CPU images and GPU buffers) may hold together. + pub fn stage_budget(&self) -> usize { + #[cfg(test)] + if let Some(b) = self.stage_budget_override { + return b; + } + lightcraft_engine::memory::budget() / STAGE_BUDGET_SHARE + } + + /// Clear stage caches, least useful first, until what they hold fits [`Self::stage_budget`]. + fn trim_stages(&mut self) { + let sizes: Vec<(Slot, usize)> = self.stages.iter().map(|(slot, c)| (*slot, c.bytes() + lightcraft_engine::gpu::stage_bytes(c))).collect(); + for slot in stage_trim_order(&sizes, self.stage_budget()) { + if let Some(c) = self.stages.get(&slot) { + c.clear(); + self.stages_trimmed += 1; + } + } + } + /// What the renderer holds: per-view stage caches (CPU images and GPU buffers) and textures. pub fn memory(&self) -> serde_json::Value { let cpu: usize = self.stages.values().map(|s| s.bytes()).sum(); @@ -590,7 +651,7 @@ impl Renderer { let tex: usize = self.textures.values().map(|t| t.size[0] * t.size[1] * 4).sum(); let copies: usize = self.textures.values().filter_map(|t| t.pixels.as_ref()).map(|p| p.pixels.len() * 4).sum(); serde_json::json!({ - "stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu}, + "stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu, "budgetBytes": self.stage_budget(), "trimmed": self.stages_trimmed}, "textures": {"count": self.textures.len(), "bytes": tex, "cpuCopyBytes": copies}, }) } @@ -1041,3 +1102,78 @@ mod thumbnail_tests { ); } } + +#[cfg(test)] +mod stage_budget_tests { + use super::*; + + const MB: usize = 1 << 20; + + // Given stage caches within the budget, nothing is cleared + #[test] + fn nothing_is_trimmed_within_the_budget() { + let sizes = [(Slot::Main, 300 * MB), (Slot::Region, 100 * MB)]; + assert_eq!(stage_trim_order(&sizes, 512 * MB), vec![]); + } + + // Given too much held, the least useful caches go first (a hover or before render is rebuilt + // cheaply; the open photo's view is what slider drags reuse) and only as many as needed + #[test] + fn the_least_useful_caches_go_first_and_only_as_many_as_needed() { + let sizes = [(Slot::Main, 300 * MB), (Slot::Before, 200 * MB), (Slot::Hover, 100 * MB), (Slot::Region, 150 * MB)]; + // 750 held, 500 allowed: hover (100) then before (200) bring it to 450 + assert_eq!(stage_trim_order(&sizes, 500 * MB), vec![Slot::Hover, Slot::Before]); + // 400 allowed: the window's too (450 > 400), which leaves the open photo's 300 + assert_eq!(stage_trim_order(&sizes, 400 * MB), vec![Slot::Hover, Slot::Before, Slot::Region]); + // 250 allowed: and in the end the open photo's own + assert_eq!(stage_trim_order(&sizes, 250 * MB), vec![Slot::Hover, Slot::Before, Slot::Region, Slot::Main]); + } + + // Given the open photo's own view alone is over the budget, it goes too (the budget is hard) + #[test] + fn the_budget_is_hard() { + assert_eq!(stage_trim_order(&[(Slot::Main, 900 * MB)], 100 * MB), vec![Slot::Main]); + } + + // Given caches of slots that hold nothing, they are never "trimmed" + #[test] + fn empty_caches_are_left_alone() { + assert_eq!(stage_trim_order(&[(Slot::Hover, 0), (Slot::Main, 900 * MB)], 100 * MB), vec![Slot::Main]); + } + + // Given real stage caches over the budget, the renderer clears them (the open photo's last) + #[test] + fn the_renderer_trims_real_stage_caches() { + use lightcraft_engine::pipeline::{RenderRequest, SourceInfo, render_cached}; + let src = std::sync::Arc::new(lightcraft_raster::Rgb32f::from_fn(320, 240, |x, y| [x as f32 / 320.0, y as f32 / 240.0, 0.3])); + let mut r = Renderer::default(); + for slot in [Slot::Main, Slot::Hover] { + let cache: Arc = Default::default(); + let mut s = lightcraft_engine::develop::DevelopSettings::default(); + s.effects.clarity = 50.0; + render_cached(&src, &SourceInfo::default(), &s, &RenderRequest::fit(320, 240), &cache); + assert!(cache.bytes() > 0); + r.stages.insert(slot, cache); + } + let total: usize = r.stages.values().map(|c| c.bytes()).sum(); + r.stage_budget_override = Some(total * 3 / 4); + r.trim_stages(); + assert_eq!(r.stages[&Slot::Hover].bytes(), 0, "the hover render's cache went"); + assert!(r.stages[&Slot::Main].bytes() > 0, "the open photo's stayed"); + assert_eq!(r.stages_trimmed, 1); + r.stage_budget_override = Some(0); + r.trim_stages(); + assert_eq!(r.stages[&Slot::Main].bytes(), 0, "the budget is hard"); + r.trim_stages(); + assert_eq!(r.stages_trimmed, 2, "empty caches are not trimmed again"); + } + + // The renderer counts and trims what it holds, and says so + #[test] + fn the_renderer_reports_its_stage_budget() { + let r = Renderer::default(); + let m = r.memory(); + assert!(m["stageCaches"]["budgetBytes"].as_u64().unwrap() > 0, "{m}"); + assert_eq!(m["stageCaches"]["trimmed"], 0); + } +} From 136b2851ee9064e4e8cb01634536882be5f2cb46 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:32:35 -0400 Subject: [PATCH 19/39] #323: engine: region_job_before renders a window of the photo without its edits Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/media.rs | 48 +++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 667e0301..6a28a5fe 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -912,7 +912,31 @@ impl crate::Session { window: lightcraft_pipeline::PixelWindow, apply_crop: bool, ) -> Option { - let mut job = self.render_job(id, full_w, full_h, false, apply_crop)?; + self.region_job_of(id, full_w, full_h, window, apply_crop, false) + } + + /// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side. + pub fn region_job_before( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + ) -> Option { + self.region_job_of(id, full_w, full_h, window, apply_crop, true) + } + + fn region_job_of( + &mut self, + id: PhotoId, + full_w: usize, + full_h: usize, + window: lightcraft_pipeline::PixelWindow, + apply_crop: bool, + before: bool, + ) -> Option { + let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?; // what the window's spots and Auto Mask strokes read must fit in one render: else the // caller keeps the whole-frame render (a window alone would come out wrong) let p = self.catalog.photo(id)?; @@ -1343,6 +1367,28 @@ mod tests { assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); } + // Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo + // without its edits (the crop is kept), with a key of its own + #[test] + fn a_before_window_shows_the_unedited_look() { + use lightcraft_pipeline::PixelWindow; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 2.0; + s.set_develop(id, d, "Exposure").unwrap(); + let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 }; + let after = s.region_job(id, w, h, win, true).unwrap(); + let before = s.region_job_before(id, w, h, win, true).unwrap(); + assert_ne!(after.key, before.key); + let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image); + assert_eq!((a.width, a.height), (b.width, b.height)); + let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::() / i.data.len() as f64; + assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); + } + #[test] fn variant_jobs_have_their_own_keys_and_hit_the_cache() { let mut s = crate::Session::with_demo(); From 72bab50c6ec2170119c3c47e21b04c6a5cf173ab Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:38:20 -0400 Subject: [PATCH 20/39] #323: Before/After at 1:1 compares like with like The Before image was a stand-in capped at 2560 px beside an After at full resolution, so a 1:1 comparison misled. Both sides now get the same treatment: the same whole-frame size and, once zoomed, a window each (Slot::RegionBefore beside Slot::Region) cut from the same frame, in side by side, top/bottom, the \ original view and both wipes (the Before window is clipped to its side of the line). Each side's windows and tiles are tracked apart, a side is freed when it is no longer shown, and ui.inspect reports regionBefore. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/detail.rs | 171 ++++++++++++++++------ crates/ui-egui/src/region.rs | 11 +- crates/ui-egui/src/render.rs | 6 +- crates/ui-egui/src/tests_preview_limit.rs | 60 ++++++++ 6 files changed, 206 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 072edc33..93d58e99 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -96,8 +96,10 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main), - // zoomed past the whole-frame render: the window rendered at the zoom scale (pixels of the zoomed frame) + // zoomed past the whole-frame render: the window rendered at no more than 100 % (pixels of its frame) "region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})), + // …and the same for the Before side of a Before/After view + "regionBefore": app.region_before_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::RegionBefore)})), })), "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8bc930fb..51efb214 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -203,6 +203,8 @@ pub struct LightcraftApp { /// The window render the loupe asked for last, by job key (see [`region`]); kept for the few /// windows whose textures can be on screen, and for the inspector. pub region_view: Option, + /// The same for the Before side of a Before/After view. + pub region_before_view: Option, pub(crate) region_tiles: std::collections::HashMap, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, @@ -272,6 +274,7 @@ impl LightcraftApp { gesture: None, loupe_shown: None, region_view: None, + region_before_view: None, region_tiles: Default::default(), merge: merge::MergeState::default(), import: None, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 9c494e98..f31fc530 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -214,6 +214,80 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } +/// What a window render of the loupe needs to know about the view it belongs to. +struct WindowCtx { + id: PhotoId, + /// Long edge of the frame the windows are cut from (`None`: no windows). + frame_edge: Option, + drawn_long: f32, + aspect: f32, + ppp: f32, + texture_side: usize, + crop_tool: bool, + interacting: bool, +} + +/// One side of the loupe that gets a window render. +#[derive(Clone, Copy)] +struct WindowView { + slot: Slot, + /// The Before side (the photo without its edits). + before: bool, + /// The rect the photo is drawn in. + img: Rect, + /// The rect it is requested for (the drawn rect without a click-zoom animation). + target: Rect, + /// The area whose pixels are on screen. + visible: Rect, +} + +/// Ask for the window of `v` that holds what is on screen; remember it so its texture can be +/// drawn where it belongs. `None`: no window is wanted (or possible) for this view. +fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { + let frame_edge = c.frame_edge?; + let (fw, fh) = + if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) }; + let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); + // what is on screen, in pixels of the window's frame (the drawn frame, scaled) + let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0); + let visible = ( + to_px * (v.visible.left() - v.target.left()), + to_px * (v.visible.top() - v.target.top()), + to_px * (v.visible.right() - v.target.left()), + to_px * (v.visible.bottom() - v.target.top()), + ); + let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; + let job = if v.before { + app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)? + } else { + app.session.region_job(c.id, fw, fh, win, !c.crop_tool)? + }; + // a drag renders drafts of the window, as it does of the whole frame + let job = if c.interacting { job.draft() } else { job }; + let look = job.settings.hash64(); + let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; + // windows of other photos, looks and zooms of this side can't be drawn any more + app.region_tiles.retain(|_, t| t.before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); + app.region_tiles.insert(job.key, view); + app.renderer.request(v.slot, job, 99); + Some(view) +} + +/// Draw `v`'s window texture over the whole-frame render, if it is the one asked for (or, in a +/// drag, an earlier draft of it). +fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) { + let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return }; + let Some(tile) = + app.region_tiles.get(&tex.key).filter(|t| t.before == v.before && t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) + else { + return; + }; + let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); + let at = v.img.min + vec2(tile.window.x as f32 / fw * v.img.width(), tile.window.y as f32 / fh * v.img.height()); + let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * v.img.width(), tile.window.h as f32 / fh * v.img.height())); + p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let full = ui.max_rect(); @@ -365,46 +439,51 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more - // than 100 % (the GPU magnifies beyond that). Not while comparing, hovering a look or showing - // an overlay (those draw one image), nor where the shown picture isn't the frame's own (an - // unsupported raw's embedded JPEG with another crop: window coordinates are the frame's). - let plain_view = !split - && !show_before - && !split_view - && hover_key.is_none() + // than 100 % (the GPU magnifies beyond that), for each side that is shown. Not while hovering a + // look or showing an overlay (those draw another image), nor where the shown picture isn't the + // frame's own (an unsupported raw's embedded JPEG with another crop: window coordinates are + // the frame's). + let plain_view = hover_key.is_none() && !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None && crate::region::same_aspect(display_aspect, aspect); - let look = d.hash64(); - let mut region_full = None; - if let Some(frame_edge) = plan.window_edge.filter(|_| plain_view) { - let (fw, fh) = if aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / aspect) } else { (frame_edge as f32 * aspect, frame_edge as f32) }; - let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); - region_full = Some((fw, fh)); - // what is on screen, in pixels of the window's frame (the drawn frame, scaled) - let to_px = ppp * frame_edge as f32 / drawn_long.max(1.0); - let visible = ( - to_px * (canvas.left() - target_rect.left()), - to_px * (canvas.top() - target_rect.top()), - to_px * (canvas.right() - target_rect.left()), - to_px * (canvas.bottom() - target_rect.top()), - ); - if let Some(win) = crate::region::window_for(fw, fh, visible, crate::region::max_span(texture_side)) - && let Some(job) = app.session.region_job(id, fw, fh, win, !crop_tool) - { - // a drag renders drafts of the window, as it does of the whole frame - let job = if interacting { job.draft() } else { job }; - let view = crate::region::RegionView { photo: id, key: job.key, full: (fw, fh), window: win, settings: look }; - // windows of other photos, looks and zooms can't be drawn any more - app.region_tiles.retain(|_, v| v.is_current(id, (fw, fh), look, interacting)); - app.region_tiles.insert(job.key, view); - app.region_view = Some(view); - app.renderer.request(Slot::Region, job, 99); + let window_ctx = + WindowCtx { id, frame_edge: plan.window_edge.filter(|_| plain_view), drawn_long, aspect, ppp, texture_side, crop_tool, interacting }; + // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) + let mut window_views: Vec = Vec::new(); + if window_ctx.frame_edge.is_some() { + let before_rect = |r: Rect, area: Rect| WindowView { slot: Slot::RegionBefore, before: true, img: r, target: r, visible: area }; + let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; + if split { + let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); + window_views.push(before_rect(br, areas[0])); + window_views.push(after); + } else if show_before { + window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); + } else if split_view { + window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); + window_views.push(WindowView { visible: canvas, ..after }); + } else { + window_views.push(WindowView { visible: canvas, ..after }); + } + } + let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); + for v in window_views { + if let Some(view) = request_window(app, &window_ctx, &v) { + windows.push((v, view)); + } + } + for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] { + let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view); + if before { + app.region_before_view = shown; + } else { + app.region_view = shown; + } + if shown.is_none() { + app.region_tiles.retain(|_, v| v.before != before); + app.renderer.release(slot); } - } else { - app.region_view = None; - app.region_tiles.clear(); - app.renderer.release(Slot::Region); } let p = ui.painter_at(canvas); // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame @@ -470,15 +549,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } - // the window render, drawn over the whole-frame one where it belongs in the frame - if let Some(full) = region_full - && let Some(tex) = app.renderer.textures.get(&Slot::Region).filter(|t| t.photo == id) - && let Some(v) = app.region_tiles.get(&tex.key).filter(|v| v.is_current(id, full, look, interacting)) - { - let (fw, fh) = (v.full.0 as f32, v.full.1 as f32); - let at = img_rect.min + vec2(v.window.x as f32 / fw * img_rect.width(), v.window.y as f32 / fh * img_rect.height()); - let dst = Rect::from_min_size(at, vec2(v.window.w as f32 / fw * img_rect.width(), v.window.h as f32 / fh * img_rect.height())); - p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + // the window renders, drawn over the whole-frame ones where they belong in the frame (the + // Before side of a wipe only on its side of the line) + for (v, view) in &windows { + let clip = match (app.ui.before_after, v.before) { + (BeforeAfter::Split, true) => Rect::from_min_max(canvas.min, pos2(img_rect.center().x, canvas.bottom())), + (BeforeAfter::SplitTopBottom, true) => Rect::from_min_max(canvas.min, pos2(canvas.right(), img_rect.center().y)), + (BeforeAfter::Split, false) => Rect::from_min_max(pos2(img_rect.center().x, canvas.top()), canvas.max), + (BeforeAfter::SplitTopBottom, false) => Rect::from_min_max(pos2(canvas.left(), img_rect.center().y), canvas.max), + _ => canvas, + }; + draw_window(&p.with_clip_rect(clip.intersect(canvas)), app, &window_ctx, v, view); } app.loupe_shown = Some((id, shown)); if app.ui.before_after == BeforeAfter::Split { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 96c882e7..ac0c357e 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -20,6 +20,8 @@ pub const MAX_SPAN: usize = if cfg!(target_arch = "wasm32") { 3072 } else { 6144 #[derive(Clone, Copy, Debug, PartialEq)] pub struct RegionView { pub photo: lightcraft_catalog::PhotoId, + /// The Before side of a Before/After view (the photo without its edits). + pub before: bool, pub key: u64, pub full: (usize, usize), pub window: PixelWindow, @@ -308,7 +310,14 @@ mod tests { #[test] fn a_tile_is_drawn_only_while_it_shows_what_the_loupe_shows() { use lightcraft_catalog::PhotoId; - let v = RegionView { photo: PhotoId(1), key: 7, full: (6000, 4000), window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, settings: 11 }; + let v = RegionView { + photo: PhotoId(1), + before: false, + key: 7, + full: (6000, 4000), + window: PixelWindow { x: 0, y: 0, w: 256, h: 256 }, + settings: 11, + }; assert!(v.is_current(PhotoId(1), (6000, 4000), 11, false)); assert!(!v.is_current(PhotoId(2), (6000, 4000), 11, false), "another photo"); assert!(!v.is_current(PhotoId(1), (6000, 4001), 11, false), "another zoom"); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 4231e61b..31a57530 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -33,6 +33,8 @@ pub enum Slot { /// The loupe zoomed past what `Main` can hold: the window of the frame that is on screen, /// rendered at the zoom scale and drawn over `Main` (see [`crate::region`]). Region, + /// The Before side of a Before/After view zoomed the same way (see [`Slot::Region`]). + RegionBefore, /// The loupe's stand-in until `Main` has the photo. Preview, Before, @@ -88,7 +90,7 @@ struct Queued { pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec { let rank = |s: Slot| match s { Slot::Hover => 0, - Slot::Before => 1, + Slot::Before | Slot::RegionBefore => 1, Slot::Compare(_) => 2, Slot::Second => 3, Slot::Region => 4, @@ -181,7 +183,7 @@ impl Slot { /// An interactive view of the open photo: slider drags redo only the stages they feed, and a /// draft that finishes late still replaces older pixels. pub fn is_view(self) -> bool { - matches!(self, Slot::Main | Slot::Region | Slot::Before | Slot::Hover) + matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover) } } diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index c0245494..af1c36e9 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -365,4 +365,64 @@ mod in_the_loupe { let (w, hh) = region_tile(&h).expect("a window draft"); assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame"); } + + fn tile(h: &Headless, slot: Slot) -> Option<(usize, usize)> { + h.app.renderer.textures.get(&slot).map(|t| (t.size[0], t.size[1])) + } + + // Given Before/After side by side at 1:1, both sides are sharp windows of the same frame: the + // Before is not a 2560 px stand-in beside a full resolution After + #[test] + fn side_by_side_at_one_to_one_compares_like_with_like() { + let (mut h, native) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let (after, before) = (h.app.region_view.expect("After window"), h.app.region_before_view.expect("Before window")); + assert_eq!(after.full, before.full, "the same frame"); + assert_eq!(after.full.0.max(after.full.1), native); + let (a, b) = (tile(&h, Slot::Region).expect("After tile"), tile(&h, Slot::RegionBefore).expect("Before tile")); + assert_eq!(a, b, "the same window of each"); + let main = h.app.renderer.textures.get(&Slot::Main).unwrap(); + let before_main = h.app.renderer.textures.get(&Slot::Before).unwrap(); + assert_eq!(main.size, before_main.size, "and the same whole-frame size under them"); + } + + // Given the Before view alone (\), only the Before gets a window + #[test] + fn the_original_view_windows_only_the_before() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::Original; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(h.app.region_before_view.is_some()); + assert_eq!(h.app.region_view, None); + assert!(tile(&h, Slot::RegionBefore).is_some() && tile(&h, Slot::Region).is_none()); + } + + // Given a wipe (Split), both sides are windows, drawn over the same rect + #[test] + fn a_wipe_windows_both_sides() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::Split; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert_eq!(h.app.region_view.map(|v| v.window), h.app.region_before_view.map(|v| v.window)); + assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some()); + } + + // Given the Before view turned off, its window is freed + #[test] + fn leaving_before_after_frees_the_before_window() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(tile(&h, Slot::RegionBefore).is_some()); + h.app.ui.before_after = crate::state::BeforeAfter::Off; + h.settle(SETTLE); + assert_eq!(tile(&h, Slot::RegionBefore), None); + assert_eq!(h.app.region_before_view, None); + assert!(tile(&h, Slot::Region).is_some()); + } } From 6a07c354313873a140984292f76925b8af24196d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:40:05 -0400 Subject: [PATCH 21/39] #323: hold the render sizes while a pinch or two-finger scroll runs A continuous trackpad pinch changes the zoom every frame, and with it the sizes the loupe plans, so it would have been a stream of re-renders of every size in between. SizeHold (pure, tested) keeps the plan from before the gesture while it runs and for a quarter of a second after; the view is drawn from the renders it has, magnified by the GPU, and planned afresh when the gesture is quiet (a repaint is asked for at that moment). Another photo resets the hold. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/detail.rs | 6 ++ crates/ui-egui/src/region.rs | 72 +++++++++++++++++++++++ crates/ui-egui/src/tests_preview_limit.rs | 24 ++++++++ 4 files changed, 105 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 51efb214..1c84db43 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -205,6 +205,8 @@ pub struct LightcraftApp { pub region_view: Option, /// The same for the Before side of a Before/After view. pub region_before_view: Option, + /// The loupe's render sizes while a pinch or two-finger scroll runs. + pub(crate) size_hold: region::SizeHold, pub(crate) region_tiles: std::collections::HashMap, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, @@ -275,6 +277,7 @@ impl LightcraftApp { loupe_shown: None, region_view: None, region_before_view: None, + size_hold: Default::default(), region_tiles: Default::default(), merge: merge::MergeState::default(), import: None, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index f31fc530..3d03899c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -384,6 +384,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { draft_scale: scale, }, ); + // 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); + if app.size_hold.holding(now) { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); + } let want = plan.main_edge; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index ac0c357e..41bd79e8 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -102,6 +102,46 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { LoupePlan { main_edge, window_edge } } +/// How long the render sizes stay as they were after a pinch or two-finger scroll stops (s). +pub const HOLD_SECS: f64 = 0.25; + +/// Holds the loupe's render sizes while a gesture runs (a continuous pinch changes the zoom every +/// frame; re-planning each time would be a stream of renders of every size in between). The view +/// keeps being drawn from the renders it has, magnified by the GPU, and is re-planned once the +/// gesture has been quiet for [`HOLD_SECS`]. +#[derive(Clone, Debug, Default)] +pub struct SizeHold { + photo: Option, + plan: Option, + until: f64, +} + +impl SizeHold { + /// The plan to render this frame: `fresh` normally; the plan from before the gesture while + /// one is running (`gesturing`) and for [`HOLD_SECS`] after. `now` is in seconds. + pub fn apply(&mut self, photo: lightcraft_catalog::PhotoId, now: f64, gesturing: bool, fresh: LoupePlan) -> LoupePlan { + if self.photo != Some(photo) { + *self = SizeHold { photo: Some(photo), plan: Some(fresh), until: f64::NEG_INFINITY }; + return fresh; + } + if gesturing { + self.until = now + HOLD_SECS; + } + match self.plan { + Some(held) if gesturing || now < self.until => held, + _ => { + self.plan = Some(fresh); + fresh + } + } + } + + /// Whether sizes are being held at `now` (the caller asks for a repaint when it ends). + pub fn holding(&self, now: f64) -> bool { + now < self.until + } +} + /// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel /// size is off by): a window is a part of the frame, so it can only be placed over a picture that /// is the frame (an unsupported raw's embedded JPEG may be cropped differently). @@ -241,6 +281,38 @@ mod tests { assert_eq!(p.window_edge, None); } + // Given a pinch from fit to 400 %, the sizes of the first frame hold until the gesture is quiet + #[test] + fn a_pinch_holds_the_render_sizes() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + assert_ne!(fit, zoomed); + let mut hold = SizeHold::default(); + let p = PhotoId(1); + assert_eq!(hold.apply(p, 0.0, false, fit), fit); + // the pinch runs for a second: every frame would plan something else + for k in 1..60 { + assert_eq!(hold.apply(p, k as f64 / 60.0, true, zoomed), fit, "frame {k}"); + } + // it stopped at t = 59/60: still held for a moment (no flicker between two events)… + assert!(hold.holding(1.1)); + assert_eq!(hold.apply(p, 1.1, false, zoomed), fit); + // …then the view is planned for where it ended + assert!(!hold.holding(59.0 / 60.0 + HOLD_SECS + 0.01)); + assert_eq!(hold.apply(p, 1.3, false, zoomed), zoomed); + assert_eq!(hold.apply(p, 1.4, false, zoomed), zoomed); + } + + // Given another photo, the held sizes are forgotten + #[test] + fn another_photo_is_planned_afresh() { + use lightcraft_catalog::PhotoId; + let (fit, zoomed) = (plan(&auto(), sizes(1400.0, 1400.0, 6000)), plan(&auto(), sizes(24000.0, 1400.0, 6000))); + let mut hold = SizeHold::default(); + hold.apply(PhotoId(1), 0.0, false, fit); + assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); + } + // Hostile numbers give a plan, never a panic #[test] fn hostile_sizes_give_a_usable_plan() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index af1c36e9..f27eae4c 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -425,4 +425,28 @@ mod in_the_loupe { assert_eq!(h.app.region_before_view, None); assert!(tile(&h, Slot::Region).is_some()); } + + // Given a trackpad pinch from fit to well past 100 %, the render sizes are held while it runs: + // no stream of renders of every size in between, and the sharp window arrives once it stops + #[test] + fn a_pinch_holds_the_render_sizes_until_it_stops() { + let (mut h, native) = detail(); + let main_at_fit = rendered_long_edge(&h); + let built = h.app.renderer.completed; + let at = h.app.canvas_rect.unwrap().center(); + h.request("ui.move", json!({"x": at.x, "y": at.y}), T); + for _ in 0..40 { + h.request("ui.zoom", json!({"factor": 1.08}), T); + assert_eq!(h.app.region_view, None, "no window while the pinch runs"); + } + assert!(matches!(h.app.ui.zoom, crate::state::Zoom::Percent(p) if p > 100.0), "{:?}", h.app.ui.zoom); + assert_eq!(rendered_long_edge(&h), main_at_fit, "the whole-frame render keeps its size"); + assert!(h.app.renderer.completed <= built + 2, "{} renders finished during the pinch", h.app.renderer.completed - built); + for _ in 0..60 { + h.step(); + } + h.settle(SETTLE); + let region = h.app.region_view.expect("the window arrives when the pinch is over"); + assert_eq!(region.full.0.max(region.full.1), native); + } } From 35c9b67944264eb5c9ea0653d6902821437e6132 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:40:37 -0400 Subject: [PATCH 22/39] #323: test: no window over a picture that is not the frame (#280) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/tests_preview_limit.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index f27eae4c..5c8dee12 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -449,4 +449,19 @@ mod in_the_loupe { let region = h.app.region_view.expect("the window arrives when the pinch is over"); assert_eq!(region.full.0.max(region.full.1), native); } + + // Given a picture on screen that is not the frame (an unsupported raw's embedded JPEG cropped + // differently, #280), no window is rendered: its coordinates are the frame's + #[test] + fn no_window_over_a_picture_that_is_not_the_frame() { + let (mut h, _) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(h.app.region_view.is_some()); + let t = h.app.renderer.textures.get_mut(&Slot::Main).unwrap(); + t.size = [t.size[0], t.size[1] / 2]; + h.step(); + assert_eq!(h.app.region_view, None); + assert_eq!(region_tile(&h), None); + } } From 2f7d248d61bc58414b1bcfe589c94bc9acf0a345 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:59:53 -0400 Subject: [PATCH 23/39] #323: a window does not keep the whole source uploaded with its stages Profiling a slider drag at 100 % and 400 % found each tick of the window render taking about 60 ms: the 24 MP source (288 MB) uploaded to the device was kept with the view's stages, counted against the stage budget, and the stages cleared every frame, so every tick uploaded and resampled again. A source over 96 MB is now uploaded for the render only; the stages keep the window's own sampled pixels. A tick costs 7-12 ms instead. equivalence.rs covers it. Co-Authored-By: Claude Sonnet 5.5 --- crates/gpu/src/render.rs | 9 +++++++-- crates/gpu/tests/equivalence.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index c3841861..46b47f1a 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -414,6 +414,9 @@ fn warp_params( /// ~20 % faster than 8 at 6000 × 4000 (fewer interval evaluations, few more edge pixels). const COVER_ROWS: usize = 16; +/// The largest source (bytes on the device) a view's stages keep uploaded between renders. +const RETAIN_SOURCE_BYTES: usize = 96 << 20; + /// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every /// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose /// interval bounds place them clearly inside or outside the image are filled at once @@ -552,9 +555,11 @@ pub fn render( Some(e) => e.sampled.clone(), None if gpu.fits(src.data.len() * 3) => { let upload = || gpu.upload(rgb_words(src)); + // (a source too big to keep is uploaded for this render only: a window is cut from the + // photo's own pixels, and what its stages keep is its own sampled pixels) let src_buf = match stages { - Some(c) => c.source(src, upload), - None => Arc::new(upload()), + Some(c) if src.data.len() * 12 <= RETAIN_SOURCE_BYTES => c.source(src, upload), + _ => Arc::new(upload()), }; sample(&mut cx, src, src_buf, &plan) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index b4b52e21..bfb72e02 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -701,3 +701,29 @@ fn windows_match() { let corner = RenderRequest { window: Some(PixelWindow { x: 2360, y: 1520, w: 640, h: 480 }), ..RenderRequest::fit(3000, 2000) }; check("window: vignette corner", &src, &info, &s, &corner); } + +/// Issue #323: a window of a 24 MP photo is cut from a source of 288 MB on the device. Keeping that +/// upload with the view's stages counted it against the stage budget on every tick of a drag and +/// got the stages cleared, and the upload and resampling redone: the window's stages are its own +/// pixels, not the photo's. +#[test] +fn a_window_does_not_keep_the_whole_source_on_the_device() { + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 6000, 4000); + let info = SourceInfo { raw: true, ..Default::default() }; + let stages = StageCache::default(); + let s = DevelopSettings::default(); + let req = RenderRequest { window: Some(PixelWindow { x: 2000, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) }; + let _ = lightcraft_gpu::render(&src, &info, &s, &req, Some(&stages)).expect("gpu render"); + let held = lightcraft_gpu::stage_bytes(&stages); + assert!(held < 100 << 20, "the view's GPU stages hold {} MiB for a 640×480 window", held >> 20); + // …and the next tick of a drag reuses what is there + let mut s2 = s.clone(); + s2.light.exposure = 0.5; + let again = lightcraft_gpu::render(&src, &info, &s2, &req, Some(&stages)).expect("gpu render"); + assert_eq!((again.image.width, again.image.height), (640, 480)); + assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20); +} From ea33301e6e142dbe7aa111c7f34b0848d259b393 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:59:53 -0400 Subject: [PATCH 24/39] #323: a drag keeps the original its superseded drafts decoded; textures convert faster A drag supersedes every request before it finishes, and the source a superseded draft had decoded was thrown away with it, so a window cut from a full-size original decoded it again for every tick. A view slot's superseded draft that is newer than what the view shows now also hands its source to the session (late results of other slots stay ignored, as before). Render results become textures with a plain copy for the opaque case instead of egui's per-pixel premultiplying conversion, on the UI thread. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/render.rs | 48 ++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 31a57530..a8470472 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -84,6 +84,18 @@ struct Queued { job: RenderJob, } +/// The texture of a render: its pixels as egui wants them. Renders are opaque, so the common case +/// is a plain copy; only a pixel with alpha pays for premultiplying. (The generic conversion, on +/// the UI thread, was most of the time a 5 MP zoom window took to show.) +pub(crate) fn color_image(img: &lightcraft_raster::Rgba8) -> egui::ColorImage { + let pixels = img + .data + .iter() + .map(|p| if p[3] == 255 { egui::Color32::from_rgb(p[0], p[1], p[2]) } else { egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) }) + .collect(); + egui::ColorImage { size: [img.width, img.height], source_size: egui::vec2(img.width as f32, img.height as f32), pixels } +} + /// Which stage caches to clear, in order, so that the rest fit `budget` bytes: the cheapest to /// rebuild and least reused first (hover, before, compare, second window, the zoom window), the /// open photo's own view last. Empty caches are never named. The budget is hard. @@ -509,7 +521,9 @@ impl Renderer { for (mut slot, r, ms) in finished { self.completed += 1; // Ignore obsolete pixels, failures and decoded sources, even for the same render key; - // an interactive view still takes a superseded draft that is newer than what it shows. + // an interactive view still takes a superseded draft that is newer than what it + // shows, and the source it decoded: a drag supersedes every request before it + // finishes, and a source thrown away with them is decoded again for every tick. if self.request_ids.get(&slot) != Some(&r.request_id) { let newer_draft = slot.is_view() && self.request_ids.contains_key(&slot) @@ -519,6 +533,7 @@ impl Renderer { if !newer_draft { continue; } + session.accept(&r); } else { self.request_ids.remove(&slot); session.accept(&r); @@ -558,7 +573,7 @@ impl Renderer { self.textures.remove(&Slot::ThumbQuick(id)); } let img = &rendered.image; - let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let color = std::sync::Arc::new(color_image(img)); let pixels = self.keep_pixels.then(|| color.clone()); let name = format!("{slot:?}"); match self.textures.get_mut(&slot) { @@ -1179,3 +1194,32 @@ mod stage_budget_tests { assert_eq!(m["stageCaches"]["trimmed"], 0); } } + +#[cfg(test)] +mod color_image_tests { + use super::*; + + // The texture a render becomes is what egui would make of its bytes, whatever the alpha + #[test] + fn the_fast_conversion_equals_eguis() { + let img = lightcraft_raster::Rgba8::from_fn(37, 29, |x, y| { + let a = match (x + y) % 5 { + 0 => 0, + 1 => 17, + 2 => 128, + _ => 255, + }; + [(x * 7) as u8, (y * 9) as u8, (x * y) as u8, a] + }); + let want = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()); + let got = color_image(&img); + assert_eq!(got.size, want.size); + assert_eq!(got.pixels, want.pixels); + } + + #[test] + fn an_empty_image_is_an_empty_texture() { + let img = lightcraft_raster::Rgba8::new(0, 0); + assert!(color_image(&img).pixels.is_empty()); + } +} From 92196fddebf73d2e7dc4d434d2d095c90b09997b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:59:53 -0400 Subject: [PATCH 25/39] #323: tests: the drag keeps its original; a profile of slider drags (ignored) profile_slider_drag prints the milliseconds from a slider tick to the loupe showing it at fit, 100 % and 400 % of the demo library's 24 MP photos. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/tests_preview_limit.rs | 66 ++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 5c8dee12..08c43dec 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -432,7 +432,6 @@ mod in_the_loupe { fn a_pinch_holds_the_render_sizes_until_it_stops() { let (mut h, native) = detail(); let main_at_fit = rendered_long_edge(&h); - let built = h.app.renderer.completed; let at = h.app.canvas_rect.unwrap().center(); h.request("ui.move", json!({"x": at.x, "y": at.y}), T); for _ in 0..40 { @@ -441,7 +440,7 @@ mod in_the_loupe { } assert!(matches!(h.app.ui.zoom, crate::state::Zoom::Percent(p) if p > 100.0), "{:?}", h.app.ui.zoom); assert_eq!(rendered_long_edge(&h), main_at_fit, "the whole-frame render keeps its size"); - assert!(h.app.renderer.completed <= built + 2, "{} renders finished during the pinch", h.app.renderer.completed - built); + assert!(!h.app.renderer.is_pending(Slot::Region), "no window was asked for during the pinch"); for _ in 0..60 { h.step(); } @@ -464,4 +463,67 @@ mod in_the_loupe { assert_eq!(h.app.region_view, None); assert_eq!(region_tile(&h), None); } + + /// `cargo test -p lightcraft-ui-egui --release profile_slider_drag -- --ignored --nocapture`: + /// milliseconds from a slider tick to the loupe showing it, at 100 % and 400 % of the demo + /// library's 24 MP photos (what the maintainers asked for in the review of #351). + #[test] + #[ignore = "a measurement, not a check"] + fn profile_slider_drag() { + for percent in [0.0, 100.0, 400.0] { + let (mut h, native) = detail(); + h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) }; + h.settle(SETTLE); + h.app.session.begin_interaction("Exposure").unwrap(); + let mut ms = Vec::new(); + for tick in 0..20 { + let t0 = std::time::Instant::now(); + let r = h.request( + "engine.execute", + json!({"command": "develop.set", "params": {"control": "light.exposure", "value": tick as f64 * 0.05}}), + T, + ); + assert_eq!(r["ok"], true); + let mut frames = 0; + while (h.app.renderer.is_pending(Slot::Main) || h.app.renderer.is_pending(Slot::Region)) && frames < 2000 { + h.step(); + std::thread::sleep(std::time::Duration::from_micros(200)); + frames += 1; + } + ms.push(t0.elapsed().as_secs_f64() * 1e3); + } + ms.sort_by(|a, b| a.total_cmp(b)); + let (w, hh) = region_tile(&h).unwrap_or((0, 0)); + eprintln!( + "PROFILE {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}", + ms[ms.len() / 2], + ms[ms.len() * 9 / 10], + ms[ms.len() - 1], + rendered_long_edge(&h) + ); + eprintln!(" {}", h.app.renderer.memory()); + let job_ms = |slot| h.app.renderer.textures.get(&slot).map_or(0.0, |t| t.ms); + eprintln!(" last job times: whole frame {:.1} ms, window {:.1} ms", job_ms(Slot::Main), job_ms(Slot::Region)); + } + } + + // Given a slider drag that outpaces the renders (each tick supersedes the last), the original + // the window was cut from is kept once it is decoded: not decoded again for every tick + #[test] + fn a_drag_keeps_the_decoded_original_even_when_every_result_is_superseded() { + let (mut h, _) = detail(); + assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "fit needed no original"); + h.app.ui.zoom = crate::state::Zoom::Percent(400.0); + h.app.session.begin_interaction("Exposure").unwrap(); + let mut ticks = 0; + while !h.app.renderer.textures.contains_key(&Slot::Region) && ticks < 3000 { + let v = (ticks % 40) as f64 * 0.02; + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": v}}), T); + h.step(); + ticks += 1; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(h.app.renderer.textures.contains_key(&Slot::Region), "a window arrived"); + assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on"); + } } From eb06b65fdb5337f7677d9ff531b0dcc94771ce69 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 17:06:18 -0400 Subject: [PATCH 26/39] #323: docs: the loupe's render plan, Before/After windows, drag timings Co-Authored-By: Claude Sonnet 5.5 --- docs/control-protocol.md | 2 +- docs/gpu-pipeline.md | 1 + docs/parity.md | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f23382b8..7d9fa04b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at the zoom scale), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index d1aca9e2..528b35de 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -212,6 +212,7 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo developed image in ~50 ms. ## Memory (M5.6) +A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is uploaded for the render alone, so the stages hold the window's own pixels (issue #323: otherwise a 288 MB upload counted against the stage budget on every tick of a drag). - `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) diff --git a/docs/parity.md b/docs/parity.md index e02f5369..4e16ef3c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -71,8 +71,8 @@ Take the first one nobody is working on. colour-filter layout, fixed in #85 by reading the file's own tag). 4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`), then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it. -4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for Before/After, compare, overlays and - soft proofing; check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). +4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing + (Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). 5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). 6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR): @@ -185,7 +185,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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). Past what one whole-frame render holds (the photo's own pixels, 8192 px ceiling, or the Preview size cap) the loupe also renders just the window on screen at the zoom scale (`RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`; issue #323), so 1:1 and 2:1–8:1 are sharp at any photo size. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (capped at 3072 px a side there), and are not made during a slider drag (the draft shows) nor while comparing, hovering a look, soft-proofing or showing an overlay. 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 | +| 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, the Preview size limit and 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 drag drafts both renders (7–12 ms a tick at 100 % and 400 % of a 24 MP photo on an M1 Max, `profile_slider_drag`), a continuous pinch holds the render sizes until it is quiet, and a fit view (also on a Retina Mac) never decodes the original. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides. 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. The per-view stage caches count against a quarter of the memory budget. 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 | | 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 | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | @@ -480,7 +480,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the size the loupe is drawn at, up to the photo's own pixels and an 8192 px ceiling; or a 1600–5120 px cap; issue #323), memory budget, thumbnail cache size in Settings | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px cap on it; zoomed views add a window at up to 100 %, issue #323), memory budget (the per-view stage caches count against a quarter of it), thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | | LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | From 8a5ecebc18c7e0995ff10a08901bae156e95b89f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 17:40:16 -0400 Subject: [PATCH 27/39] #323: wipe, pane clipping and identical-looks fixes for the Before/After windows An adversarial review found that (1) in a wipe the Before window was drawn before, so under, the low-resolution Before stand-in; (2) the windows of side by side and stacked views were clipped to the canvas, so their margins spilled into the neighbouring pane; (3) with identical Before and After looks (a fresh, unedited photo) both windows had the same job key and the After tile replaced the Before one in the tile table. Windows are now drawn over the stand-ins and under the wipe line, clipped to their pane or their side of the line (region::window_clip, pure and tested), and tiles are keyed by side and job key. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 7 ++- crates/ui-egui/src/panels/detail.rs | 62 +++++++++++++---------- crates/ui-egui/src/region.rs | 38 ++++++++++++++ crates/ui-egui/src/tests_preview_limit.rs | 29 +++++++++++ 4 files changed, 107 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1c84db43..90cff182 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -207,7 +207,10 @@ 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, - pub(crate) region_tiles: std::collections::HashMap, + /// What the loupe drew last frame, in order (tests check layering). + #[cfg(test)] + pub(crate) draw_order: Vec<&'static str>, + pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, /// An import in progress (the import review dialog's batches). @@ -278,6 +281,8 @@ impl LightcraftApp { region_view: None, region_before_view: None, size_hold: Default::default(), + #[cfg(test)] + draw_order: Vec::new(), region_tiles: Default::default(), merge: merge::MergeState::default(), import: None, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3d03899c..bf33336c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -267,8 +267,8 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt let look = job.settings.hash64(); let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms of this side can't be drawn any more - app.region_tiles.retain(|_, t| t.before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); - app.region_tiles.insert(job.key, view); + app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); + app.region_tiles.insert((v.before, job.key), view); app.renderer.request(v.slot, job, 99); Some(view) } @@ -277,9 +277,7 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt /// drag, an earlier draft of it). fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) { let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return }; - let Some(tile) = - app.region_tiles.get(&tex.key).filter(|t| t.before == v.before && t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) - else { + let Some(tile) = app.region_tiles.get(&(v.before, tex.key)).filter(|t| t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) else { return; }; let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); @@ -487,7 +485,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.region_view = shown; } if shown.is_none() { - app.region_tiles.retain(|_, v| v.before != before); + app.region_tiles.retain(|(b, _), _| *b != before); app.renderer.release(slot); } } @@ -555,35 +553,43 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } - // the window renders, drawn over the whole-frame ones where they belong in the frame (the - // Before side of a wipe only on its side of the line) - for (v, view) in &windows { - let clip = match (app.ui.before_after, v.before) { - (BeforeAfter::Split, true) => Rect::from_min_max(canvas.min, pos2(img_rect.center().x, canvas.bottom())), - (BeforeAfter::SplitTopBottom, true) => Rect::from_min_max(canvas.min, pos2(canvas.right(), img_rect.center().y)), - (BeforeAfter::Split, false) => Rect::from_min_max(pos2(img_rect.center().x, canvas.top()), canvas.max), - (BeforeAfter::SplitTopBottom, false) => Rect::from_min_max(pos2(canvas.left(), img_rect.center().y), canvas.max), - _ => canvas, - }; - draw_window(&p.with_clip_rect(clip.intersect(canvas)), app, &window_ctx, v, view); - } app.loupe_shown = Some((id, shown)); + #[cfg(test)] + app.draw_order.clear(); + // a wipe shows the Before whole-frame render on its side of the line… + if app.ui.before_after == BeforeAfter::Split + && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) + { + let before_rect = fit_texture_rect(img_rect, tex.size); + let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom())); + p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE); + } + if app.ui.before_after == BeforeAfter::SplitTopBottom + && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) + { + let before_rect = fit_texture_rect(img_rect, tex.size); + let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y)); + p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); + } + #[cfg(test)] + if matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom) { + app.draw_order.push("wipe stand-in"); + } + // …and the window renders go over the whole-frame ones, all of them, where they belong in the + // frame (clipped to their pane, or their side of the line) + for (v, view) in &windows { + let clip = crate::region::window_clip(app.ui.before_after, v.before, v.visible, canvas, img_rect); + draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); + #[cfg(test)] + app.draw_order.push(if v.before { "window RegionBefore" } else { "window Region" }); + } + // the line of a wipe over both if app.ui.before_after == BeforeAfter::Split { let mid = img_rect.center().x; - if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { - let before_rect = fit_texture_rect(img_rect, tex.size); - let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom())); - p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE); - } p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); } if app.ui.before_after == BeforeAfter::SplitTopBottom { let mid = img_rect.center().y; - if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { - let before_rect = fit_texture_rect(img_rect, tex.size); - let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y)); - p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); - } p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); } if app.ui.show_clipping && !show_before && !fullscreen { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 41bd79e8..a3fb86d5 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -142,6 +142,23 @@ impl SizeHold { } } +/// Where a window of one side of a Before/After view may be drawn: its own pane in side by side +/// and stacked views (a window carries margin beyond what its pane shows, which would otherwise +/// spill into the neighbour), its side of the line in a wipe, else the canvas. +pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egui::Rect, canvas: egui::Rect, img: egui::Rect) -> egui::Rect { + use crate::state::BeforeAfter; + let (min, max) = (canvas.min, canvas.max); + let clip = match (mode, before_side) { + (BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(img.center().x, max.y)), + (BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, img.center().y)), + (BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(img.center().x, min.y), max), + (BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, img.center().y), max), + (BeforeAfter::SideBySide | BeforeAfter::TopBottom, _) => pane, + _ => canvas, + }; + clip.intersect(canvas) +} + /// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel /// size is off by): a window is a part of the frame, so it can only be placed over a picture that /// is the frame (an unsupported raw's embedded JPEG may be cropped differently). @@ -313,6 +330,27 @@ mod tests { assert_eq!(hold.apply(PhotoId(2), 0.1, true, zoomed), zoomed); } + // Given side by side, a window is drawn in its pane only; given a wipe, on its side of the line + #[test] + fn a_window_is_clipped_to_its_pane_or_its_side_of_the_wipe() { + use crate::state::BeforeAfter::*; + let canvas = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1000.0, 600.0)); + let (left, right) = ( + egui::Rect::from_min_max(egui::pos2(24.0, 24.0), egui::pos2(494.0, 576.0)), + egui::Rect::from_min_max(egui::pos2(506.0, 24.0), egui::pos2(976.0, 576.0)), + ); + assert_eq!(window_clip(SideBySide, true, left, canvas, left), left); + assert_eq!(window_clip(SideBySide, false, right, canvas, right), right); + let img = egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(900.0, 550.0)); + let before = window_clip(Split, true, canvas, canvas, img); + let after = window_clip(Split, false, canvas, canvas, img); + assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); + assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); + assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); + assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); + } + // Hostile numbers give a plan, never a panic #[test] fn hostile_sizes_give_a_usable_plan() { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 08c43dec..6efbba35 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -526,4 +526,33 @@ mod in_the_loupe { assert!(h.app.renderer.textures.contains_key(&Slot::Region), "a window arrived"); assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on"); } + + // Given a wipe at 1:1, the Before window is drawn after (over) the low-resolution Before + // stand-in on its side of the line, never under it + #[test] + fn a_wipe_draws_the_before_window_over_its_stand_in() { + for mode in [crate::state::BeforeAfter::Split, crate::state::BeforeAfter::SplitTopBottom] { + let (mut h, _) = detail(); + h.app.ui.before_after = mode; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + h.step(); + let order = &h.app.draw_order; + let pos = |what: &str| order.iter().position(|d| *d == what); + let (stand_in, before, after) = (pos("wipe stand-in"), pos("window RegionBefore"), pos("window Region")); + assert!(stand_in.is_some() && before.is_some() && after.is_some(), "{mode:?}: {order:?}"); + assert!(before > stand_in, "{mode:?}: the Before window is under its stand-in: {order:?}"); + } + } + + // Given a Before and an After with the same look (an unedited photo), both windows are kept + // apart: the job key is the same, and one tile must not replace the other + #[test] + fn identical_before_and_after_keep_a_tile_each() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::>()); + } } From e9fb4b08e1fe14f2658e755bb4856d172cbae41b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 17:50:04 -0400 Subject: [PATCH 28/39] #323: the render plan: drags keep the window, modes without windows render full size, limits count Review findings on region::plan: - a drag at fit on a 4K or 5K display switched the window off (the whole-frame draft shrinks, the preview-source cap does not): the window decision now compares against the undrafted whole-frame render; - overlays, soft proofing and windows refused for reading too far (a spot or Auto Mask stroke) left the 1:1 view a canvas-sized upscale: those modes render the whole frame at the size it is drawn, as before windows (a refusal is remembered per photo, look and frame, and retried when any changes); - the 3840 and 5120 preview sizes had become no-ops: a limit above the preview source level is honoured; - windows were limited to 8192 px frames: they now reach the photo's own pixels (65536 px at most), so a medium-format photo is sharp at 1:1; - a pinch out from a deep zoom asked for an ever wider window every frame: windows already there are kept, magnified, until the gesture has been quiet. The unused region::needed is gone. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/detail.rs | 58 ++++++++++++---- crates/ui-egui/src/region.rs | 84 +++++++++++++++++------ crates/ui-egui/src/state.rs | 8 ++- crates/ui-egui/src/tests_preview_limit.rs | 47 +++++++++++++ 5 files changed, 163 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90cff182..56aeb97c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -207,6 +207,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, + /// (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)>, /// What the loupe drew last frame, in order (tests check layering). #[cfg(test)] pub(crate) draw_order: Vec<&'static str>, @@ -281,6 +283,7 @@ impl LightcraftApp { region_view: None, region_before_view: None, size_hold: Default::default(), + window_refused: None, #[cfg(test)] draw_order: Vec::new(), region_tiles: Default::default(), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index bf33336c..c51609f9 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -225,6 +225,10 @@ struct WindowCtx { texture_side: usize, crop_tool: bool, interacting: bool, + /// Hash of the photo's develop settings. + look: u64, + /// A pinch or two-finger scroll is running: keep the windows there are. + holding: bool, } /// One side of the loupe that gets a window render. @@ -245,6 +249,11 @@ struct WindowView { /// drawn where it belongs. `None`: no window is wanted (or possible) for this view. fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { let frame_edge = c.frame_edge?; + // while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one + // per frame (zooming out would ask for ever wider ones) + if c.holding { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); + } let (fw, fh) = if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) }; let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); @@ -258,9 +267,15 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt ); let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; let job = if v.before { - app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)? + app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) } else { - app.session.region_job(c.id, fw, fh, win, !c.crop_tool)? + app.session.region_job(c.id, fw, fh, win, !c.crop_tool) + }; + // refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one + // render, so the whole frame is rendered at the drawn size instead (see `region::plan`) + let Some(job) = job else { + app.window_refused = Some((c.id, c.look, frame_edge)); + return None; }; // a drag renders drafts of the window, as it does of the whole frame let job = if c.interacting { job.draft() } else { job }; @@ -372,16 +387,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 plan = crate::region::plan( - &app.ui.settings, - crate::region::ViewSizes { - drawn_long, - canvas_long: canvas.width().max(canvas.height()) * ppp, - native_long, - texture_side, - draft_scale: scale, - }, - ); + let look = d.hash64(); + // (modes that draw another picture over the loupe, or reads too far for one window, have the + // whole frame rendered at the size it is drawn, as they did before windows) + let sizes = crate::region::ViewSizes { + drawn_long, + canvas_long: canvas.width().max(canvas.height()) * ppp, + native_long, + texture_side, + draft_scale: scale, + windows: !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None, + }; + let mut plan = crate::region::plan(&app.ui.settings, sizes); + if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { + plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); + } // 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); @@ -451,8 +471,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None && crate::region::same_aspect(display_aspect, aspect); - let window_ctx = - WindowCtx { id, frame_edge: plan.window_edge.filter(|_| plain_view), drawn_long, aspect, ppp, texture_side, crop_tool, interacting }; + let window_ctx = WindowCtx { + id, + frame_edge: plan.window_edge.filter(|_| plain_view), + drawn_long, + aspect, + ppp, + texture_side, + crop_tool, + interacting, + look, + holding: navigating || app.size_hold.holding(now), + }; // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) let mut window_views: Vec = Vec::new(); if window_ctx.frame_edge.is_some() { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index a3fb86d5..031db8eb 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -72,6 +72,10 @@ pub struct ViewSizes { pub texture_side: usize, /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). pub draft_scale: f32, + /// Whether a window can be drawn over the whole-frame render (not with an overlay, soft + /// proofing, or a read too far for one window): if not, the whole frame is rendered at the + /// size it is drawn. + pub windows: bool, } /// What the loupe renders for a view: the whole frame at about canvas size, and, once the view is @@ -92,13 +96,20 @@ 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 main_edge = settings - .loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side) - .min(lightcraft_engine::SourceLevel::Preview.max_edge()); + if !v.windows { + let main_edge = settings.loupe_edge(drawn_long * scale, v.native_long, v.texture_side); + return LoupePlan { main_edge, window_edge: None }; + } + // a user who chose a size above the preview source level (3840, 5120) means it; otherwise the + // 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; - let window_edge = (frame_edge as f32 > main_edge as f32 / scale * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); + // (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 } } @@ -166,12 +177,6 @@ pub fn same_aspect(shown: f32, frame: f32) -> bool { shown.is_finite() && frame.is_finite() && frame > 0.0 && (shown / frame - 1.0).abs() <= 0.02 } -/// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of -/// the whole-frame render, which has only `rendered_long` of them. -pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { - drawn_long.is_finite() && drawn_long > rendered_long as f32 * 1.02 + 1.0 -} - /// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the /// frame's pixels, as much of the frame as is on screen — is covered: expanded by a margin, /// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame, @@ -209,7 +214,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 } + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true } } fn auto() -> AppSettings { @@ -291,6 +296,53 @@ mod tests { assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); } + // Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: + // it is only the whole-frame draft that gets smaller + #[test] + 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) }); + assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); + assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); + } + } + + // Given a mode whose picture can't be a window (a mask overlay, soft proofing, a spot read too + // far for one render), the whole-frame render is as big as the view, as it was before windows + #[test] + fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { + let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (6000, None)); + let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); + let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); + assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); + let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!(small.main_edge, 2048); + } + + // Given a user who chose a preview size above the preview source level (3840 or 5120 on a big + // display), the whole-frame render is that big + #[test] + fn a_large_preview_limit_is_honoured() { + for limit in [3840u32, 5120] { + let s = AppSettings { preview_limit: limit, ..Default::default() }; + let p = plan(&s, sizes(5120.0, 5120.0, 6000)); + assert_eq!(p.main_edge, limit as usize, "{limit}"); + assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); + } + let s = AppSettings { preview_limit: 2560, ..Default::default() }; + assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); + } + + // Given a medium format photo wider than 8192 px, 1:1 is its own pixels + #[test] + fn a_window_frame_can_be_as_big_as_the_photo() { + let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); + assert_eq!(p.window_edge, Some(11648)); + } + // 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() { @@ -362,16 +414,6 @@ mod tests { assert!(p.main_edge >= 8); } - // Given a view drawn at 6000 px and a render of 6000, no window is needed; at 6000 vs 2560 it is - #[test] - fn a_window_is_needed_only_when_the_whole_frame_render_is_stretched() { - assert!(!needed(6000.0, 6000)); - assert!(!needed(6100.0, 6000), "a couple of percent is not worth a second render"); - assert!(needed(6000.0, 2560)); - assert!(needed(48000.0, 8192)); - assert!(!needed(f32::NAN, 100) && !needed(f32::INFINITY, 100)); - } - // Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid #[test] fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c7ff1e86..17f5c58b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -182,6 +182,10 @@ pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; /// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. pub const LOUPE_EDGE_CEILING: u32 = 8192; +/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more +/// than the photos there are (a window holds only what is on screen, so the frame costs nothing). +pub const WINDOW_FRAME_CEILING: usize = 65536; + /// The smallest GPU texture side the loupe plans for, whatever the host reports. pub const MIN_TEXTURE_SIDE: usize = 512; @@ -223,8 +227,8 @@ impl AppSettings { /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about /// the whole-frame render (see [`crate::region::plan`]). pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { - let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, LOUPE_EDGE_CEILING as f32) } as usize; - wanted.min(native_long_edge.max(8)).min(LOUPE_EDGE_CEILING as usize) + let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize; + wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING) } /// The edge Build Standard-Sized Previews uses. diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 6efbba35..55157eb8 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -555,4 +555,51 @@ mod in_the_loupe { h.settle(SETTLE); assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::>()); } + + // Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame + // render is as big as the view, not a canvas-sized upscale + #[test] + fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() { + let (mut h, native) = detail(); + h.app.ui.soft_proof = true; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert_eq!(h.app.region_view, None); + assert_eq!(rendered_long_edge(&h), native); + } + + // Given a window the photo's spots make too big for one render (refused), the loupe falls back + // to the whole frame at the drawn size, and tries windows again when the look changes + #[test] + fn a_refused_window_falls_back_to_a_full_size_render() { + let (mut h, native) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(h.app.region_view.is_some()); + let id = h.app.session.active().unwrap(); + let look = h.app.session.develop_of(id).unwrap().hash64(); + h.app.window_refused = Some((id, look, native)); + h.settle(SETTLE); + assert_eq!(h.app.region_view, None); + assert_eq!(rendered_long_edge(&h), native); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + h.settle(SETTLE); + assert!(h.app.region_view.is_some(), "a new look tries a window again"); + } + + // Given a pinch out from 400 %, the window there is is kept (magnified, then shrunk) until the + // pinch stops: a new, ever wider window every frame is not asked for + #[test] + fn pinching_out_asks_for_no_new_windows() { + let (mut h, _) = detail(); + h.app.ui.zoom = crate::state::Zoom::Percent(400.0); + h.settle(SETTLE); + let key = h.app.region_view.expect("a window").key; + let at = h.app.canvas_rect.unwrap().center(); + h.request("ui.move", json!({"x": at.x, "y": at.y}), T); + for _ in 0..12 { + h.request("ui.zoom", json!({"factor": 0.9}), T); + assert_eq!(h.app.region_view.map(|v| v.key), Some(key), "the window of before the pinch"); + } + } } From f68cd69b1e28065ab05ca173ef91127af5387a61 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 17:57:11 -0400 Subject: [PATCH 29/39] #323: the windows of a big photo share one device copy of its source Keeping a 288 MB upload with each view's stages made them thrash against the stage budget; not keeping it made every new window (a pan crossing a snap, the first window of the Before side, any control that invalidates the sampled pixels) upload and convert the source again, about 64 ms against 6-12 ms. A source too big for a view's stages is now kept once for all views of the photo (shared_source), outside the views' stages, and given back when the app idles (trim_pool(0)). source_uploads and shared_source_bytes expose it for tests and diagnostics. Co-Authored-By: Claude Sonnet 5.5 --- crates/gpu/src/lib.rs | 27 ++++++++++++++++++++ crates/gpu/src/render.rs | 44 ++++++++++++++++++++++++++++++--- crates/gpu/tests/equivalence.rs | 32 ++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 5ef3011b..312d4263 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -270,12 +270,39 @@ pub fn set_pool_limit(bytes: u64) { pub fn trim_pool(keep: u64) { #[cfg(not(target_arch = "wasm32"))] if let Some(g) = existing_device() { + if keep == 0 { + render::release_shared_source(); + } g.trim(keep); } #[cfg(target_arch = "wasm32")] let _ = keep; } +/// Device bytes of the one big source (a zoom window's original) shared by every view of the photo. +pub fn shared_source_bytes() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + render::shared_source_bytes() + } + #[cfg(target_arch = "wasm32")] + { + 0 + } +} + +/// Big sources uploaded to the shared copy so far (a diagnostic: one big photo is uploaded once, not once per window). +pub fn source_uploads() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + render::source_uploads() + } + #[cfg(target_arch = "wasm32")] + { + 0 + } +} + /// Device bytes held by a view's GPU stages (kept with its [`StageCache`]); 0 when it has none. pub fn stage_bytes(stages: &StageCache) -> usize { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 46b47f1a..8fda204f 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -417,6 +417,43 @@ const COVER_ROWS: usize = 16; /// The largest source (bytes on the device) a view's stages keep uploaded between renders. const RETAIN_SOURCE_BYTES: usize = 96 << 20; +/// Big sources uploaded to the shared copy so far (diagnostics and tests). +static SOURCE_UPLOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// The one big source (the original a zoom window is cut from) kept on the device for all the +/// views of the photo: the Before and After windows and every pan share it, instead of each +/// view's stages holding (or re-uploading) 288 MB of a 24 MP photo. It does not count as a view's +/// stages; it is released with the buffer pool when the app idles. +static SHARED_SOURCE: std::sync::Mutex, Arc)>> = std::sync::Mutex::new(None); + +fn shared_source(src: &Arc, upload: impl FnOnce() -> Buf) -> Arc { + let mut g = SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((weak, buf)) = &*g + && weak.upgrade().is_some_and(|s| Arc::ptr_eq(&s, src)) + { + return buf.clone(); + } + *g = None; // the previous photo's copy goes before the next is made + SOURCE_UPLOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let buf = Arc::new(upload()); + *g = Some((Arc::downgrade(src), buf.clone())); + buf +} + +/// Free the shared big source (see [`shared_source`]). +pub(crate) fn release_shared_source() { + *SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Device bytes of the shared big source, 0 when there is none. +pub(crate) fn shared_source_bytes() -> usize { + SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map_or(0, |(_, b)| b.len * 4) +} + +pub(crate) fn source_uploads() -> u64 { + SOURCE_UPLOADS.load(std::sync::atomic::Ordering::Relaxed) +} + /// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every /// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose /// interval bounds place them clearly inside or outside the image are filled at once @@ -555,11 +592,12 @@ pub fn render( Some(e) => e.sampled.clone(), None if gpu.fits(src.data.len() * 3) => { let upload = || gpu.upload(rgb_words(src)); - // (a source too big to keep is uploaded for this render only: a window is cut from the - // photo's own pixels, and what its stages keep is its own sampled pixels) + // (a source too big for a view's stages to keep is the one shared copy: a window is + // cut from the photo's own pixels, and what its stages keep is its own sampled pixels) let src_buf = match stages { Some(c) if src.data.len() * 12 <= RETAIN_SOURCE_BYTES => c.source(src, upload), - _ => Arc::new(upload()), + Some(_) => shared_source(src, upload), + None => Arc::new(upload()), }; sample(&mut cx, src, src_buf, &plan) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index bfb72e02..cb6e7c02 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -706,8 +706,12 @@ fn windows_match() { /// upload with the view's stages counted it against the stage budget on every tick of a drag and /// got the stages cleared, and the upload and resampling redone: the window's stages are its own /// pixels, not the photo's. +// (these two share the process's one big source: a test lock keeps them from replacing each other's) +static BIG_SOURCE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn a_window_does_not_keep_the_whole_source_on_the_device() { + let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner()); if !gpu() { return; } @@ -727,3 +731,31 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() { assert_eq!((again.image.width, again.image.height), (640, 480)); assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20); } + +/// …but the 288 MB is uploaded once for every window of every view of the photo, not once per +/// window: panning at 1:1, and the Before and After windows, share one device copy of it, which +/// goes when the app idles (`trim_pool(0)`). +#[test] +fn windows_of_a_big_photo_share_one_uploaded_source() { + let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + if !gpu() { + return; + } + use lightcraft_pipeline::PixelWindow; + let src = scene(1, 6000, 4000); + let info = SourceInfo { raw: true, ..Default::default() }; + let s = DevelopSettings::default(); + let (after, before) = (StageCache::default(), StageCache::default()); + let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) }; + let start = lightcraft_gpu::source_uploads(); + for (cache, x) in [(&after, 1000), (&after, 1256), (&after, 1512), (&before, 1512), (&before, 1768)] { + lightcraft_gpu::render(&src, &info, &s, &at(x), Some(cache)).expect("gpu render"); + } + assert_eq!(lightcraft_gpu::source_uploads() - start, 1, "one upload for five windows of two views"); + assert!(lightcraft_gpu::shared_source_bytes() >= 280_000_000, "{}", lightcraft_gpu::shared_source_bytes()); + assert!(lightcraft_gpu::stage_bytes(&after) < 100 << 20, "and it is not in the views' stages"); + lightcraft_gpu::trim_pool(0); + assert_eq!(lightcraft_gpu::shared_source_bytes(), 0, "an idle app gives it back"); + lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render"); + assert_eq!(lightcraft_gpu::source_uploads() - start, 2); +} From a94b647dfd9ffd26dd9eecfaea9835155f6f8684 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 17:57:11 -0400 Subject: [PATCH 30/39] #323: the open photo's views are never trimmed from the stage budget A review measured the drag at 100 % on a 2800 px canvas taking 87 ms a tick against 24 ms with a bigger budget: the open photo's caches were over the budget (a 5 MP window alone is 230 MB) and a "hard" budget cleared them every frame, so every tick rebuilt them. The whole-frame, zoom window and before caches are what a drag reuses; only idle caches (hover, compare tiles, second window) are trimmed now, the least useful first, and the open photo's are counted and reported. The drag test no longer waits on a tick count. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/render.rs | 58 +++++++++++++---------- crates/ui-egui/src/tests_preview_limit.rs | 24 +++++++++- 2 files changed, 56 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index a8470472..cc5e563f 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -96,21 +96,22 @@ pub(crate) fn color_image(img: &lightcraft_raster::Rgba8) -> egui::ColorImage { egui::ColorImage { size: [img.width, img.height], source_size: egui::vec2(img.width as f32, img.height as f32), pixels } } -/// Which stage caches to clear, in order, so that the rest fit `budget` bytes: the cheapest to -/// rebuild and least reused first (hover, before, compare, second window, the zoom window), the -/// open photo's own view last. Empty caches are never named. The budget is hard. +/// Which stage caches to clear, in order, so that the rest fit `budget` bytes. The open photo's +/// own views (the whole frame, the zoom windows, the before side) are never named: they are what a +/// slider drag reuses on every tick, and a cache cleared every frame is rebuilt every frame (a +/// drag on a 2800 px canvas got 3.6x slower). What goes is what nothing is looking at, the cheapest +/// to rebuild and least reused first (hover, compare, second window, background work). Empty +/// caches are never named. Their weight still counts toward the total and is reported. pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec { let rank = |s: Slot| match s { Slot::Hover => 0, - Slot::Before | Slot::RegionBefore => 1, Slot::Compare(_) => 2, Slot::Second => 3, - Slot::Region => 4, - Slot::Main => 6, _ => 5, }; + let in_use = |s: Slot| matches!(s, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before); let mut held: usize = sizes.iter().map(|(_, b)| *b).fold(0, usize::saturating_add); - let mut order: Vec<(Slot, usize)> = sizes.iter().copied().filter(|(_, b)| *b > 0).collect(); + let mut order: Vec<(Slot, usize)> = sizes.iter().copied().filter(|(s, b)| *b > 0 && !in_use(*s)).collect(); order.sort_by_key(|(s, _)| rank(*s)); let mut out = Vec::new(); for (slot, bytes) in order { @@ -124,7 +125,7 @@ pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec, + pub(crate) stage_budget_override: Option, /// Since when nothing has been pending, and whether the GPU pool was trimmed since. #[cfg(not(target_arch = "wasm32"))] idle: Option<(std::time::Instant, bool)>, @@ -1133,29 +1134,36 @@ mod stage_budget_tests { assert_eq!(stage_trim_order(&sizes, 512 * MB), vec![]); } - // Given too much held, the least useful caches go first (a hover or before render is rebuilt - // cheaply; the open photo's view is what slider drags reuse) and only as many as needed + // Given too much held, the caches nothing is looking at go first (a hover render's, a compare + // tile's: rebuilt cheaply), the least useful first, and only as many as needed #[test] - fn the_least_useful_caches_go_first_and_only_as_many_as_needed() { - let sizes = [(Slot::Main, 300 * MB), (Slot::Before, 200 * MB), (Slot::Hover, 100 * MB), (Slot::Region, 150 * MB)]; - // 750 held, 500 allowed: hover (100) then before (200) bring it to 450 - assert_eq!(stage_trim_order(&sizes, 500 * MB), vec![Slot::Hover, Slot::Before]); - // 400 allowed: the window's too (450 > 400), which leaves the open photo's 300 - assert_eq!(stage_trim_order(&sizes, 400 * MB), vec![Slot::Hover, Slot::Before, Slot::Region]); - // 250 allowed: and in the end the open photo's own - assert_eq!(stage_trim_order(&sizes, 250 * MB), vec![Slot::Hover, Slot::Before, Slot::Region, Slot::Main]); + fn the_idle_caches_go_first_and_only_as_many_as_needed() { + let sizes = + [(Slot::Main, 300 * MB), (Slot::Hover, 100 * MB), (Slot::Second, 80 * MB), (Slot::Compare(0), 60 * MB), (Slot::Compare(1), 60 * MB)]; + // 600 held, 500 allowed: the hover render's 100 is enough + assert_eq!(stage_trim_order(&sizes, 500 * MB), vec![Slot::Hover]); + // 400 allowed: the compare tiles' too (380 left), then, at 350, the second window's + assert_eq!(stage_trim_order(&sizes, 400 * MB), vec![Slot::Hover, Slot::Compare(0), Slot::Compare(1)]); + assert_eq!(stage_trim_order(&sizes, 350 * MB), vec![Slot::Hover, Slot::Compare(0), Slot::Compare(1), Slot::Second]); } - // Given the open photo's own view alone is over the budget, it goes too (the budget is hard) + // Given the open photo's views (the whole frame, the zoom windows, the before side) alone are + // over the budget, they are not trimmed: they are what a slider drag reuses on every tick, and + // clearing them every frame costs far more than the memory (issue #323 review: 3.6x slower + // drags on a 2800 px canvas). They are counted and reported, not dropped. #[test] - fn the_budget_is_hard() { - assert_eq!(stage_trim_order(&[(Slot::Main, 900 * MB)], 100 * MB), vec![Slot::Main]); + fn the_open_photos_views_are_never_trimmed() { + let views = [(Slot::Main, 400 * MB), (Slot::Region, 500 * MB), (Slot::RegionBefore, 500 * MB), (Slot::Before, 100 * MB)]; + assert_eq!(stage_trim_order(&views, 100 * MB), vec![]); + let mut with_idle = views.to_vec(); + with_idle.push((Slot::Hover, 50 * MB)); + assert_eq!(stage_trim_order(&with_idle, 100 * MB), vec![Slot::Hover]); } // Given caches of slots that hold nothing, they are never "trimmed" #[test] fn empty_caches_are_left_alone() { - assert_eq!(stage_trim_order(&[(Slot::Hover, 0), (Slot::Main, 900 * MB)], 100 * MB), vec![Slot::Main]); + assert_eq!(stage_trim_order(&[(Slot::Hover, 0), (Slot::Second, 900 * MB)], 100 * MB), vec![Slot::Second]); } // Given real stage caches over the budget, the renderer clears them (the open photo's last) @@ -1180,9 +1188,9 @@ mod stage_budget_tests { assert_eq!(r.stages_trimmed, 1); r.stage_budget_override = Some(0); r.trim_stages(); - assert_eq!(r.stages[&Slot::Main].bytes(), 0, "the budget is hard"); + assert!(r.stages[&Slot::Main].bytes() > 0, "the open photo's view is never trimmed"); r.trim_stages(); - assert_eq!(r.stages_trimmed, 2, "empty caches are not trimmed again"); + assert_eq!(r.stages_trimmed, 1, "and nothing is trimmed again"); } // The renderer counts and trims what it holds, and says so diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 55157eb8..08dfb747 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -516,7 +516,8 @@ mod in_the_loupe { h.app.ui.zoom = crate::state::Zoom::Percent(400.0); h.app.session.begin_interaction("Exposure").unwrap(); let mut ticks = 0; - while !h.app.renderer.textures.contains_key(&Slot::Region) && ticks < 3000 { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !h.app.renderer.textures.contains_key(&Slot::Region) && std::time::Instant::now() < deadline { let v = (ticks % 40) as f64 * 0.02; h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": v}}), T); h.step(); @@ -602,4 +603,25 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|v| v.key), Some(key), "the window of before the pinch"); } } + + // Given a stage budget far below what the open photo's views hold (a 2800 px canvas at 1:1), + // a slider drag still reuses them: they are not cleared every frame + #[test] + fn a_drag_over_the_stage_budget_does_not_clear_the_open_views() { + let (mut h, _) = detail(); + h.app.renderer.stage_budget_override = Some(1 << 20); + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + h.settle(SETTLE); + h.app.session.begin_interaction("Exposure").unwrap(); + for tick in 0..8 { + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": tick as f64 * 0.1}}), T); + h.settle(SETTLE); + } + assert_eq!(h.app.renderer.memory()["stageCaches"]["trimmed"], 0); + assert!( + h.app.renderer.memory()["stageCaches"]["gpuBytes"].as_u64().unwrap() + + h.app.renderer.memory()["stageCaches"]["cpuBytes"].as_u64().unwrap() + > 1 << 20 + ); + } } From a5b9dd603ff4d685457c1e064563d87d6537c937 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:03:16 -0400 Subject: [PATCH 31/39] #323: the drag profile covers a 2800 x 1700 canvas Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/tests_preview_limit.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 08dfb747..d019d3c6 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -150,9 +150,13 @@ mod in_the_loupe { /// The demo library in a 1400×900 window, the first photo open in Detail. fn detail() -> (Headless, usize) { + detail_in([1400.0, 900.0]) + } + + fn detail_in(size: [f32; 2]) -> (Headless, usize) { let services = Services { png: None, ..Default::default() }; let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let mut h = Headless::new(app, size, 1.0); let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); @@ -470,8 +474,10 @@ mod in_the_loupe { #[test] #[ignore = "a measurement, not a check"] fn profile_slider_drag() { - for percent in [0.0, 100.0, 400.0] { - let (mut h, native) = detail(); + for (size, percent) in + [([1400.0, 900.0], 0.0), ([1400.0, 900.0], 100.0), ([1400.0, 900.0], 400.0), ([2800.0, 1700.0], 100.0), ([2800.0, 1700.0], 400.0)] + { + let (mut h, native) = detail_in(size); h.app.ui.zoom = if percent == 0.0 { crate::state::Zoom::Fit } else { crate::state::Zoom::Percent(percent) }; h.settle(SETTLE); h.app.session.begin_interaction("Exposure").unwrap(); @@ -495,7 +501,9 @@ mod in_the_loupe { ms.sort_by(|a, b| a.total_cmp(b)); let (w, hh) = region_tile(&h).unwrap_or((0, 0)); eprintln!( - "PROFILE {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}", + "PROFILE {}x{} canvas, {percent:>4}% (0 = fit) of {native} px: tick→shown median {:.1} ms, p90 {:.1} ms, max {:.1} ms; whole-frame render {} px, window tile {w}×{hh}", + size[0], + size[1], ms[ms.len() / 2], ms[ms.len() * 9 / 10], ms[ms.len() - 1], From f1a513ffe2f3365fe46d14794eb674ad023bddc6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:03:16 -0400 Subject: [PATCH 32/39] #323: docs: drag timings on two canvases, what a fit view decodes, the shared source Co-Authored-By: Claude Sonnet 5.5 --- docs/gpu-pipeline.md | 2 +- docs/parity.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 528b35de..c0a96957 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -212,7 +212,7 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo developed image in ~50 ms. ## Memory (M5.6) -A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is uploaded for the render alone, so the stages hold the window's own pixels (issue #323: otherwise a 288 MB upload counted against the stage budget on every tick of a drag). +A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it) and given back when the app idles (issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). - `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) diff --git a/docs/parity.md b/docs/parity.md index 4e16ef3c..826b8b88 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -185,7 +185,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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, the Preview size limit and 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 drag drafts both renders (7–12 ms a tick at 100 % and 400 % of a 24 MP photo on an M1 Max, `profile_slider_drag`), a continuous pinch holds the render sizes until it is quiet, and a fit view (also on a Retina Mac) never decodes the original. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides. 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. The per-view stage caches count against a quarter of the memory budget. 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 | +| 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 10–20 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); 20 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 render the whole frame at the size it is drawn, as before windows. 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 | | 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 | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | @@ -480,7 +480,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px cap on it; zoomed views add a window at up to 100 %, issue #323), memory budget (the per-view stage caches count against a quarter of it), thumbnail cache size in Settings | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, not trimmed), thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | | LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | From 495b1ac9fb80f06e7dad8bb312fe96858bda552e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:31:45 -0400 Subject: [PATCH 33/39] #323: tests: the wipe layering and pane clipping are checked on the painted shapes A review showed the wipe test asserted on a hand-written list of labels (deleting the stand-in draw calls still passed) and nothing tested the clip at the call site (replacing it with the canvas still passed). The tests now read the meshes the frame painted: the Before window comes after the stand-in and stays inside its side of the line, and side by side each window is painted inside its pane. Both mutations of the code now fail them. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/headless.rs | 2 +- crates/ui-egui/src/lib.rs | 5 -- crates/ui-egui/src/panels/detail.rs | 8 ---- crates/ui-egui/src/tests_preview_limit.rs | 56 +++++++++++++++++++---- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 41a55651..de206582 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -32,7 +32,7 @@ const FRAME_DT: f64 = 1.0 / 60.0; pub struct HeadlessView { pub ctx: egui::Context, pub textures: TextureStore, - shapes: Vec, + pub(crate) shapes: Vec, pixels_per_point: f32, size: egui::Vec2, frames: u64, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 56aeb97c..07b01817 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -209,9 +209,6 @@ pub struct LightcraftApp { pub(crate) size_hold: region::SizeHold, /// (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)>, - /// What the loupe drew last frame, in order (tests check layering). - #[cfg(test)] - pub(crate) draw_order: Vec<&'static str>, pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, @@ -284,8 +281,6 @@ impl LightcraftApp { region_before_view: None, size_hold: Default::default(), window_refused: None, - #[cfg(test)] - draw_order: Vec::new(), region_tiles: Default::default(), merge: merge::MergeState::default(), import: None, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c51609f9..0a080e07 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -584,8 +584,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } app.loupe_shown = Some((id, shown)); - #[cfg(test)] - app.draw_order.clear(); // a wipe shows the Before whole-frame render on its side of the line… if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) @@ -601,17 +599,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y)); p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); } - #[cfg(test)] - if matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom) { - app.draw_order.push("wipe stand-in"); - } // …and the window renders go over the whole-frame ones, all of them, where they belong in the // frame (clipped to their pane, or their side of the line) for (v, view) in &windows { let clip = crate::region::window_clip(app.ui.before_after, v.before, v.visible, canvas, img_rect); draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); - #[cfg(test)] - app.draw_order.push(if v.before { "window RegionBefore" } else { "window Region" }); } // the line of a wipe over both if app.ui.before_after == BeforeAfter::Split { diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index d019d3c6..8150fd4f 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -536,24 +536,64 @@ mod in_the_loupe { assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on"); } - // Given a wipe at 1:1, the Before window is drawn after (over) the low-resolution Before - // stand-in on its side of the line, never under it + /// The meshes the last frame drew with `slot`'s texture: (paint order, clip rect, bounds). + fn drawn(h: &Headless, slot: Slot) -> Vec<(usize, egui::Rect, egui::Rect)> { + let Some(tex) = h.app.renderer.textures.get(&slot) else { return vec![] }; + h.view + .shapes + .iter() + .enumerate() + .filter_map(|(i, c)| match &c.shape { + egui::epaint::Shape::Mesh(m) if m.texture_id == tex.tex.id() => Some((i, c.clip_rect, m.calc_bounds())), + _ => None, + }) + .collect() + } + + // Given a wipe at 1:1, the Before window is painted after (over) the low-resolution Before + // stand-in on its side of the line, never under it, and inside its side of the line #[test] - fn a_wipe_draws_the_before_window_over_its_stand_in() { + fn a_wipe_paints_the_before_window_over_its_stand_in() { for mode in [crate::state::BeforeAfter::Split, crate::state::BeforeAfter::SplitTopBottom] { let (mut h, _) = detail(); h.app.ui.before_after = mode; h.request("engine.execute", json!({"command": "view.zoom100"}), T); h.settle(SETTLE); h.step(); - let order = &h.app.draw_order; - let pos = |what: &str| order.iter().position(|d| *d == what); - let (stand_in, before, after) = (pos("wipe stand-in"), pos("window RegionBefore"), pos("window Region")); - assert!(stand_in.is_some() && before.is_some() && after.is_some(), "{mode:?}: {order:?}"); - assert!(before > stand_in, "{mode:?}: the Before window is under its stand-in: {order:?}"); + let (stand_in, window) = (drawn(&h, Slot::Before), drawn(&h, Slot::RegionBefore)); + assert!(!stand_in.is_empty() && !window.is_empty(), "{mode:?}: the stand-in {stand_in:?} and the window {window:?} are both painted"); + assert!(window[0].0 > stand_in[0].0, "{mode:?}: the Before window is under its stand-in"); + let mid = h.app.image_rect.unwrap().center(); + let clip = window[0].1; + match mode { + crate::state::BeforeAfter::Split => assert!(clip.right() <= mid.x + 0.5, "{clip:?} crosses the line at x = {}", mid.x), + _ => assert!(clip.bottom() <= mid.y + 0.5, "{clip:?} crosses the line at y = {}", mid.y), + } + let after = drawn(&h, Slot::Region); + assert!(!after.is_empty()); + match mode { + crate::state::BeforeAfter::Split => assert!(after[0].1.left() >= mid.x - 0.5), + _ => assert!(after[0].1.top() >= mid.y - 0.5), + } } } + // Given Before and After side by side at 1:1, each window is painted inside its own pane: its + // margin (a window is wider than what its pane shows) does not spill into the neighbour + #[test] + fn side_by_side_windows_stay_in_their_panes() { + let (mut h, _) = detail_in([1800.0, 900.0]); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + h.step(); + let canvas = h.app.canvas_rect.unwrap(); + let (before, after) = (drawn(&h, Slot::RegionBefore), drawn(&h, Slot::Region)); + assert!(!before.is_empty() && !after.is_empty()); + assert!(before.iter().all(|(_, clip, _)| clip.right() <= canvas.center().x), "the Before window spills into the After pane: {before:?}"); + assert!(after.iter().all(|(_, clip, _)| clip.left() >= canvas.center().x), "the After window spills into the Before pane: {after:?}"); + } + // Given a Before and an After with the same look (an unedited photo), both windows are kept // apart: the job key is the same, and one tile must not replace the other #[test] From 03249f461959652a0a5ca4694c75176cf262cf63 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:32:45 -0400 Subject: [PATCH 34/39] #323: holding \ in a wipe shows the Before window everywhere The window of the Before view alone was clipped to the wipe's left half while the picture drawn was the Before on the whole canvas, leaving the right half the low-resolution stand-in. The clip follows the effective mode. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/headless.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 4 +++- crates/ui-egui/src/tests_preview_limit.rs | 23 +++++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index de206582..4612281f 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -114,7 +114,7 @@ pub struct Headless { pub pixels_per_point: f32, time: f64, frames: u64, - events: Vec, + pub(crate) events: Vec, control: Sender, quit: bool, } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0a080e07..32bf919b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -602,7 +602,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // …and the window renders go over the whole-frame ones, all of them, where they belong in the // frame (clipped to their pane, or their side of the line) for (v, view) in &windows { - let clip = crate::region::window_clip(app.ui.before_after, v.before, v.visible, canvas, img_rect); + // (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then) + let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after }; + let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect); draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view); } // the line of a wipe over both diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 8150fd4f..a1bd224c 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -578,6 +578,29 @@ mod in_the_loupe { } } + // Given `\` held in a wipe at 1:1, the Before is shown everywhere, so its window is painted + // everywhere (not only left of the line) + #[test] + fn holding_backslash_in_a_wipe_paints_the_before_window_across_the_canvas() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::Split; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + h.events.push(egui::Event::Key { + key: egui::Key::Backslash, + physical_key: Some(egui::Key::Backslash), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + h.settle(SETTLE); + h.step(); + let canvas = h.app.canvas_rect.unwrap(); + let window = drawn(&h, Slot::RegionBefore); + assert!(!window.is_empty()); + assert!(window[0].1.right() >= canvas.right() - 0.5, "the Before window stops at the wipe line: {:?}", window[0].1); + } + // Given Before and After side by side at 1:1, each window is painted inside its own pane: its // margin (a window is wider than what its pane shows) does not spill into the neighbour #[test] From ddaea580a1ab987aee33177036725bdfc3a8a560 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:34:02 -0400 Subject: [PATCH 35/39] #323: test: holding \ in a wipe is tested with the key held, and the mutation fails it Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/tests_preview_limit.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index a1bd224c..4d64dca2 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -594,6 +594,10 @@ mod in_the_loupe { modifiers: egui::Modifiers::NONE, }); h.settle(SETTLE); + // (the key's own shortcut switches to the Before view; the wipe is chosen again with the + // key still down, which is what holding `\` in a wipe is) + h.app.ui.before_after = crate::state::BeforeAfter::Split; + h.settle(SETTLE); h.step(); let canvas = h.app.canvas_rect.unwrap(); let window = drawn(&h, Slot::RegionBefore); From 057a8d308e20cdb2a1cc7a5ba628fad9e5cd34f4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:38:39 -0400 Subject: [PATCH 36/39] #323: the Before window waits for the original the After is decoding At first open of Before/After the two window jobs were asked for together, each decoded its own 24 MP copy of the original (a review saw two decodes and three uploads for one photo) and the shared device copy was swapped between them. The Before's window now waits until the After's job has handed the original to the session (region::defer_before_window, Session media has_source); later windows of both share the one copy. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/media.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 5 +++++ crates/ui-egui/src/region.rs | 17 +++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6a28a5fe..67367e0e 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -403,6 +403,15 @@ impl MediaCache { (self.thumbs.len(), self.thumbs.cost()) } + /// Whether the decoded source of `id` at `level` is held. + pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool { + match level { + SourceLevel::Thumb => self.thumbs.contains(&id), + SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), + SourceLevel::Full => self.full.as_ref().is_some_and(|e| e.0 == id), + } + } + /// Decoded sources held: (thumbnail level, preview level, full size). pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { use crate::memory::Usage; @@ -1389,6 +1398,20 @@ mod tests { assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); } + #[test] + fn has_source_follows_what_the_session_accepted() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let long = p.width.max(p.height) as usize; + assert!(!s.media.has_source(id, SourceLevel::Full)); + let r = s.render_job(id, long, long, false, true).unwrap().run(); + s.accept(&r); + assert!(s.media.has_source(id, SourceLevel::Full)); + assert!(!s.media.has_source(id, SourceLevel::Preview)); + assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full)); + } + #[test] fn variant_jobs_have_their_own_keys_and_hit_the_cache() { let mut s = crate::Session::with_demo(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 32bf919b..c9fb643d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -266,6 +266,11 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt to_px * (v.visible.bottom() - v.target.top()), ); let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; + // the Before side waits for the original the After is decoding (see `defer_before_window`) + let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); + if crate::region::defer_before_window(v.before, app.renderer.is_pending(Slot::Region), original_held) { + return app.region_before_view.filter(|w| w.photo == c.id); + } let job = if v.before { app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) } else { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 031db8eb..db170da4 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -170,6 +170,13 @@ pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egu clip.intersect(canvas) } +/// Whether the Before window waits for the After's. Both are cut from the same decoded original; +/// asked for together while it isn't held yet, each worker decodes its own copy (two full-size +/// decodes, two uploads, one thrown away). The Before follows once the original is in. +pub fn defer_before_window(before_side: bool, after_pending: bool, original_held: bool) -> bool { + before_side && after_pending && !original_held +} + /// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel /// size is off by): a window is a part of the frame, so it can only be placed over a picture that /// is the frame (an unsupported raw's embedded JPEG may be cropped differently). @@ -403,6 +410,16 @@ mod tests { assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); } + // Given Before and After windows asked for at once at first open, only the After's goes: the + // Before's waits for the decoded original instead of decoding another copy + #[test] + fn the_before_window_waits_for_the_original_the_after_is_decoding() { + assert!(defer_before_window(true, true, false)); + assert!(!defer_before_window(true, true, true), "the original is in"); + assert!(!defer_before_window(true, false, false), "nothing is decoding it"); + assert!(!defer_before_window(false, true, false), "the After never waits"); + } + // Hostile numbers give a plan, never a panic #[test] fn hostile_sizes_give_a_usable_plan() { From 22b7ba09b9a087ecd398d095b8fd13ef9651287f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:48:19 -0400 Subject: [PATCH 37/39] #323: stage budget: the open photo's views are cut only in a runaway; the shared source is reported A review measured 1 GB of GPU stages for a Before/After at 1:1 on a 2800 px canvas against a 400 MB budget, with nothing bounding it. The open photo's window and before caches are now cut (the before side first) past four times the budget, the whole-frame view's never; idle caches still go at the budget. The one shared device copy of a big original is reported (sharedSourceBytes in the renderer's memory report) and a misleading comment about it is corrected. Co-Authored-By: Claude Sonnet 5.5 --- crates/gpu/src/render.rs | 4 +- crates/ui-egui/src/render.rs | 69 ++++++++++++++++------- crates/ui-egui/src/tests_preview_limit.rs | 19 ++++--- 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 8fda204f..60ffd806 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -433,7 +433,9 @@ fn shared_source(src: &Arc, upload: impl FnOnce() -> Buf) -> Arc { { return buf.clone(); } - *g = None; // the previous photo's copy goes before the next is made + // the previous photo's copy goes (on a render thread it is retired with the render's other + // buffers, so for a moment both exist) + *g = None; SOURCE_UPLOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let buf = Arc::new(upload()); *g = Some((Arc::downgrade(src), buf.clone())); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index cc5e563f..3f0906a2 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -96,36 +96,59 @@ pub(crate) fn color_image(img: &lightcraft_raster::Rgba8) -> egui::ColorImage { egui::ColorImage { size: [img.width, img.height], source_size: egui::vec2(img.width as f32, img.height as f32), pixels } } -/// Which stage caches to clear, in order, so that the rest fit `budget` bytes. The open photo's -/// own views (the whole frame, the zoom windows, the before side) are never named: they are what a -/// slider drag reuses on every tick, and a cache cleared every frame is rebuilt every frame (a -/// drag on a 2800 px canvas got 3.6x slower). What goes is what nothing is looking at, the cheapest -/// to rebuild and least reused first (hover, compare, second window, background work). Empty -/// caches are never named. Their weight still counts toward the total and is reported. +/// How far over the budget the open photo's window and before caches may run before they are cut. +const HARD_FACTOR: usize = 4; + +/// Which stage caches to clear, in order, so that the rest fit `budget` bytes. +/// +/// What nothing is looking at goes first, the cheapest to rebuild and least reused first (hover, +/// compare, second window, background work). The open photo's own views are what a slider drag +/// reuses on every tick, and a cache cleared every frame is rebuilt every frame (a drag on a +/// 2800 px canvas got 3.6x slower), so they stay — counted and reported — up to +/// [`HARD_FACTOR`] times the budget; past that (a runaway: 8:1 on a huge canvas, both sides of +/// Before/After) the before side's and the zoom window's go, the before side first. The +/// whole-frame view's cache is never named. Empty caches are never named. pub(crate) fn stage_trim_order(sizes: &[(Slot, usize)], budget: usize) -> Vec { - let rank = |s: Slot| match s { + let idle_rank = |s: Slot| match s { Slot::Hover => 0, Slot::Compare(_) => 2, Slot::Second => 3, _ => 5, }; - let in_use = |s: Slot| matches!(s, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before); + let view_rank = |s: Slot| match s { + Slot::RegionBefore => Some(0), + Slot::Before => Some(1), + Slot::Region => Some(2), + _ => None, + }; + let is_view = |s: Slot| matches!(s, Slot::Main) || view_rank(s).is_some(); let mut held: usize = sizes.iter().map(|(_, b)| *b).fold(0, usize::saturating_add); - let mut order: Vec<(Slot, usize)> = sizes.iter().copied().filter(|(s, b)| *b > 0 && !in_use(*s)).collect(); - order.sort_by_key(|(s, _)| rank(*s)); let mut out = Vec::new(); - for (slot, bytes) in order { + let mut idle: Vec<(Slot, usize)> = sizes.iter().copied().filter(|(s, b)| *b > 0 && !is_view(*s)).collect(); + idle.sort_by_key(|(s, _)| idle_rank(*s)); + for (slot, bytes) in idle { if held <= budget { break; } held = held.saturating_sub(bytes); out.push(slot); } + let hard = budget.saturating_mul(HARD_FACTOR); + let mut views: Vec<(Slot, usize, u8)> = sizes.iter().filter_map(|(s, b)| view_rank(*s).filter(|_| *b > 0).map(|r| (*s, *b, r))).collect(); + views.sort_by_key(|(_, _, r)| *r); + for (slot, bytes, _) in views { + if held <= hard { + break; + } + held = held.saturating_sub(bytes); + out.push(slot); + } out } /// The share of the memory budget the per-view stage caches (the loupe's, the zoom window's, the -/// before / hover renders') may hold together before the idle ones are cleared: they are the decoded-image sized buffers that the +/// before / hover renders') may hold together before the idle ones are cleared (see +/// [`stage_trim_order`] for the open photo's own): they are the decoded-image sized buffers that the /// engine's own caches do not count. const STAGE_BUDGET_SHARE: usize = 4; @@ -669,7 +692,7 @@ impl Renderer { let tex: usize = self.textures.values().map(|t| t.size[0] * t.size[1] * 4).sum(); let copies: usize = self.textures.values().filter_map(|t| t.pixels.as_ref()).map(|p| p.pixels.len() * 4).sum(); serde_json::json!({ - "stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu, "budgetBytes": self.stage_budget(), "trimmed": self.stages_trimmed}, + "stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu, "budgetBytes": self.stage_budget(), "trimmed": self.stages_trimmed, "sharedSourceBytes": lightcraft_engine::gpu::shared_source_bytes()}, "textures": {"count": self.textures.len(), "bytes": tex, "cpuCopyBytes": copies}, }) } @@ -1147,17 +1170,22 @@ mod stage_budget_tests { assert_eq!(stage_trim_order(&sizes, 350 * MB), vec![Slot::Hover, Slot::Compare(0), Slot::Compare(1), Slot::Second]); } - // Given the open photo's views (the whole frame, the zoom windows, the before side) alone are - // over the budget, they are not trimmed: they are what a slider drag reuses on every tick, and - // clearing them every frame costs far more than the memory (issue #323 review: 3.6x slower - // drags on a 2800 px canvas). They are counted and reported, not dropped. + // Given the open photo's views over the budget, they are not trimmed: they are what a slider + // drag reuses on every tick, and clearing them every frame costs far more than the memory + // (issue #323 review: 3.6x slower drags on a 2800 px canvas). They are counted and reported, + // and only a runaway (more than HARD_FACTOR times the budget) cuts the window and before + // caches, never the whole-frame view's. #[test] - fn the_open_photos_views_are_never_trimmed() { + fn the_open_photos_views_are_trimmed_only_in_a_runaway_and_never_the_whole_frame() { let views = [(Slot::Main, 400 * MB), (Slot::Region, 500 * MB), (Slot::RegionBefore, 500 * MB), (Slot::Before, 100 * MB)]; - assert_eq!(stage_trim_order(&views, 100 * MB), vec![]); + assert_eq!(stage_trim_order(&views, 400 * MB), vec![], "1.5 GB against a 400 MB budget is a busy Before/After, not a runaway"); + // against a 100 MB budget (HARD_FACTOR 4 = 400 MB) it is: the before side goes first, then the window + assert_eq!(stage_trim_order(&views, 100 * MB), vec![Slot::RegionBefore, Slot::Before, Slot::Region]); let mut with_idle = views.to_vec(); with_idle.push((Slot::Hover, 50 * MB)); - assert_eq!(stage_trim_order(&with_idle, 100 * MB), vec![Slot::Hover]); + assert_eq!(stage_trim_order(&with_idle, 400 * MB), vec![Slot::Hover]); + // a whole-frame view of any size stays + assert_eq!(stage_trim_order(&[(Slot::Main, 9000 * MB)], MB), vec![]); } // Given caches of slots that hold nothing, they are never "trimmed" @@ -1200,6 +1228,7 @@ mod stage_budget_tests { let m = r.memory(); assert!(m["stageCaches"]["budgetBytes"].as_u64().unwrap() > 0, "{m}"); assert_eq!(m["stageCaches"]["trimmed"], 0); + assert!(m["stageCaches"]["sharedSourceBytes"].is_u64(), "the one device copy of a big original is reported: {m}"); } } diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 4d64dca2..4b15cd17 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -679,24 +679,27 @@ mod in_the_loupe { } } - // Given a stage budget far below what the open photo's views hold (a 2800 px canvas at 1:1), - // a slider drag still reuses them: they are not cleared every frame + // Given a stage budget below what the open photo's views hold (a 2800 px canvas at 1:1), a + // slider drag still reuses them: they are not cleared every frame #[test] fn a_drag_over_the_stage_budget_does_not_clear_the_open_views() { let (mut h, _) = detail(); - h.app.renderer.stage_budget_override = Some(1 << 20); h.app.ui.zoom = crate::state::Zoom::Percent(100.0); h.settle(SETTLE); + let held = |h: &Headless| { + let m = h.app.renderer.memory(); + (m["stageCaches"]["gpuBytes"].as_u64().unwrap() + m["stageCaches"]["cpuBytes"].as_u64().unwrap()) as usize + }; + let before = held(&h); + assert!(before > 3, "the views hold something ({before} bytes)"); + // a third of it is the budget: over it, but inside the runaway limit (four times) + h.app.renderer.stage_budget_override = Some(before / 3); h.app.session.begin_interaction("Exposure").unwrap(); for tick in 0..8 { h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": tick as f64 * 0.1}}), T); h.settle(SETTLE); } assert_eq!(h.app.renderer.memory()["stageCaches"]["trimmed"], 0); - assert!( - h.app.renderer.memory()["stageCaches"]["gpuBytes"].as_u64().unwrap() - + h.app.renderer.memory()["stageCaches"]["cpuBytes"].as_u64().unwrap() - > 1 << 20 - ); + assert!(held(&h) > before / 3, "and they are still there"); } } From 826ba9107532930630f846fb64a1ffe80931b281 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 18:48:20 -0400 Subject: [PATCH 38/39] #323: docs: measured drag times, the Before/After memory, what stays canvas-sized Co-Authored-By: Claude Sonnet 5.5 --- docs/control-protocol.md | 2 +- docs/gpu-pipeline.md | 2 +- docs/parity.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7d9fa04b..e87525bd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index c0a96957..ea7c8c64 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -212,7 +212,7 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo developed image in ~50 ms. ## Memory (M5.6) -A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it) and given back when the app idles (issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). +A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles (issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). - `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) diff --git a/docs/parity.md b/docs/parity.md index 826b8b88..7104b4ad 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -185,7 +185,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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 10–20 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); 20 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 render the whole frame at the size it is drawn, as before windows. 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 | +| 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 | | 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 | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | @@ -480,7 +480,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, not trimmed), thumbnail cache size in Settings | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | | LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | From eab88ea30c72a0aded9eb95df5124a1931109b8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:16:01 +0000 Subject: [PATCH 39/39] Follow-up to #351: drag drafts without a window stay canvas-sized; de/es/ru hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - With no window (a mask or point-colour overlay, Visualize Spots, soft proofing, a window refused for a far read) the loupe renders the whole frame at the size it is drawn. While a slider drags that meant the photo's every pixel each draft frame at 1:1 and beyond (24 MP per tick where 2.5 MP was rendered before #351; mask overlays are on by default). A drag now drafts at most the canvas size, as the windowed path does; at rest the zoomed frame is rendered as before. - The Preview size hint in German, Spanish and Russian; the replaced 'Largest long edge…' text removed from those catalogs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/src/region.rs | 18 +++++++++++++++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index ad625ccb..e2bd2a73 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -129,6 +129,7 @@ "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatisches Verorten anhand eines GPS-Tracks: GPS-Orte für deine Fotos aus einem GPX-Track, abgeglichen nach Aufnahmezeit.", "Auto-Tag from Tracklog…": "Anhand eines GPS-Tracks automatisch verorten…", "Automatic": "Automatisch", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatisch rendert die Detailansicht in der angezeigten Größe, bis zu den eigenen Pixeln des Fotos. Eine Größe wählen, um sie zu begrenzen: kleiner ist schneller.", "Automatically Write Changes into XMP": "Änderungen automatisch in XMP schreiben", "Automatically write changes into XMP sidecars": "Änderungen automatisch in XMP-Begleitdateien schreiben", "Autumn Gold": "Herbstgold", @@ -741,7 +742,6 @@ "Label": "Farbmarkierung", "Landscape": "Landschaft", "Language": "Sprache", - "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Längste Kante beim Rendern der Detailansicht; größere Werte sind auf großen Bildschirmen schärfer, aber langsamer.", "Last": "Letzter", "Last GPU fallback": "Letzter GPU-Fallback", "Last commit": "Letzter Commit", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 052eeb96..d38b9ad6 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -112,6 +112,7 @@ "Auto-Tag from Tracklog": "Etiquetar automáticamente desde el registro de ruta", "Auto-Tag from Tracklog…": "Etiquetar automáticamente desde el registro de ruta…", "Automatic": "Automático", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automático muestra la vista Detalle al tamaño en que se ve, hasta los píxeles propios de la foto. Elegir un tamaño para limitarla: cuanto más pequeño, más rápido.", "Automatically Write Changes into XMP": "Escribir automáticamente los cambios en XMP", "Automatically write changes into XMP sidecars": "Escribir automáticamente los cambios en los archivos XMP complementarios", "Autumn Gold": "Oro otoñal", @@ -704,7 +705,6 @@ "Label": "Etiqueta", "Landscape": "Paisaje", "Language": "Idioma", - "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Lado largo máximo con el que se renderiza la vista Detalle; un valor mayor es más nítido en pantallas grandes, pero más lento.", "Last": "Último", "Last GPU fallback": "Último recurso alternativo de GPU", "Last commit": "Último commit", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 365a4bf2..94ca6ac6 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -91,6 +91,7 @@ "Auto-Tag from Tracklog": "Автопривязка по треку", "Auto-Tag from Tracklog…": "Автопривязка по треку…", "Automatic": "Автоматически", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "«Автоматически» отрисовывает детальный просмотр в показанном размере, но не больше собственных пикселей фото. Выберите размер, чтобы ограничить его: меньше — быстрее.", "Automatically Write Changes into XMP": "Автоматически записывать изменения в XMP", "Automatically write changes into XMP sidecars": "Автоматически записывать изменения в XMP-файлы", "Autumn Gold": "Осеннее золото", @@ -587,7 +588,6 @@ "Keywords you add appear here; ⌥1–⌥9 apply them.": "Добавленные вами ключевые слова появятся здесь; ⌥1–⌥9 применяют их.", "Landscape": "Пейзаж", "Language": "Язык", - "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "Максимальная длинная сторона, с которой рисуется режим «Детализация»; больше — чётче на больших экранах, но медленнее.", "Last": "Последний", "Last view": "Последний вид", "Lateral Chromatic Aberration": "Боковая хроматическая аберрация", diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index db170da4..8fe47f97 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -97,7 +97,11 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; if !v.windows { - let main_edge = settings.loupe_edge(drawn_long * scale, v.native_long, v.texture_side); + // the whole frame at the size it is drawn (an overlay or soft proof 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 main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; } // a user who chose a size above the preview source level (3840, 5120) means it; otherwise the @@ -277,6 +281,18 @@ mod tests { assert_eq!(drag.window_edge, still.window_edge); } + // Given no window (an overlay, soft proofing), the view at rest renders the whole frame at the + // size it is drawn, but a slider drag drafts at the canvas size like the windowed path + #[test] + 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 still = plan(&auto(), no_window(1.0)); + let drag = plan(&auto(), no_window(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:?}"); + } + // 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]