LR-IMP-PREVIEW-READ: an unedited raw's grid thumbnail is its embedded JPEG

The grid already showed the camera's embedded preview first, then queued a render of the whole raw
to replace it. For a 512 px thumbnail that render decodes 50 of a 56 MB Fuji RAF's bytes for a
picture the 4 416 × 2 944 embedded JPEG already covers — 45 s a photo on the 1.2 MB/s share this was
measured on, 8.3 GB of traffic for a 149-photo folder, and a queue that delayed the render the user
was actually waiting for (measured: the first loupe render of a cold photo finished 174 s after the
click, most of it spent behind thumbnails). Idle for a minute afterwards, the same folder read 12
previews (26.9 MB) and decoded zero raws for thumbnails, against 638 MB of whole-file reads before.

- `Session::embedded_thumb_is_final` answers whether a photo is an unedited raw whose camera JPEG the
  viewer is showing — the same `has_import_look` gate the preview path uses.
- `grid::skips_the_real_render` is the decision (unit-tested): a cached render as stand-in, an edited
  photo and a raw without an embedded JPEG all still render, because their thumbnail must show the
  pipeline's output rather than the camera's.
- `Renderer::remember_embedded_thumb` records the quick render's key and the bucket, and
  `thumb_quick_is_final` / `thumb_quick_pending` let the per-frame grid request stop at the embedded
  preview instead of rebuilding the render job (and its cache key) every frame.
- The thumbnail key still follows the settings hash, so editing a photo re-renders its thumbnail
  through the ordinary background path.

`docs/raw-preview-reads.md` and the `LR-IMP-PREVIEW-READ` parity row record the behaviour and the
numbers.

Co-Authored-By: DeepSeek V3.2 <noreply@deepseek.com>
This commit is contained in:
goujicheng
2026-10-11 09:39:32 +08:00
co-authored by DeepSeek V3.2
parent ab438b05ca
commit bc2cbf41db
6 changed files with 141 additions and 12 deletions
+17
View File
@@ -1212,6 +1212,23 @@ impl crate::Session {
}
}
/// Whether `p`'s thumbnail can be the camera's own JPEG, so a frontend can skip the sensor
/// decode behind it.
///
/// The grid shows this JPEG first in any case ([`QuickJob`]: "grid thumbnails of unedited raws
/// likewise start from the embedded preview"), and then paid for a render of the raw to replace
/// it. That render decodes the whole sensor — 50 of a 56 MB Fuji RAF's bytes — for a 512 px
/// thumbnail the 4 416 × 2 944 embedded JPEG already covers, and on a share at 1.2 MB/s it is
/// 45 s a photo: a 149-photo folder spent 8.3 GB of traffic on thumbnails, and the queue behind
/// it delayed the render the user was waiting for.
///
/// Only unedited raws answer `true`: any edit must be rendered, and `has_import_look` is what
/// says the photo still looks the way it did at import. A photo the user edits re-renders
/// through the ordinary path, because its thumbnail key (settings hash) changes.
pub fn embedded_thumb_is_final(&self, p: &Photo) -> bool {
self.embedded_of(p).is_some()
}
/// The camera's embedded preview of a raw file, for the face scan. Faces are read from the unedited picture, so the
/// photo's edits do not matter, and the camera's own JPEG (oriented, display-ready) costs a fraction of decoding the
/// raw. `None` for anything else, and where the host installed no loader.
+35 -4
View File
@@ -444,8 +444,10 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids:
}
/// Request a grid/filmstrip thumbnail at `priority`. An unedited raw without a thumbnail texture
/// first gets a stand-in (its cached thumbnail, else its embedded camera preview), and its real
/// render then follows in the background.
/// gets its embedded camera preview, and the render of the raw behind it follows in the background
/// — except where that render would only redraw what the embedded JPEG already shows (see
/// [`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) {
let bucket = lightcraft_engine::media::thumb_bucket(size);
if let Some(photo) = app.session.catalog.photo(id)
@@ -453,19 +455,35 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority
{
return;
}
// The camera's embedded preview is this photo's thumbnail (unedited raw): once its quick render
// has arrived there is nothing left to ask for. Without this the grid would rebuild the render
// job — and its cache key — on every frame, which is what the embedded-thumbnail record settles.
if let Some(photo) = app.session.catalog.photo(id)
&& (app.renderer.thumb_quick_is_final(photo, bucket, priority) || app.renderer.thumb_quick_pending(photo, bucket))
{
return;
}
let Some(job) = app.session.thumb_job(id, size) else { return };
#[cfg(test)]
{
app.renderer.thumb_jobs_built += 1;
}
let quick = if app.renderer.textures.contains_key(&Slot::Thumb(id)) { None } else { app.session.quick_thumb_job(&job) };
// A cached render is a stand-in for whatever comes next; an embedded camera preview of an
// unedited raw is the picture itself, so no sensor decode is scheduled behind it.
let embedded_is_final =
quick.is_some() && app.session.catalog.photo(id).is_some_and(|p| skips_the_real_render(app.session.embedded_thumb_is_final(p), true));
if let Some(photo) = app.session.catalog.photo(id) {
app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some());
if embedded_is_final {
app.renderer.remember_embedded_thumb(photo, bucket, job.key);
} else {
app.renderer.remember_thumb(photo, bucket, job.key, quick.is_some());
}
}
match quick {
Some(q) => {
app.renderer.request_quick(Slot::ThumbQuick(id), q, priority + 1);
if !app.renderer.is_pending(Slot::ThumbQuick(id)) {
if !embedded_is_final && !app.renderer.is_pending(Slot::ThumbQuick(id)) {
app.renderer.request(Slot::Thumb(id), job, BACKGROUND_THUMB_PRIORITY);
}
}
@@ -473,6 +491,19 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority
}
}
/// Whether a photo's grid thumbnail is already the picture it should show, so the render of the raw
/// behind it is skipped. `embedded_is_final` is the engine's answer
/// ([`lightcraft_engine::media::Session::embedded_thumb_is_final`]: the photo is an unedited raw
/// whose camera JPEG the viewer is showing) and `from_embedded` says the stand-in actually came from
/// the embedded preview rather than a cached render of the file.
///
/// Skipping matters where the file is expensive to read: decoding a 56 MB Fuji RAF to draw a 512 px
/// thumbnail — which the 4 416 × 2 944 embedded JPEG already covers — cost 45 s a photo over a
/// 1.2 MB/s share, and the queue it built delayed the render the user was waiting for.
pub(crate) fn skips_the_real_render(embedded_is_final: bool, from_embedded: bool) -> bool {
embedded_is_final && from_embedded
}
/// Rendered thumbnails replacing embedded previews: after everything on screen.
pub const BACKGROUND_THUMB_PRIORITY: u32 = 3;
+51 -7
View File
@@ -174,6 +174,8 @@ pub struct Renderer {
/// Last thumbnail inputs; weak identities do not retain photo histories or force deep clones.
/// (photo, size bucket, job key, whether the request went through an embedded stand-in)
thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64, bool)>,
/// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it).
embedded_thumbs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64)>,
pool: JobPool<Slot, RenderResult>,
/// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet.
offload: Option<Box<dyn RenderOffload>>,
@@ -251,6 +253,7 @@ impl Renderer {
preview_generation: None,
pool: JobPool::new(threads),
thumb_inputs: HashMap::new(),
embedded_thumbs: HashMap::new(),
offload: None,
queue: Vec::new(),
seq: 0,
@@ -295,6 +298,30 @@ impl Renderer {
}
/// Remember the inputs of the thumbnail just requested (`quick`: through a stand-in).
/// Whether the camera's embedded preview *is* this photo's thumbnail: its quick render arrived
/// (so `Slot::ThumbQuick` holds the picture) and no edit has moved the photo on to a settings
/// hash of its own. The grid stops here instead of decoding the raw behind a thumbnail the
/// embedded JPEG already covers — see `grid::skips_the_real_render`.
pub fn thumb_quick_is_final(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool {
let Some((old, size, key)) = self.embedded_thumbs.get(&photo.id) else { return false };
old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::ThumbQuick(photo.id), *key, priority)
}
/// Whether this photo's embedded-preview thumbnail is being fetched right now: the grid has
/// asked, and neither the picture nor a render behind it is owed.
pub fn thumb_quick_pending(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize) -> bool {
let Some((old, size, _, quick)) = self.thumb_inputs.get(&photo.id) else { return false };
old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && *quick && self.pending.contains_key(&Slot::ThumbQuick(photo.id))
}
/// Remember that the camera's embedded preview is this photo's final thumbnail, so the grid's
/// per-frame requests stop at it (the quick render's key, which [`Self::thumb_quick_is_final`]
/// compares against).
pub fn remember_embedded_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64) {
self.remember_thumb(photo, bucket, key, true);
self.embedded_thumbs.insert(photo.id, (Arc::downgrade(photo), bucket, key));
}
pub fn remember_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64, quick: bool) {
if self.thumb_inputs.len() >= 1024 && !self.thumb_inputs.contains_key(&photo.id) {
self.thumb_inputs.retain(|_, (p, _, _, _)| p.strong_count() > 0);
@@ -1026,8 +1053,9 @@ mod thumbnail_tests {
#[test]
fn raw_quick_path_frames_build_no_jobs() {
// an unedited raw: the grid shows its embedded preview first (held here until released),
// then queues the real thumbnail behind everything on screen
// an unedited raw: the grid shows its embedded preview (held here until released) and stops
// there — the camera JPEG at 4 416 × 2 944 already covers a 512 px thumbnail, so decoding the
// whole raw behind it only costs the file read (45 s a photo on a 1.2 MB/s share)
let gate = Arc::new(std::sync::Mutex::new(()));
let held = gate.lock().unwrap();
let mut s = Session::new();
@@ -1055,14 +1083,30 @@ mod thumbnail_tests {
std::thread::sleep(std::time::Duration::from_millis(5));
app.renderer.poll(&ctx, &mut app.session);
}
// the stand-in is shown: one more job queues the real thumbnail at background priority,
// and the grid asking at its own (higher) priority every frame does not rebuild it
// the embedded preview is the thumbnail: no real render is queued behind it, and the grid
// asking every frame neither rebuilds the job nor re-requests the preview
for _ in 0..180 {
request(&mut app);
}
assert_eq!(app.renderer.thumb_jobs_built, 2);
assert_eq!(app.renderer.queue.len(), 1);
assert_eq!(app.renderer.pending.get(&Slot::Thumb(PhotoId(1))).map(|p| p.1), Some(crate::panels::grid::BACKGROUND_THUMB_PRIORITY));
assert_eq!(app.renderer.thumb_jobs_built, 1, "an unedited raw's embedded preview is the thumbnail");
assert!(app.renderer.queue.is_empty(), "nothing is decoded behind it");
assert!(!app.renderer.is_pending(Slot::Thumb(PhotoId(1))));
// an edit takes the embedded preview out of the picture: the real thumbnail is rendered then
let mut edited = (*photo()).clone();
edited.kind = lightcraft_catalog::MediaKind::Raw;
edited.develop = Arc::new({
let mut d = edited.camera_defaults();
d.light.exposure = 0.5;
d
});
let (mut app, _, _ctx) = fixture_with(Session::new(), edited);
app.renderer.set_offload(Box::new(Blocked));
for _ in 0..180 {
request(&mut app);
}
assert_eq!(app.renderer.thumb_jobs_built, 1);
assert!(app.renderer.is_pending(Slot::Thumb(PhotoId(1))) || !app.renderer.queue.is_empty(), "an edited raw renders its thumbnail");
}
#[test]
+17
View File
@@ -155,3 +155,20 @@ fn unloaded_thumbnails_show_their_selection() {
}
assert!(checked >= 3, "checked {checked}");
}
/// A folder of unedited raws on a slow share: the grid must not decode every raw to draw a 512 px
/// thumbnail the embedded camera JPEG already covers. Measured on a 56 MB Fuji RAF over a 1.2 MB/s
/// share, that decode is 45 s a photo, and 149 of them queued in front of the render the user asked
/// for. The embedded JPEG stays a *stand-in* — replaced by a real render — whenever it is not the
/// picture the photo should show.
#[test]
fn an_unedited_raws_thumbnail_skips_the_sensor_decode() {
use crate::panels::grid::skips_the_real_render;
// unedited raw, embedded camera JPEG on screen: nothing behind it to render
assert!(skips_the_real_render(true, true));
// a cached render of the file was the stand-in, even for an unedited raw: the real one follows
assert!(!skips_the_real_render(true, false));
// edited photo, or a raw with no embedded JPEG: render it
assert!(!skips_the_real_render(false, true));
assert!(!skips_the_real_render(false, false));
}