From ea33301e6e142dbe7aa111c7f34b0848d259b393 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 16:59:53 -0400 Subject: [PATCH] #323: a drag keeps the original its superseded drafts decoded; textures convert faster A drag supersedes every request before it finishes, and the source a superseded draft had decoded was thrown away with it, so a window cut from a full-size original decoded it again for every tick. A view slot's superseded draft that is newer than what the view shows now also hands its source to the session (late results of other slots stay ignored, as before). Render results become textures with a plain copy for the opaque case instead of egui's per-pixel premultiplying conversion, on the UI thread. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/render.rs | 48 ++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 31a57530..a8470472 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -84,6 +84,18 @@ struct Queued { job: RenderJob, } +/// The texture of a render: its pixels as egui wants them. Renders are opaque, so the common case +/// is a plain copy; only a pixel with alpha pays for premultiplying. (The generic conversion, on +/// the UI thread, was most of the time a 5 MP zoom window took to show.) +pub(crate) fn color_image(img: &lightcraft_raster::Rgba8) -> egui::ColorImage { + let pixels = img + .data + .iter() + .map(|p| if p[3] == 255 { egui::Color32::from_rgb(p[0], p[1], p[2]) } else { egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) }) + .collect(); + egui::ColorImage { size: [img.width, img.height], source_size: egui::vec2(img.width as f32, img.height as f32), pixels } +} + /// Which stage caches to clear, in order, so that the rest fit `budget` bytes: the cheapest to /// rebuild and least reused first (hover, before, compare, second window, the zoom window), the /// open photo's own view last. Empty caches are never named. The budget is hard. @@ -509,7 +521,9 @@ impl Renderer { for (mut slot, r, ms) in finished { self.completed += 1; // Ignore obsolete pixels, failures and decoded sources, even for the same render key; - // an interactive view still takes a superseded draft that is newer than what it shows. + // an interactive view still takes a superseded draft that is newer than what it + // shows, and the source it decoded: a drag supersedes every request before it + // finishes, and a source thrown away with them is decoded again for every tick. if self.request_ids.get(&slot) != Some(&r.request_id) { let newer_draft = slot.is_view() && self.request_ids.contains_key(&slot) @@ -519,6 +533,7 @@ impl Renderer { if !newer_draft { continue; } + session.accept(&r); } else { self.request_ids.remove(&slot); session.accept(&r); @@ -558,7 +573,7 @@ impl Renderer { self.textures.remove(&Slot::ThumbQuick(id)); } let img = &rendered.image; - let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let color = std::sync::Arc::new(color_image(img)); let pixels = self.keep_pixels.then(|| color.clone()); let name = format!("{slot:?}"); match self.textures.get_mut(&slot) { @@ -1179,3 +1194,32 @@ mod stage_budget_tests { assert_eq!(m["stageCaches"]["trimmed"], 0); } } + +#[cfg(test)] +mod color_image_tests { + use super::*; + + // The texture a render becomes is what egui would make of its bytes, whatever the alpha + #[test] + fn the_fast_conversion_equals_eguis() { + let img = lightcraft_raster::Rgba8::from_fn(37, 29, |x, y| { + let a = match (x + y) % 5 { + 0 => 0, + 1 => 17, + 2 => 128, + _ => 255, + }; + [(x * 7) as u8, (y * 9) as u8, (x * y) as u8, a] + }); + let want = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()); + let got = color_image(&img); + assert_eq!(got.size, want.size); + assert_eq!(got.pixels, want.pixels); + } + + #[test] + fn an_empty_image_is_an_empty_texture() { + let img = lightcraft_raster::Rgba8::new(0, 0); + assert!(color_image(&img).pixels.is_empty()); + } +}