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:
parent
ab438b05ca
commit
bc2cbf41db
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -40,6 +40,26 @@ preview is never lost to a damaged header.
|
|||
[profile] preview …/DSCF9492.RAF: read 5857257 of 59277632 bytes (header 5857257, window 5857109)
|
||||
```
|
||||
|
||||
## The grid thumbnail is the embedded JPEG too
|
||||
|
||||
A 512 px grid thumbnail of an unedited raw now keeps the camera's own JPEG instead of rendering the
|
||||
sensor behind it. The grid showed that JPEG first in any case and then queued a decode of the whole
|
||||
raw to replace it — for a folder of 149 Fuji RAFs that is 8.3 GB of traffic, 45 s a photo at
|
||||
1.2 MB/s, 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).
|
||||
|
||||
- `Session::embedded_thumb_is_final(photo)` says whether the photo is an unedited raw whose camera
|
||||
JPEG the viewer is showing (the same "unedited raw" gate as the preview path: `has_import_look`).
|
||||
- `grid::skips_the_real_render(embedded_is_final, from_embedded)` is the decision, unit-tested:
|
||||
a **cached render** as the stand-in, an **edited photo** and a **raw with no embedded JPEG** all
|
||||
still render, because their thumbnail must show the pipeline's output rather than the camera's.
|
||||
- The thumbnail key still changes with the settings hash, so editing a photo re-renders its
|
||||
thumbnail through the ordinary background path.
|
||||
|
||||
Measured on the real folder: idle for a minute, the app read 12 previews (**26.9 MB**) and decoded
|
||||
**zero** raws for thumbnails, against 638 MB of whole-file reads for the same 12 photos before. The
|
||||
render queue stayed empty.
|
||||
|
||||
## Tests
|
||||
|
||||
- `crates/raw/src/preview.rs`: the header-only range matches `embedded_preview` byte for byte; a
|
||||
|
|
|
|||
Loading…
Reference in New Issue