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