M5.4a: instant first view — cached view render / embedded raw preview, then the full render

- engine: QuickJob — what to show the moment a photo opens: the loupe's last render for the
  current settings (kept by Session::loupe_job under a size-independent "view" key in the
  preview cache, memory + disk with a library; the disk JPEG is written off the render
  thread via PreviewCache::put_deferred), else the embedded camera JPEG of a raw that still
  has its import look (import::has_import_look), else a thumbnail-level render.
- Grid / filmstrip: an unedited raw without a thumbnail shows its cached thumbnail, else its
  embedded preview (Slot::ThumbQuick) at once; the real rendered thumbnail follows at
  background priority (same cache keys, so it still ends up matching the develop render;
  edited photos render directly).
- Loupe (detail, compare, filmstrip selection): Slot::Preview holds the stand-in until
  Slot::Main has the photo; the full render then replaces it in place (no blank frame).
- files::embedded_preview_srgb + PreviewLoader hook (desktop/CLI/MCP and web).
- ui.inspect → loupe {photo, source: render|cached|embedded|small|thumb|none, pending}.

Real app (24 MP raws, loaded machine): first image 15–90 ms (thumbnail, then embedded
preview), full render 0.2–0.5 s (was 1.0–2.4 s with nothing shown before it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 09:18:50 -04:00
co-authored by Claude Opus 5.5
parent ec6637f238
commit 5fd5384bfb
11 changed files with 380 additions and 43 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ impl Bench {
let idle = app.renderer.queued() == 0;
match self.phase {
Phase::Idle => {
let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count();
if idle && thumbs > 0 && self.frames > 3 {
self.thumbs_done_ms = Some(now - self.start_ms);
let first = app.session.visible().first().copied();
+3
View File
@@ -74,6 +74,9 @@ impl MemStore {
let (l, p) = self.hooks();
session.media.file_loader = Some(l);
session.media.file_probe = Some(p);
let s = self.clone();
session.media.preview_loader =
Some(Arc::new(move |path: &str, max_edge: usize| lightcraft_engine::files::embedded_preview_srgb(&s.get(path)?, max_edge)));
}
}
+56 -1
View File
@@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo;
use lightcraft_raster::Rgb32f;
use lightcraft_raster::resample::{Filter, fit};
use crate::media::{FileLoader, FileProbe, ProbeInfo};
use crate::media::{FileLoader, FileProbe, PreviewLoader, ProbeInfo};
fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
@@ -287,6 +287,24 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
}
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
}
let o = preview_orientation(bytes, d.orientation);
Some(d.to_srgb8().oriented(o))
}
/// Filesystem-backed embedded-preview hook (native).
pub fn fs_preview_loader() -> PreviewLoader {
Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge))
}
/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead.
pub fn fs_hooks() -> (FileLoader, FileProbe) {
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| {
@@ -306,6 +324,7 @@ impl crate::Session {
let (l, p) = fs_hooks();
self.media.file_loader = Some(l);
self.media.file_probe = Some(p);
self.media.preview_loader = Some(fs_preview_loader());
self
}
}
@@ -341,4 +360,40 @@ mod tests {
// no preview at all: a clear error, not a panic
assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
}
#[test]
fn quick_jobs_show_embedded_previews_then_cached_renders() {
use crate::media::QuickSource;
let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.cr3");
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
let mut s = crate::Session::new().with_fs();
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
// loupe: an unedited raw opens on its embedded preview…
let q = s.quick_view_job(id, 1600, true).unwrap().run();
assert_eq!(q.quick, Some(QuickSource::Embedded));
let img = q.rendered.unwrap().image;
assert_eq!((img.width, img.height), (96, 64));
// …and once the loupe has rendered it, on that render (any size)
let full = s.loupe_job(id, 48, 32, true).unwrap().run();
assert!(full.rendered.is_ok() && full.quick.is_none());
let q = s.quick_view_job(id, 1600, true).unwrap().run();
assert_eq!(q.quick, Some(QuickSource::Cached));
assert_eq!(q.rendered.unwrap().image.width, 48);
// grid: embedded first, then the real thumbnail (final, with the thumbnail job's key)
let job = s.thumb_job(id, 128).unwrap();
let q = s.quick_thumb_job(&job).unwrap().run();
assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key));
drop(job.clone().run());
assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached));
// an edited raw: no embedded stand-in (it wouldn't show the edit)
s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap();
s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap();
let job = s.thumb_job(id, 128).unwrap();
assert!(s.quick_thumb_job(&job).is_none());
assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small));
let _ = std::fs::remove_dir_all(&dir);
}
}
+20 -7
View File
@@ -57,6 +57,25 @@ pub struct ImportReport {
pub sidecars: usize,
}
/// The develop settings a photo gets on import: raws start from their as-shot white balance with
/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the
/// camera intended).
pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings {
let mut d = match p.as_shot_wb {
Some((t, tint)) => lightcraft_develop::DevelopSettings::for_raw(t, tint),
None => lightcraft_develop::DevelopSettings::default(),
};
if p.embedded_lens.is_some() {
d.optics.lens_profile = true;
}
d
}
/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)?
pub fn has_import_look(p: &Photo) -> bool {
*p.develop == import_defaults(p)
}
pub fn is_supported(path: &Path) -> bool {
path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()))
}
@@ -220,14 +239,8 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
if let Some((t, tint)) = info.as_shot_wb {
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
}
p.embedded_lens = info.embedded_lens;
if p.embedded_lens.is_some() {
// built-in (file-embedded) lens corrections are on by default, like the camera intended
std::sync::Arc::make_mut(&mut p.develop).optics.lens_profile = true;
}
p.develop = std::sync::Arc::new(import_defaults(&p));
let raw = p.kind == lightcraft_catalog::MediaKind::Raw;
let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming)
.and_then(|f| std::fs::read_to_string(f).ok())
+143 -5
View File
@@ -10,6 +10,11 @@
//! disk cache in the library's `thumbs/` folder), keyed by the photo's content hash, the develop
//! settings hash, the size and [`RENDER_CACHE_VERSION`] — so reopening a library shows its grid
//! without decoding a single original, and an edit simply produces a new key.
//!
//! Opening a photo shows something at once ([`QuickJob`]): the loupe's last render for the current
//! settings (kept in the same cache under a size-independent "view" key), else the camera's
//! embedded JPEG for an unedited raw, else a thumbnail-level render — while the real render is
//! prepared. Grid thumbnails of unedited raws likewise start from the embedded preview.
use std::sync::Arc;
@@ -17,7 +22,7 @@ use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_raster::{Histogram, Rgb32f};
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
@@ -62,6 +67,10 @@ impl SourceLevel {
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app).
pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), String> + Send + Sync>;
/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than
/// `max_edge` (set by the app). `None`: no usable preview.
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
#[derive(Clone)]
pub enum SourceRef {
Loaded(Arc<Rgb32f>),
@@ -92,6 +101,7 @@ pub struct MediaCache {
pub preview_capacity: usize,
pub file_loader: Option<FileLoader>,
pub file_probe: Option<FileProbe>,
pub preview_loader: Option<PreviewLoader>,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
@@ -106,6 +116,7 @@ impl Default for MediaCache {
preview_capacity: 0,
file_loader: None,
file_probe: None,
preview_loader: None,
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)),
}
@@ -198,6 +209,8 @@ pub struct RenderJob {
/// Intermediate results of this view's previous renders (set by the frontend for the loupe):
/// slider drags then only redo the stages the changed setting feeds.
pub stages: Option<Arc<StageCache>>,
/// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]).
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
}
pub struct RenderResult {
@@ -207,6 +220,8 @@ pub struct RenderResult {
pub rendered: Result<Rendered, String>,
/// A source that was loaded by this job (to be inserted into the cache).
pub loaded: Option<Arc<Rgb32f>>,
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
pub quick: Option<QuickSource>,
}
impl RenderJob {
@@ -231,7 +246,14 @@ impl RenderJob {
{
let image = Arc::unwrap_or_clone(img);
let histogram = Histogram::of_srgb8(&image);
return RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram }), loaded: None };
return RenderResult {
photo: self.photo,
level: self.level,
key: self.key,
rendered: Ok(Rendered { image, histogram }),
loaded: None,
quick: None,
};
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load() {
@@ -243,9 +265,21 @@ impl RenderJob {
if let Some((cache, key)) = &self.cache {
cache.put(*key, Arc::new(rendered.image.clone()));
}
RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(rendered), loaded: (!was_loaded).then_some(src) }
if let Some((cache, key)) = &self.view_cache
&& self.request.quality == Quality::Full
{
cache.put_deferred(*key, Arc::new(rendered.image.clone()));
}
RenderResult {
photo: self.photo,
level: self.level,
key: self.key,
rendered: Ok(rendered),
loaded: (!was_loaded).then_some(src),
quick: None,
}
}
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None },
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None, quick: None },
}
}
}
@@ -263,6 +297,58 @@ pub fn develop(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &
}
}
/// Where a [`QuickJob`]'s stand-in image came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum QuickSource {
/// The photo's own develop render for its current settings (cached view or thumbnail render).
Cached,
/// The camera's embedded JPEG (unedited raws).
Embedded,
/// A thumbnail-level develop render made for the occasion.
Small,
}
/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached
/// renders, the embedded preview of an unedited raw, a thumbnail-level render.
#[derive(Clone)]
pub struct QuickJob {
pub photo: PhotoId,
pub key: u64,
/// Cached renders to try first, in order: (cache, key).
pub cached: Vec<(Arc<PreviewCache>, Hash128)>,
/// Embedded preview of an unedited raw: (path, loader, max edge).
pub embedded: Option<(String, PreviewLoader, usize)>,
/// Last resort: run this thumbnail job.
pub small: Option<Box<RenderJob>>,
}
impl QuickJob {
pub fn run(self) -> RenderResult {
let (photo, key) = (self.photo, self.key);
let done = |rendered: Result<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
let rendered = |image: Rgba8| {
let histogram = Histogram::of_srgb8(&image);
Ok(Rendered { image, histogram })
};
for (cache, k) in &self.cached {
if let Some(img) = cache.get(*k) {
return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached));
}
}
if let Some((path, loader, edge)) = &self.embedded
&& let Some(image) = loader(path, *edge)
{
return done(rendered(image), Some(QuickSource::Embedded));
}
if let Some(job) = self.small {
let r = job.run();
return RenderResult { key, quick: Some(QuickSource::Small), ..r };
}
done(Err("no quick preview".into()), None)
}
}
/// What identifies a photo's pixels for caching: its content hash, else its source.
pub fn content_key(p: &Photo) -> String {
match (&p.content_hash, &p.source) {
@@ -316,7 +402,59 @@ impl crate::Session {
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
(self.media.rendered.clone(), k)
});
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache, stages: None })
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache, stages: None, view_cache: None })
}
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish()
}
/// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the
/// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`].
pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option<RenderJob> {
let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?;
let p = self.catalog.photo(id)?;
job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop)));
Some(job)
}
/// The embedded preview of an unedited raw (path, loader), when the app installed a loader.
fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> {
match (&p.source, &self.media.preview_loader) {
(Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())),
_ => None,
}
}
/// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view
/// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail.
pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option<QuickJob> {
let p = self.catalog.photo(id)?.clone();
let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?;
// (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview)
let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))];
let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish();
let key = h.0 as u64;
let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge())));
Some(QuickJob { photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) })
}
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
/// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for
/// other photos (render them directly).
pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option<QuickJob> {
let p = self.catalog.photo(job.photo)?.clone();
let (path, l) = self.embedded_of(&p)?;
let edge = job.request.max_w.max(job.request.max_h);
Some(QuickJob {
photo: job.photo,
key: job.key,
cached: job.cache.clone().into_iter().collect(),
embedded: Some((path, l, edge)),
small: None,
})
}
/// Accept a finished job's loaded source into the cache.
+22
View File
@@ -63,6 +63,28 @@ impl PreviewCache {
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img, c);
}
/// Like [`Self::put`], but the disk write (a JPEG encode) happens on a background thread, so
/// the caller (a render about to hand its result to the screen) isn't held up.
pub fn put_deferred(self: &Arc<Self>, key: Hash128, img: Arc<Rgba8>) {
let c = cost(&img);
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), c);
if self.disk.is_none() {
return;
}
let me = self.clone();
let write = move || {
if let Some(d) = &me.disk {
d.put(key, &img);
}
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("lc-preview-write".into()).spawn(write).is_err() {
log::warn!("preview cache: could not start a writer thread");
}
#[cfg(target_arch = "wasm32")]
write();
}
/// Drop everything (memory and disk).
pub fn clear(&self) {
self.mem.lock().unwrap_or_else(|e| e.into_inner()).clear();
+1
View File
@@ -78,6 +78,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"activeMask": app.session.active_mask,
"widgetCount": app.widgets.len(),
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()},
"loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})),
"status": app.ui.status,
})
}
+4
View File
@@ -80,6 +80,9 @@ pub struct LightcraftApp {
pub widgets: Vec<(String, egui::Rect)>,
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
pub gesture: Option<panels::detail::Gesture>,
/// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small",
/// "thumb", "none").
pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>,
}
impl LightcraftApp {
@@ -105,6 +108,7 @@ impl LightcraftApp {
image_rect: None,
widgets: vec![],
gesture: None,
loupe_shown: None,
}
}
+41 -18
View File
@@ -142,10 +142,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let scale = if interacting { 0.6 } else { 1.0 };
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, !crop_tool) {
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
let job = if interacting { job.draft() } else { job };
app.renderer.request(Slot::Main, job, 100);
}
// until the loupe has this photo: show its cached render / embedded preview / a thumbnail
if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id)
&& let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool)
{
app.renderer.request_quick(Slot::Preview, q, 110);
}
let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash));
if (split || show_before || app.ui.before_after == BeforeAfter::Split)
&& let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool)
@@ -153,29 +159,39 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.renderer.request(Slot::Before, job, 90);
}
let p = ui.painter_at(canvas);
let draw = |slot: Slot, r: Rect| {
if let Some(tex) = app.renderer.textures.get(&slot).filter(|t| t.photo == id) {
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
true
} else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
true
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
// between photos, and the full render replaces them in place)
let draw = |slot: Slot, r: Rect| -> &'static str {
let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id);
let (tex, what) = if let Some(t) = mine(slot) {
(t, "render")
} else if let Some(t) = mine(Slot::Preview) {
(t, t.quick.map(quick_name).unwrap_or("preview"))
} else if let Some(t) = app.renderer.thumb(id) {
(t, "thumb")
} else {
false
}
return "none";
};
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
what
};
let shown;
if split {
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
draw(Slot::Before, br);
draw(Slot::Main, img_rect);
shown = draw(Slot::Main, img_rect);
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim);
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim);
} else if show_before {
draw(Slot::Before, img_rect);
shown = draw(Slot::Before, img_rect);
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text);
} else if !draw(Slot::Main, img_rect) {
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
} else {
shown = draw(Slot::Main, img_rect);
if shown == "none" {
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
}
}
app.loupe_shown = Some((id, shown));
if app.ui.before_after == BeforeAfter::Split {
let mid = img_rect.center().x;
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
@@ -199,6 +215,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
}
fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
use lightcraft_engine::media::QuickSource;
match q {
QuickSource::Cached => "cached",
QuickSource::Embedded => "embedded",
QuickSource::Small => "small",
}
}
fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
// Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint.
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) {
@@ -691,10 +716,8 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
}
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
if let Some(job) = app.session.thumb_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2) {
app.renderer.request(Slot::Thumb(*id), job, 8);
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(*id)) {
super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8);
if let Some(tex) = app.renderer.thumb(*id) {
let [tw, th] = tex.size;
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
+22 -4
View File
@@ -128,6 +128,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE);
}
/// 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.
pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) {
let Some(job) = app.session.thumb_job(id, size) else { return };
let quick = if app.renderer.textures.contains_key(&Slot::Thumb(id)) { None } else { app.session.quick_thumb_job(&job) };
match quick {
Some(q) => {
app.renderer.request_quick(Slot::ThumbQuick(id), q, priority + 1);
if !app.renderer.is_pending(Slot::ThumbQuick(id)) {
app.renderer.request(Slot::Thumb(id), job, BACKGROUND_THUMB_PRIORITY);
}
}
None => app.renderer.request(Slot::Thumb(id), job, priority),
}
}
/// Rendered thumbnails replacing embedded previews: after everything on screen.
pub const BACKGROUND_THUMB_PRIORITY: u32 = 3;
fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square: bool, onscreen: bool, ppp: f32) {
let t = Tokens::get(ui.ctx());
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
@@ -144,10 +164,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
};
// thumbnail
let size = thumb_px(img_rect.width().max(img_rect.height()), ppp);
if let Some(job) = app.session.thumb_job(id, size) {
app.renderer.request(Slot::Thumb(id), job, if onscreen { 10 } else { 5 });
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
request_thumb(app, id, size, if onscreen { 10 } else { 5 });
if let Some(tex) = app.renderer.thumb(id) {
let [tw, th] = tex.size;
let fit = if square {
let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32);
+67 -7
View File
@@ -5,12 +5,18 @@
//! prioritised (loupe first, then on-screen thumbnails, then prefetch); thumbnails scrolled far
//! out of view are dropped from the queue. Thumbnail jobs hit the engine's preview cache (memory +
//! disk) before rendering. On wasm the jobs run inline, one per frame.
//!
//! Stand-ins ([`QuickJob`]s, once per photo and settings): [`Slot::Preview`] holds what the loupe
//! shows until [`Slot::Main`] has the photo's render (cached view render, embedded camera JPEG or
//! a thumbnail render); [`Slot::ThumbQuick`] holds a raw's embedded preview in the grid until its
//! rendered thumbnail arrives (a cached thumbnail found by the quick job becomes the
//! [`Slot::Thumb`] texture directly).
use std::collections::HashMap;
use lightcraft_catalog::PhotoId;
use lightcraft_engine::Session;
use lightcraft_engine::media::{RenderJob, RenderResult};
use lightcraft_engine::media::{QuickJob, QuickSource, RenderJob, RenderResult};
use lightcraft_engine::pipeline::StageCache;
use lightcraft_preview::JobPool;
use lightcraft_raster::Histogram;
@@ -19,7 +25,11 @@ use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Slot {
Thumb(PhotoId),
/// A thumbnail's stand-in (embedded preview of an unedited raw).
ThumbQuick(PhotoId),
Main,
/// The loupe's stand-in until `Main` has the photo.
Preview,
Before,
Compare(u8),
}
@@ -31,6 +41,8 @@ pub struct Tex {
pub size: [usize; 2],
pub histogram: Option<Histogram>,
pub ms: f64,
/// Set for stand-ins: where the image came from.
pub quick: Option<QuickSource>,
/// CPU copy of the pixels (only with [`Renderer::keep_pixels`]; for headless screenshots).
pub pixels: Option<std::sync::Arc<egui::ColorImage>>,
}
@@ -48,6 +60,8 @@ pub struct Renderer {
pub keep_pixels: bool,
/// Per-view intermediate results (loupe and "before"), so slider drags redo only what changed.
stages: HashMap<Slot, Arc<StageCache>>,
/// Slot → key of the last quick job requested for it (each is tried once).
quick_tried: HashMap<Slot, u64>,
}
impl Default for Renderer {
@@ -61,6 +75,7 @@ impl Default for Renderer {
completed: 0,
keep_pixels: false,
stages: HashMap::new(),
quick_tried: HashMap::new(),
}
}
}
@@ -88,6 +103,27 @@ impl Renderer {
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
}
/// Request a stand-in for `slot` (once per job key).
pub fn request_quick(&mut self, slot: Slot, job: QuickJob, priority: u32) {
if self.quick_tried.get(&slot) == Some(&job.key) {
return;
}
self.quick_tried.insert(slot, job.key);
self.pending.insert(slot, (job.key, priority));
let key = job.key;
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
}
/// Is a request for `slot` queued or running?
pub fn is_pending(&self, slot: Slot) -> bool {
self.pending.contains_key(&slot)
}
/// The texture to show for a grid/filmstrip thumbnail: the rendered one, else its stand-in.
pub fn thumb(&self, id: PhotoId) -> Option<&Tex> {
self.textures.get(&Slot::Thumb(id)).or_else(|| self.textures.get(&Slot::ThumbQuick(id)))
}
pub fn queued(&self) -> usize {
self.pool.queued()
}
@@ -99,7 +135,7 @@ impl Renderer {
/// Thumbnail textures currently loaded.
pub fn thumb_textures(&self) -> usize {
self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count()
self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count()
}
/// Collect finished jobs into textures. Returns true if anything changed.
@@ -115,7 +151,7 @@ impl Renderer {
let inline_ms = 0.0;
let mut changed = false;
while let Some(done) = self.pool.try_recv() {
let (slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms });
let (mut slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms });
session.accept(&r);
self.completed += 1;
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
@@ -124,6 +160,18 @@ impl Renderer {
let Ok(rendered) = r.rendered else {
continue;
};
if let Slot::ThumbQuick(id) = slot {
if self.textures.contains_key(&Slot::Thumb(id)) {
continue; // the real thumbnail won the race
}
if r.quick == Some(QuickSource::Cached) {
// the photo's own cached thumbnail: final
slot = Slot::Thumb(id);
}
}
if let Slot::Thumb(id) = slot {
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 pixels = self.keep_pixels.then(|| color.clone());
@@ -137,12 +185,22 @@ impl Renderer {
t.size = [img.width, img.height];
t.histogram = Some(rendered.histogram);
t.ms = ms;
t.quick = r.quick;
}
None => {
let tex = ctx.load_texture(name, color, egui::TextureOptions::LINEAR);
self.textures.insert(
slot,
Tex { key: r.key, photo: r.photo, tex, size: [img.width, img.height], histogram: Some(rendered.histogram), ms, pixels },
Tex {
key: r.key,
photo: r.photo,
tex,
size: [img.width, img.height],
histogram: Some(rendered.histogram),
ms,
quick: r.quick,
pixels,
},
);
}
}
@@ -166,16 +224,18 @@ impl Renderer {
/// thumbnail jobs for photos that scrolled out of `keep`.
pub fn evict_thumbs(&mut self, keep: &std::collections::HashSet<PhotoId>, max: usize) {
let dropped = self.pool.reprioritize(|s, p| match s {
Slot::Thumb(id) if !keep.contains(id) && p <= 10 => None,
Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id) && p <= 11 => None,
_ => Some(p),
});
for s in dropped {
self.pending.remove(&s);
self.quick_tried.remove(&s);
}
let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
let thumbs = self.thumb_textures();
if thumbs <= max {
return;
}
self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) if !keep.contains(id)));
self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id)));
self.quick_tried.retain(|s, _| !matches!(s, Slot::ThumbQuick(id) if !keep.contains(id)));
}
}