From caf61d41a539903f8d806e4b40d450c827203726 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 22:48:10 +0200 Subject: [PATCH] Re-thumbnail a photograph from its own edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thumbnail comes from the file's embedded preview, which is the camera's idea of the photograph and knows nothing about what has been done to it since. So a frame could be cropped, turned upright and pulled two stops back, and the grid would go on showing the original — making the library, where a photographer spends most of their time, the one view in which an edit is invisible. The render is the framed output, not the sensor: `output_size` is what a crop, a quarter turn, a flip and a straighten all act on, so a thumbnail taken from the raw frame would be the right pixels in the wrong shape and still the wrong way up. It is the same path an export takes, at a size the store wants rather than at full resolution, and always sRGB — this is a JPEG in a shard that syncs between devices and is drawn as a cell, not a file anyone is finishing. Both size classes are replaced. The store keys on the class, so refreshing only the one the grid happens to be drawing leaves the other holding the unedited preview, and a zoom across the boundary would show the edit undoing itself. Each is rendered rather than downscaled from the larger, which would be a second and worse resampler than the GPU has already applied. It runs on the way out of develop, after the sidecar write is queued and never instead of it — the edit is what must not be lost, and a render that failed must not take the save down with it. Two cases are worth the work: an edit made in this sitting, which `can-undo` records even when it ends back at neutral, and an image opened with an edit already in its sidecar and left untouched, whose cached thumbnail has never shown that edit at all. A neutral image nobody touched fails both and costs nothing. Not covered: a batch paste onto a selection, which deliberately never opens a session — there is no rendered frame to take a thumbnail from, and downloading forty RAWs to make forty is exactly what that path exists to avoid. --- ui/dr-ui/src/develop.rs | 29 ++++++++++ ui/dr-ui/src/library_ui.rs | 107 +++++++++++++++++++++++++++++++++++++ ui/dr-ui/src/presets.rs | 50 +++++++++++++++++ 3 files changed, 186 insertions(+) diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index e15be4d..0b062f4 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -694,6 +694,35 @@ impl DevelopSession { dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string()) } + /// TRACES: FR-CAT-9 + /// Render this edit small, for the grid's thumbnail. + /// + /// **The framed output, not the sensor.** `output_size` is what a crop, a + /// quarter turn, a flip and a straighten all act on, so a thumbnail taken + /// from the raw frame would show the grid a photograph the user no longer + /// has — the right pixels in the wrong shape, still the wrong way up. This + /// is the same path [`Self::render_for_export`] takes, at a size the store + /// wants instead of at full resolution. + /// + /// Always sRGB: this is going into a JPEG in a thumbnail shard that syncs + /// between devices and is drawn as a cell, not a file the user is + /// finishing. The wider spaces exist for export and mean nothing here. + /// + /// Returns width, height and RGBA8. + pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec), String> { + let (sw, sh) = self.demosaiced.size(); + let (fw, fh) = self.graph.output_size(sw, sh); + let (w, h) = fit(fw, fh, edge.max(1), edge.max(1)); + + let shader = self.graph.compose_for(dr_types::ColourSpace::Srgb); + self.adjust + .render(&self.demosaiced, &shader, w, h) + .map_err(|e| e.to_string())?; + + let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?; + Ok((rw, rh, pixels)) + } + /// The sensor's own dimensions, before framing. /// /// What a crop overlay needs: its handles are placed against the full diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 21e51a9..38a6451 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -2556,6 +2556,113 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class); } +/// TRACES: FR-CAT-9 | FR-DEV-6 +/// Replace a photograph's cached thumbnail with one rendered from its edit. +/// +/// # Why the grid cannot be left alone +/// +/// A thumbnail comes from the file's embedded preview, which is the camera's +/// idea of the photograph and knows nothing about what has been done to it +/// since. So a frame could be cropped, turned upright, and pulled two stops +/// back, and the grid would go on showing the original — the one view of a +/// library where an edit is least visible is the one the photographer spends +/// most of their time in. +/// +/// # Both classes, and why +/// +/// The store keys on the size class, so replacing only the one the grid +/// happens to be drawing at leaves the other holding the unedited preview — +/// and a zoom past the class boundary would show the edit undoing itself. +/// Each class is rendered separately because they are different sizes; a +/// downscale of the large one would be a second, worse resampler than the GPU +/// already applied. +/// +/// # Silent on failure +/// +/// The edit is saved to the sidecar by the caller before this runs, so nothing +/// here can lose work. A thumbnail that could not be re-rendered is a stale +/// cell, which the next scroll past it corrects from the store — worth a log +/// line and not worth an error in front of a photographer who has just +/// finished an image. +pub fn refresh_thumbnail( + window: &AppWindow, + ctl: &Rc, + remote_path: &str, + mut render: impl FnMut(u32) -> Result<(u32, u32, Vec), String>, +) { + // Where this photograph sits in the loaded window. It is always in it: the + // develop view is reached from a cell, and the guards on the scroll and + // geometry handlers stop the window moving while it is open. + let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else { + return; + }; + let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else { + // Nothing to key the store on. A photograph the scan recorded without + // a server file id cannot have a cached thumbnail either, so there is + // nothing here to correct. + return; + }; + let Some((_, session, _)) = ctl.session.borrow().clone() else { + return; + }; + + let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir( + &session.server, + &session.user_id, + )) { + Ok(s) => s, + Err(e) => { + log::warn!("re-thumbnailing {remote_path}: opening the store: {e}"); + return; + } + }; + + // What the grid is drawing at, so the cell can be corrected on screen + // rather than only on disk. + let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32); + + for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] { + let (w, h, rgba) = match render(class.edge()) { + Ok(r) => r, + Err(e) => { + log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); + continue; + } + }; + + match dr_thumbs::codec::encode_rgba(w, h, &rgba) { + Ok(bytes) => { + let thumb = dr_thumbs::Thumbnail { + width: w, + height: h, + bytes, + }; + if let Err(e) = store.put(file_id, class, &thumb) { + log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); + } + } + Err(e) => { + log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}"); + continue; + } + } + + if class == drawn { + // Straight into the model, so the edit is on the cell the moment + // the grid comes back rather than after a scroll evicts and + // refetches it. + let model = window.get_library_cells(); + if let Some(mut cell) = model.row_data(row) { + cell.thumbnail = to_slint_image(w, h, &rgba); + cell.has_thumb = true; + cell.unavailable = false; + model.set_row_data(row, cell); + } + record_class(ctl, row, class); + } + } +} + /// Note which size class a row's pixels came from. /// /// Silent about a row past the end: the model and this vector are rebuilt diff --git a/ui/dr-ui/src/presets.rs b/ui/dr-ui/src/presets.rs index d1c0a41..4cc1469 100644 --- a/ui/dr-ui/src/presets.rs +++ b/ui/dr-ui/src/presets.rs @@ -280,10 +280,60 @@ pub fn save_open_edit( }, }; library_ui::start_sidecar_writes(window, library, vec![write]); + + // TRACES: FR-CAT-9 + // And bring the grid's cell up to date, so the photograph the user + // just finished is the photograph they see in the library. + // + // After the sidecar write is queued, never instead of it: the edit + // is the thing that must not be lost, and a render that failed + // must not take the save down with it. + refresh_thumbnail_for(window, path, session, library); } } } +/// TRACES: FR-CAT-9 +/// Re-render the grid's thumbnail from the edit that is being saved. +/// +/// # When it is worth doing +/// +/// Two cases, and the second is the one that is easy to miss. +/// +/// An edit made in this sitting is the obvious one — `can-undo` is the app's +/// own record that something was changed, and it stays true for a change that +/// happened to end at neutral, which still needs the thumbnail putting back. +/// +/// The other is an image opened with an edit already in its sidecar and left +/// untouched. Nothing changed, so there is nothing to *re-*render — but the +/// cached thumbnail came from the file's embedded preview and has never shown +/// that edit at all. `is_neutral` is what distinguishes it: a graph that does +/// something, against a thumbnail that shows none of it. +/// +/// A neutral image nobody touched fails both and costs nothing, which is the +/// common case on a scroll through a library. +fn refresh_thumbnail_for( + window: &AppWindow, + path: &str, + session: &Rc>>, + library: &Rc, +) { + let worth_it = window.get_can_undo() + || session.borrow().as_ref().is_some_and(|s| !s.is_neutral()); + if !worth_it { + return; + } + + // The borrow is held across the renders, which is safe here and would not + // be if this were reachable from a Slint callback that also touches the + // session — it is not: the only caller is on the way out of the view. + let mut borrow = session.borrow_mut(); + let Some(open) = borrow.as_mut() else { + return; + }; + library_ui::refresh_thumbnail(window, library, path, |edge| open.render_thumbnail(edge)); +} + /// Load a stored edit into the open session and refresh the panel. /// /// Returns whether anything was applied, so the caller can skip a redraw for