diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 0474e2a0..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,6 +112,7 @@ impl WireJob { space: lightcraft_engine::pipeline::OutputSpace::Srgb, depth: lightcraft_engine::pipeline::OutputDepth::U8, proof: None, + window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }), } } @@ -348,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(); @@ -393,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/engine/src/media.rs b/crates/engine/src/media.rs index 2d5274a3..1ed89e47 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>; @@ -389,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; @@ -718,9 +741,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) @@ -881,6 +908,69 @@ 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). `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, + 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, 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)?; + 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) + .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 +1308,110 @@ 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()); + } + + // 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); + } + + // 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()); + } + + // 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 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/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/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..60ffd806 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -414,6 +414,48 @@ 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; + +/// 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(); + } + // 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())); + 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 @@ -552,8 +594,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 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) => c.source(src, upload), + Some(c) if src.data.len() * 12 <= RETAIN_SOURCE_BYTES => c.source(src, upload), + Some(_) => shared_source(src, upload), None => Arc::new(upload()), }; sample(&mut cx, src, src_buf, &plan) @@ -579,7 +624,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 +699,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 +721,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 +769,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..cb6e7c02 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -660,3 +660,102 @@ 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); +} + +/// 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. +// (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; + } + 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); +} + +/// …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); +} 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..c99bde45 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,30 @@ 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 { + 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..057fb42f 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,17 @@ 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, + /// A windowed render works on a larger window when spots reach into it (see + /// [`spots::window_for_reads`]); 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. +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) { @@ -309,10 +344,39 @@ 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 (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 mut keep = 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)); + } + // (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_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 }); + } + (frame.window(full_w, full_h, work), work.w, work.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 +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 } + 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). @@ -401,7 +465,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) } }; @@ -410,7 +474,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 }); @@ -424,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 } } @@ -510,3 +585,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/masks.rs b/crates/pipeline/src/masks.rs index 96911e8b..aae261c5 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(); @@ -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 f9edff72..c50bcf79 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -106,6 +106,135 @@ 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 = 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 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, + full_w: usize, + full_h: usize, + 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), + _ => None, + }) + .flatten() + .filter(|st| st.auto_mask) + .collect(); + if spots.is_empty() && autos.is_empty() { + 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 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(); + // 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 dabs = crate::masks::brush_dabs(st, frame, full_w, full_h); + let r = dabs.r; + if dabs.dabs.len() > MAX_DABS_CONSIDERED { + continue; + } + 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 { + 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 { + 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); + 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. pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) { if spots.is_empty() { @@ -248,4 +377,95 @@ mod tests { assert!(img.get(25, 50)[0] > 0.8); 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() } + } + + #[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_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_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_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_reads(&with_spots(vec![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_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_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_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); + // 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)); + } } diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs new file mode 100644 index 00000000..0ed5e81e --- /dev/null +++ b/crates/pipeline/src/tests_window.rs @@ -0,0 +1,343 @@ +//! 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) { + 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 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]); + 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); + } +} + +/// 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); +} + +// ---- 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}"); +} + +// 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); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e05e264a..2a472186 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 53e09397..c0986c19 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/ja.json b/crates/ui-egui/locales/ja.json index c53d7d2a..31c61a7a 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 63362e16..03ae0773 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/ru.json b/crates/ui-egui/locales/ru.json index 16e01fe7..530cdfcb 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/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 53b85abc..cea313d0 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 2a34ed96..597ecbf9 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/control.rs b/crates/ui-egui/src/control.rs index 9f1c0df1..cfcb287f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -92,7 +92,15 @@ 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 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, "notices": app.notices, diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 484828e3..206d75b1 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, @@ -107,12 +107,14 @@ 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, time: f64, frames: u64, - events: Vec, + pub(crate) events: Vec, control: Sender, quit: bool, /// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host). @@ -130,6 +132,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, @@ -155,6 +158,7 @@ impl Headless { 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.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized); + 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| { @@ -1894,8 +1898,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 5df63661..aeab2794 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; pub mod merge; pub mod panels; pub mod pick; +pub mod region; pub mod render; pub mod shortcuts; pub mod softpaint; @@ -53,6 +54,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; @@ -62,6 +65,8 @@ mod tests_switch_library; mod tests_titlebar; #[cfg(test)] mod tests_unsaved; +#[cfg(test)] +mod tests_zoom; use std::sync::mpsc::{Receiver, Sender}; @@ -211,6 +216,16 @@ 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, + /// 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, + /// (photo, look, window frame size) a window was refused for: it reads more than one render holds. + pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>, + pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>, /// Photo Merge dialog previews and background merges. pub merge: merge::MergeState, /// An import in progress (the import review dialog's batches). @@ -279,6 +294,11 @@ impl LightcraftApp { widgets: vec![], gesture: None, loupe_shown: None, + region_view: None, + region_before_view: None, + size_hold: Default::default(), + window_refused: None, + region_tiles: Default::default(), merge: merge::MergeState::default(), import: None, scan: None, diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 55c205d5..6ecdb169 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -361,7 +361,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 891a4201..5ca01cb4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -961,7 +961,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/compare.rs b/crates/ui-egui/src/panels/compare.rs index fd0d3305..77ee4242 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) { @@ -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 6c1b4550..c9fb643d 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,20 @@ 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] { + 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 }; @@ -200,6 +214,98 @@ 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, + /// 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. +#[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?; + // 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); + // 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))?; + // 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 { + 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 }; + 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(|(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) +} + +/// 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(&(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); + 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(); @@ -230,8 +336,8 @@ 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]; + 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); @@ -283,8 +389,35 @@ 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 drawn_long = target_rect.width().max(target_rect.height()) * ppp; + // the whole frame at about canvas size; the zoomed part of it is a window render (below) + let look = d.hash64(); + // (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); + 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 + 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 }; let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); @@ -293,7 +426,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) @@ -312,8 +445,9 @@ 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 = (nr.width().max(nr.height()) * ppp).min(max_edge) 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), 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); @@ -329,10 +463,67 @@ 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); } + // zoomed past what the whole-frame render holds: also render the part on screen, at no more + // than 100 % (the GPU magnifies beyond that), for each side that is shown. 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 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() { + 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(|(b, _), _| *b != before); + app.renderer.release(slot); + } + } 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) @@ -398,22 +589,36 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } app.loupe_shown = Some((id, shown)); + // 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); + } + // …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 { + // (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 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/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/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index d87d64ba..db5ca5a1 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/region.rs b/crates/ui-egui/src/region.rs new file mode 100644 index 00000000..8fe47f97 --- /dev/null +++ b/crates/ui-egui/src/region.rs @@ -0,0 +1,546 @@ +//! 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; +/// 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 = 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. +#[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, + /// 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). + /// + /// 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) + } +} + +/// 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 +} + +/// 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) +} + +/// 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, + /// 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 +/// 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 }; + if !v.windows { + // 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 + // original is not decoded for the whole-frame render + let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize }; + let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap); + let main_edge = edge_at(scale); + let frame_edge = settings.window_frame_edge(drawn_long, v.native_long); + // a user who capped the preview size has chosen speed over sharpness at fit + let fit = drawn_long <= canvas_long * 1.05; + // (against the undrafted whole-frame render: a drag must not switch the window off and on) + let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); + LoupePlan { main_edge, window_edge } +} + +/// 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 + } +} + +/// 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 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). +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 +} + +/// 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, +/// 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; + } + 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); + // 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::*; + 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, windows: true } + } + + 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 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] + 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 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() { + let p = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(2800.0, 2800.0, 6000) }); + 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); + } + + // 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); + } + + // 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() { + 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 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), 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:?}"); + 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), 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), 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), 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), 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)); + } + + // 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), + 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"); + 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 + #[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), 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), MAX_SPAN), None); + } + 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), MAX_SPAN).unwrap(); + assert!(w.w <= MAX_SPAN && w.h <= MAX_SPAN); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 5c1c1bbb..3f0906a2 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -30,6 +30,11 @@ 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 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, @@ -79,6 +84,74 @@ 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 } +} + +/// 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 idle_rank = |s: Slot| match s { + Slot::Hover => 0, + Slot::Compare(_) => 2, + Slot::Second => 3, + _ => 5, + }; + 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 out = Vec::new(); + 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 (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; + /// 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. @@ -128,6 +201,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)] + 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)>, @@ -137,6 +215,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::RegionBefore | 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) }; @@ -173,6 +259,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, } @@ -219,6 +308,12 @@ impl Renderer { } true } + /// 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). pub fn set_offload(&mut self, offload: Box) { self.offload = Some(offload); @@ -247,8 +342,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); @@ -429,6 +523,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 = { @@ -450,9 +545,11 @@ 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 = 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) @@ -460,11 +557,12 @@ impl Renderer { if !newer_draft { continue; } + session.accept(&r); } else { 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) { @@ -499,7 +597,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) { @@ -567,6 +665,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(); @@ -574,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}, + "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}, }) } @@ -1025,3 +1143,120 @@ 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 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_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 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_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, 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, 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" + #[test] + fn empty_caches_are_left_alone() { + 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) + #[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!(r.stages[&Slot::Main].bytes() > 0, "the open photo's view is never trimmed"); + r.trim_stages(); + assert_eq!(r.stages_trimmed, 1, "and nothing is 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); + assert!(m["stageCaches"]["sharedSourceBytes"].is_u64(), "the one device copy of a big original is reported: {m}"); + } +} + +#[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()); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b57be4b9..17f5c58b 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, + /// 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, /// 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,66 @@ 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; + +/// 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; + +/// 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 (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, 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) + } + + /// 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, 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, texture_side: usize) -> usize { + 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, 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. + 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 +745,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..4b15cd17 --- /dev/null +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -0,0 +1,705 @@ +//! 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}; + +/// 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() } +} + +// 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, 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, 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, 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 +// 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, 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, 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, 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, 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, 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 +#[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, 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, 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 +#[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) { + 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, 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); + 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 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_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 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 + #[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)); + } + + 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 fit view, the whole-frame render is all there is: no second render, no original decoded + #[test] + 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 + // 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); + } + + // 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"); + } + + // 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"); + } + + // 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); + } + } + + // 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"); + } + + 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()); + } + + // 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 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.is_pending(Slot::Region), "no window was asked for during the pinch"); + 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); + } + + // 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); + } + + /// `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 (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(); + 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 {}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], + 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; + 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(); + 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"); + } + + /// 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_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 (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 `\` 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); + // (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); + 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] + 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] + 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::>()); + } + + // 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"); + } + } + + // 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.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!(held(&h) > before / 3, "and they are still there"); + } +} 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}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 80fd01a5..f1208206 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`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes) | +| `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`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes) | | `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 12d6b0b3..5eb0ce1d 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -181,7 +181,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. @@ -218,6 +222,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; 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 c41c3f36..90dc8a74 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 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): @@ -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). 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 | @@ -479,7 +481,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 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 |