diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index e231c47c..b3d685f5 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -64,7 +64,7 @@ impl WireJob { /// `view`: the slot name, for jobs that keep a stage cache. pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob { let max_edge = match &job.source { - SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge, + SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge, SourceRef::Loaded(img) => img.image.width.max(img.image.height), SourceRef::Smart { .. } => 2560, }; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index c54cd8d7..a14f6029 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -223,6 +223,13 @@ impl DecodedSource { #[derive(Clone)] pub enum SourceRef { Loaded(Box), + /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of + /// the photo open in the loupe is cut from its preview-level source instead of decoding the + /// file again (a second or more for a 64 MP raw). + Fit { + from: Box, + max_edge: usize, + }, Demo { scene: Box, max_edge: usize, @@ -251,6 +258,13 @@ impl SourceRef { pub fn load_source(&self) -> Result { let image = match self { SourceRef::Loaded(a) => return Ok((**a).clone()), + SourceRef::Fit { from, max_edge } => { + if from.image.width.max(from.image.height) <= *max_edge { + return Ok((**from).clone()); + } + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); + } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, SourceRef::File { path, max_edge, loader, fallback, denoise } => { let plain = || match loader { @@ -625,6 +639,15 @@ impl MediaCache { if cached && let Some(a) = self.get_source(p.id, level) { return SourceRef::Loaded(Box::new(a)); } + // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not + // with a denoised picture, which the thumbnail would lose + if cached + && level == SourceLevel::Thumb + && self.denoise.spec(p.id).is_none() + && let Some(big) = self.get_source(p.id, SourceLevel::Preview) + { + return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; + } // Procedural scenes have a nominal size: "full" is that size, not unbounded. let max_edge = level.max_edge().min(match (&p.source, level) { (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, @@ -1987,6 +2010,24 @@ mod tests { assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); } + /// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here + /// one nothing can decode) is not read again. + #[test] + fn a_thumbnail_is_cut_from_the_decoded_preview() { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); + let job = s.thumb_job(p.id, 512).unwrap(); + assert!(matches!(job.source, SourceRef::Fit { .. })); + let r = job.run(); + let thumb = r.rendered.as_ref().unwrap(); + assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); + s.accept(&r); + let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); + assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); + } + #[test] fn has_source_follows_what_the_session_accepted() { let mut s = crate::Session::with_demo(); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 1f61a51e..e5af3a35 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -479,6 +479,11 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: /// [`lightcraft_engine::media::Session::embedded_thumb_is_final`]), which for a folder of raws on a /// network share is the difference between reading every file whole and reading a preview each. pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) { + // A slider drag changes the photo every frame: its thumbnail follows once the drag ends, so its + // renders (a raw decode the first time) don't compete with the loupe's drafts. + if app.session.interaction.as_ref().is_some_and(|i| i.photo == id) && app.renderer.thumb(id).is_some() { + return; + } let bucket = lightcraft_engine::media::thumb_bucket(size); if let Some(photo) = app.session.catalog.photo(id) && app.renderer.thumb_current(photo, bucket, priority) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 03ac1894..c78d2caf 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -1015,6 +1015,26 @@ mod thumbnail_tests { assert!(work.borrow().jobs.is_empty()); } + #[test] + fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); + for v in [0.1, 0.2, 0.3] { + app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); + assert!(work.borrow().jobs.is_empty()); + app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); + } + #[test] fn unchanged_queued_frames_build_no_jobs() { let (mut app, _, _) = fixture(); diff --git a/docs/whats-new.md b/docs/whats-new.md index 5abfd3f0..e016385f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-13 + +### Slider drags on large raws +- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you + let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's + previews for up to a second. +- A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file + again. + ## 2026-10-11 ### HDR and panorama merges of CR2 keep their colour