//! Source proxies and render jobs. //! //! Sources are decoded (or generated) once per level and cached in memory LRUs: `Thumb` (≤ 512 px //! long edge) for the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe, `Full` (native //! resolution, the last one only) for exports larger than a preview. A [`RenderJob`] is //! self-contained and `Send`: frontends run it on a worker thread; if the source wasn't cached yet //! the job loads it and hands it back in the [`RenderResult`] so the cache can keep it. //! //! Rendered thumbnails are cached too ([`lightcraft_preview::PreviewCache`]: memory LRU, plus a //! 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::collections::{HashMap, VecDeque}; use std::sync::{Arc, Weak}; use lightcraft_catalog::{MediaKind, Op, 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, Rgba8}; use serde::{Deserialize, Serialize}; const SETTINGS_HASH_ENTRIES: usize = 1024; #[derive(Default)] struct SettingsHashes { entries: HashMap, u64)>, order: VecDeque, #[cfg(test)] computations: usize, } impl SettingsHashes { fn get(&mut self, id: PhotoId, settings: &Arc) -> u64 { if let Some((old, hash)) = self.entries.get(&id) && old.as_ptr() == Arc::as_ptr(settings) { return *hash; } let hash = settings.hash64(); #[cfg(test)] { self.computations += 1; } if !self.entries.contains_key(&id) { if self.entries.len() >= SETTINGS_HASH_ENTRIES && let Some(oldest) = self.order.pop_front() { self.entries.remove(&oldest); } self.order.push_back(id); } self.entries.insert(id, (Arc::downgrade(settings), hash)); hash } } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. pub const RENDER_CACHE_VERSION: u64 = 64; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; /// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the /// tile's size follows the thumbnail slider and the screen's scale, and without steps every size /// in between would render (and cache) every face again. pub const FACE_EDGE_STEP: usize = 16; /// The edge a face tile asked to be `edge` pixels across is rendered at: the next /// [`FACE_EDGE_STEP`], 16..=512. pub fn face_edge(edge: usize) -> usize { edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge()) } pub fn thumb_bucket(long_edge: usize) -> usize { THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512) } /// Memory budgets. const THUMB_SOURCE_BYTES: usize = 384 << 20; const RENDERED_MEM_BYTES: usize = 128 << 20; /// Disk budget for the thumbnail cache. pub const DISK_CACHE_BYTES: u64 = 2 << 30; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SourceLevel { Thumb, Preview, /// The original at its native resolution (exports larger than a preview). Full, } impl SourceLevel { pub fn max_edge(self) -> usize { match self { SourceLevel::Thumb => 512, SourceLevel::Preview => 2560, SourceLevel::Full => usize::MAX, } } /// Smallest level that can serve an output of `long_edge` pixels. pub fn for_size(long_edge: usize) -> SourceLevel { match long_edge { 0..=520 => SourceLevel::Thumb, 521..=2560 => SourceLevel::Preview, _ => SourceLevel::Full, } } } /// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows /// only part of the photo, so its pixels come from a source that many times larger (the source /// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview). fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize { let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); let r = settings.crop.geometry.rect; let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) }; if !shown.is_finite() || shown < 1.0 { return out_long; } let needed = out_long as f64 * w.max(h) / shown; if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long } } /// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). pub type FileLoader = Arc 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 Option + Send + Sync>; /// Makes a photo's denoised picture when it is not cached yet (an export does not wait for the background queue): /// `Ok(true)` once it is there, `Ok(false)` when denoise does not apply to this photo (render it plain), `Err` when it /// should have worked and did not. pub type MakeProduct = Arc Result + Send + Sync>; /// A raw photo's cached AI-denoised picture (see [`crate::denoise`]): where the file is and the key it must have been /// made with. A file that is missing, damaged or made for something else is a cache miss, never an error. #[derive(Clone)] pub struct DenoiseSpec { pub product: std::path::PathBuf, pub key: String, /// Run before the picture is read, by a job that must not render without it (an export). pub make: Option, } /// Decodes a raw file once and develops it twice: as usual, and from the denoised picture in `spec` when that can be /// read (`None` when it cannot). Both are at most `max_edge` long and the same size. pub type PairLoader = Arc Result<(Rgb32f, Option, SourceInfo), String> + Send + Sync>; /// A photo's denoised picture next to its plain one, and the last mix of the two that was asked for. pub struct Twin { pub image: Arc, /// The Amount (in thousandths) and the picture mixed for it, so dragging other sliders keeps one source picture. mix: std::sync::Mutex)>>, } impl Twin { pub fn new(image: Arc) -> Twin { Twin { image, mix: std::sync::Mutex::new(None) } } /// Bytes reserved for the twin and its lazy mix, before either cache cost can grow. fn bytes(&self) -> usize { self.image.data.len().saturating_mul(24) } } /// Pixels and the source interpretation learned while decoding; they must be cached together. #[derive(Clone)] pub struct DecodedSource { pub image: Arc, pub info: Option, /// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the /// catalog's header facts lack (used when `info` is `None`). pub camera_tone: Option, /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. pub denoised: Option>, } impl DecodedSource { pub fn new(image: Arc, info: Option) -> Self { DecodedSource { image, info, camera_tone: None, denoised: None } } /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, /// the denoised one, or a mix of the two. A mix is made once per amount and kept, so the stages after it are /// reused until the amount changes. pub fn image_for(&self, amount: f32) -> Arc { let Some(twin) = &self.denoised else { return self.image.clone() }; if amount.is_nan() || amount <= 0.0 || twin.image.width != self.image.width || twin.image.height != self.image.height { return self.image.clone(); } if amount >= 1.0 { return twin.image.clone(); } let thousandths = (amount * 1000.0).round().clamp(1.0, 999.0) as u32; let mut memo = twin.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((t, img)) = memo.as_ref() && *t == thousandths { return img.clone(); } let mut mixed = (*self.image).clone(); if !lightcraft_denoise::product::blend(&mut mixed, &twin.image, thousandths as f32 / 1000.0) { return self.image.clone(); } let mixed = Arc::new(mixed); *memo = Some((thousandths, mixed.clone())); mixed } /// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix. pub fn bytes(&self) -> usize { let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes()); let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4); self.image.data.len() * 12 + 64 + std::mem::size_of::() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes()) } /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) /// with any stored camera tone curve. The original's size comes from `header` either way: the /// decoder only sees the level it decoded. pub fn info_or(&self, header: SourceInfo) -> SourceInfo { match &self.info { Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, } } } #[derive(Clone)] pub enum SourceRef { Loaded(Box), /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of /// the photo open in the loupe is cut from its preview-level source instead of decoding the /// file again (a second or more for a 64 MP raw). Fit { from: Box, max_edge: usize, }, Demo { scene: Box, max_edge: usize, }, File { path: String, max_edge: usize, loader: Option, /// The photo's smart preview, used when the original can't be read (offline drive). The /// UI thread never checks whether the original is there; the render worker finds out. fallback: Option, /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). denoise: Option<(DenoiseSpec, PairLoader)>, }, /// A smart preview standing in for a missing original. Smart { path: std::path::PathBuf, }, } impl SourceRef { pub fn load(&self) -> Result, String> { self.load_source().map(|s| s.image) } pub fn load_source(&self) -> Result { let image = match self { SourceRef::Loaded(a) => return Ok((**a).clone()), SourceRef::Fit { from, max_edge } => { if from.image.width.max(from.image.height) <= *max_edge { return Ok((**from).clone()); } let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, SourceRef::File { path, max_edge, loader, fallback, denoise } => { let plain = || match loader { Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), None => Err(format!("no decoder available for {path}")), }; // the denoised picture comes with the plain one from a single decode; when it cannot be read the // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) let r = match denoise { Some((spec, pair)) => { // a job that needs the denoised picture has it made first; if that fails the job fails (no smart // preview stands in: an export must not quietly come out without the denoise it was asked for) let made = match &spec.make { Some(make) => make()?, None => true, }; if made { pair(path, *max_edge, spec).and_then(|(image, twin, info)| { if spec.make.is_some() && twin.is_none() { return Err("the cached AI Denoise picture could not be read; clear the denoise cache and retry".into()); } Ok(DecodedSource { image: Arc::new(image), info: Some(info), camera_tone: None, denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), }) }) } else { plain() } } None => plain(), }; // An offline original renders from its smart preview (no decoder facts: header ones). return match (r, fallback) { #[cfg(not(target_arch = "wasm32"))] (Err(e), Some(sp)) if denoise.as_ref().is_none_or(|(spec, _)| spec.make.is_none()) && crate::smart::is_valid(sp) => { crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")) } (r, _) => r, }; } #[cfg(not(target_arch = "wasm32"))] SourceRef::Smart { path } => return crate::smart::load(path), #[cfg(target_arch = "wasm32")] SourceRef::Smart { .. } => return Err("smart previews are not available here".into()), }; Ok(DecodedSource::new(image, None)) } } pub struct MediaCache { settings_hashes: SettingsHashes, /// Decoded thumbnail-level sources (LRU by bytes). thumbs: Lru, /// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]). previews: Vec<(PhotoId, DecodedSource, u64)>, /// The last full-resolution originals (exports, zoomed views; ~300 MB each at 24 MP), with /// their last use. Normally one; see [`Self::set_full_capacity`]. full: Vec<(PhotoId, DecodedSource, u64)>, /// How many full-resolution originals to keep (LRU, at least one). full_capacity: usize, /// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget. on_screen: Option, /// How many previews to keep (LRU). pub preview_capacity: usize, /// Bytes all decoded sources and rendered previews in memory may take together (the cache /// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first. budget: usize, pub file_loader: Option, pub file_probe: Option, pub preview_loader: Option, /// Reads a photo file's bytes (Photo Merge); `None` = the local file system. pub file_bytes: Option, /// The library's smart previews folder (originals offline: render from the proxy). pub smart_dir: Option, /// Whether originals (and smart previews) are on disk: cached and checked off the UI thread /// in the app (see [`crate::availability`]). pub availability: crate::availability::Availability, scenes: Vec, /// Rendered thumbnails (memory, plus disk once a library is attached). pub rendered: Arc, /// Which photos have a cached denoised picture, and how to read it. pub denoise: DenoiseIndex, } /// The photos that have a cached denoised picture for the denoise model in use. Only memory: nothing here touches the /// file system, so it can be asked on the UI thread. #[derive(Default)] pub struct DenoiseIndex { ready: std::collections::HashMap, /// Reads the pictures (`None`: this build cannot, and nothing is ever ready). pub loader: Option, } impl DenoiseIndex { /// What to load for `id` besides its plain picture, and how. pub fn get(&self, id: PhotoId) -> Option<(&DenoiseSpec, &PairLoader)> { self.ready.get(&id).zip(self.loader.as_ref()) } pub fn spec(&self, id: PhotoId) -> Option<&DenoiseSpec> { self.ready.get(&id) } pub fn set(&mut self, id: PhotoId, spec: DenoiseSpec) { self.ready.insert(id, spec); } pub fn remove(&mut self, id: PhotoId) -> bool { self.ready.remove(&id).is_some() } pub fn clear(&mut self) { self.ready.clear(); } pub fn len(&self) -> usize { self.ready.len() } pub fn is_empty(&self) -> bool { self.ready.is_empty() } pub fn ids(&self) -> Vec { let mut v: Vec = self.ready.keys().copied().collect(); v.sort(); v } } impl Default for MediaCache { fn default() -> Self { let budget = crate::memory::cache_share(crate::memory::budget()); MediaCache { settings_hashes: SettingsHashes::default(), thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), previews: Vec::new(), full: Vec::new(), full_capacity: 1, on_screen: None, preview_capacity: 0, budget, file_loader: None, file_probe: None, preview_loader: None, file_bytes: None, smart_dir: None, availability: Default::default(), scenes: Vec::new(), rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), denoise: DenoiseIndex::default(), } } } /// Memory for rendered previews out of the cache share. fn rendered_budget(share: usize) -> usize { RENDERED_MEM_BYTES.min(share / 4) } impl MediaCache { /// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired /// (its files stay): render jobs still holding it can't write through it any more, even /// into a directory a later clear emptied (`dir` may be the same one). pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { self.rendered.retire(); self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes)); } /// Change the disk budget of the cache in `dir`: in place when that cache is attached (its /// thumbnails are keyed by content, so they and the textures shown from them stay valid, /// and the same cache object keeps its generation), else attach it. pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) { match self.rendered.disk().filter(|d| d.dir() == dir) { Some(d) => d.set_budget(disk_bytes), None => self.attach_disk_cache(dir, disk_bytes), } } /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). pub fn clear_sources(&mut self) { self.settings_hashes = SettingsHashes::default(); self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); self.previews.clear(); self.full.clear(); } /// How many full-resolution originals to keep at once (at least one). Compare and Reference /// views zoomed to 1:1 cut windows from two photos' originals: with room for one, every window /// render of one side would evict the other side's original and decode its own again. Lowering /// it drops the least recently used originals at once. pub fn set_full_capacity(&mut self, n: usize) { self.full_capacity = n.max(1); self.trim_full(); } /// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays /// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it /// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once. pub fn set_on_screen(&mut self, id: Option) { if self.on_screen != id { self.on_screen = id; self.enforce_budget(); } } fn trim_full(&mut self) { while self.full.len() > self.full_capacity { let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); self.full.remove(oldest); } } /// Bytes the caches may hold together. pub fn budget(&self) -> usize { self.budget } /// Change the bytes the caches may hold together, evicting at once if needed. pub fn set_budget(&mut self, bytes: usize) { self.budget = bytes; self.thumbs.set_budget(THUMB_SOURCE_BYTES.min(bytes)); self.rendered.set_mem_budget(rendered_budget(bytes)); self.enforce_budget(); } /// Bytes held by decoded sources and rendered previews in memory. pub fn held(&self) -> usize { let (t, p, f) = self.usage(); t.bytes + p.bytes + f.bytes + self.rendered.mem_usage().1 } /// Evict least recently used entries across the source caches and the rendered previews /// until they fit the budget. The newest preview-level source (the photo on screen, or the /// one just decoded) is never evicted here, nor is the original of the photo on screen /// ([`Self::set_on_screen`]). pub fn enforce_budget(&mut self) { // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); let mut held = self.held().saturating_sub(pinned); while held > self.budget { let newest_preview = self.previews.iter().map(|e| e.2).max(); let candidates = [ self.thumbs.oldest_tick().map(|t| (t, 0)), self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)), self.rendered.oldest_tick().map(|t| (t, 3)), ]; let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; let freed = match which { 0 => self.thumbs.pop_oldest(), 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| self.previews.remove(i).1.bytes()), 2 => self.full.iter().position(|e| e.2 == tick).map(|i| self.full.remove(i).1.bytes()), _ => self.rendered.evict_oldest(), }; match freed { Some(b) if b > 0 => held = held.saturating_sub(b), _ => break, } } } pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { self.get_source(id, level).map(|s| s.image) } fn get_source(&mut self, id: PhotoId, level: SourceLevel) -> Option { match level { SourceLevel::Thumb => self.thumbs.get(&id).cloned(), SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| { e.2 = lightcraft_preview::next_tick(); e.1.clone() }), SourceLevel::Full => self.full.iter_mut().find(|e| e.0 == id).map(|e| { e.2 = lightcraft_preview::next_tick(); e.1.clone() }), } } /// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header` /// (with a smart preview's stored camera tone curve). fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo { let cached = || { self.thumbs .peek(&id) .into_iter() .chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1)) .chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1)) }; match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) { Some(s) => s.info_or(header), None => header, } } pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { self.insert_source(id, level, DecodedSource::new(img, None)); } fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) { let tick = lightcraft_preview::next_tick(); match level { SourceLevel::Thumb => { let cost = img.bytes(); self.thumbs.insert(id, img, cost); } SourceLevel::Preview => { self.previews.retain(|e| e.0 != id); self.previews.push((id, img, tick)); let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; while self.previews.len() > cap { let oldest = self.previews.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); self.previews.remove(oldest); } } SourceLevel::Full => { self.full.retain(|e| e.0 != id); self.full.push((id, img, tick)); self.trim_full(); } } self.enforce_budget(); } /// Forget a photo's decoded sources (e.g. after its file changed). pub fn forget(&mut self, id: PhotoId) { self.thumbs.remove(&id); self.previews.retain(|e| e.0 != id); self.full.retain(|e| e.0 != id); } /// (decoded thumbnail sources, bytes). pub fn source_usage(&self) -> (usize, usize) { (self.thumbs.len(), self.thumbs.cost()) } /// Whether the decoded source of `id` at `level` is held. pub fn has_source(&self, id: PhotoId, level: SourceLevel) -> bool { match level { SourceLevel::Thumb => self.thumbs.contains(&id), SourceLevel::Preview => self.previews.iter().any(|e| e.0 == id), SourceLevel::Full => self.full.iter().any(|e| e.0 == id), } } /// Decoded sources held: (thumbnail level, preview level, full size). pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { use crate::memory::Usage; let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| e.1.bytes()).sum()); let full = Usage::new(self.full.len(), self.full.iter().map(|e| e.1.bytes()).sum()); (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) } /// What a render of photo `id` with settings `s` adds to its identity for the denoised picture it will use: 0 unless /// it uses one (the Denoise amount is above 0 and the photo has a cached denoised picture). pub fn denoise_salt(&self, id: PhotoId, s: &DevelopSettings) -> u64 { match self.denoise.spec(id) { Some(spec) if s.denoise_amount() > 0.0 => Hasher128::new().str(&spec.key).finish().0 as u64 | 1, _ => 0, } } /// How to load photo `p` at `level`: from the cache when its source is there, else from its file (with its denoised /// picture beside it when it has one), its smart preview or the demo scene. pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { self.source_ref_with(p, level, true) } /// [`Self::source_ref`] that never takes a decoded source from memory: for a job that needs something the cached /// one lacks (an export, whose denoised picture is made when it runs). pub fn source_ref_uncached(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { self.source_ref_with(p, level, false) } fn source_ref_with(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { let mut r = self.source_ref_plain(p, level, cached); if let (SourceRef::File { denoise, .. }, Some((spec, loader))) = (&mut r, self.denoise.get(p.id)) { *denoise = Some((spec.clone(), loader.clone())); } r } fn source_ref_plain(&mut self, p: &Photo, level: SourceLevel, cached: bool) -> SourceRef { if cached && let Some(a) = self.get_source(p.id, level) { return SourceRef::Loaded(Box::new(a)); } // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not // with a denoised picture, which the thumbnail would lose if cached && level == SourceLevel::Thumb && self.denoise.spec(p.id).is_none() && let Some(big) = self.get_source(p.id, SourceLevel::Preview) { return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; } // Procedural scenes have a nominal size: "full" is that size, not unbounded. let max_edge = level.max_edge().min(match (&p.source, level) { (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, _ => usize::MAX, }); // the original is offline: its smart preview, when there is one. Only cached answers are // used here (no file-system call on the UI thread in the app); an original not known to // be offline is tried first and its smart preview is the render worker's fallback. #[cfg(not(target_arch = "wasm32"))] if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir) { let sharded = crate::smart::sharded_file_path(dir, p); let legacy = dir.join(crate::smart::file_name(p)); let sp = if self.availability.exists(&sharded.to_string_lossy()) == Some(true) { sharded } else { legacy }; // (a proxy cut short fails to load, like the offline original; the worker's fallback // in `SourceRef::load` only uses a proxy that passes `smart::is_valid`) if self.availability.is_offline(path) && self.availability.exists(&sp.to_string_lossy()) == Some(true) { return SourceRef::Smart { path: sp }; } let mut r = self.origin_ref(&p.source, max_edge); if let SourceRef::File { fallback, .. } = &mut r { *fallback = Some(sp); } return r; } self.origin_ref(&p.source, max_edge) } /// How to load `origin` at most `max_edge` pixels long, ignoring decoded sources in memory (a /// render worker in another wasm instance builds its sources from this). pub fn origin_ref(&mut self, origin: &Source, max_edge: usize) -> SourceRef { match origin { Source::Demo { scene } => { if self.scenes.is_empty() { self.scenes = lightcraft_scenes::demo_library(); } match self.scenes.iter().find(|s| s.id == *scene) { Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None }, } } Source::File { path } => { SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None } } } } } /// Everything needed to render one photo, detached from the session. #[derive(Clone)] pub struct RenderJob { /// Assigned by a frontend to distinguish late completions, even for an identical render key. pub request_id: u64, pub cache_generation: u64, /// Content identity for validating a decoded source returned after a reload. pub source_key: Option, pub photo: PhotoId, pub level: SourceLevel, pub source: SourceRef, /// Where the photo's pixels come from (for executors that can't share `source`, e.g. a web /// worker). pub origin: Source, pub info: SourceInfo, pub settings: Arc, pub request: RenderRequest, /// Identifies the result for caching: hash of settings + request. pub key: u64, /// Rendered-thumbnail cache and this job's key in it. pub cache: Option<(Arc, Hash128)>, /// 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>, /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). pub view_cache: Option<(Arc, Hash128)>, /// Render for this monitor profile ([`Self::with_display`]). pub display: Option>, /// Compose the render into a slideshow slide ([`Self::with_slide`]). pub slide: Option>, /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option, } pub struct RenderResult { pub request_id: u64, pub source_key: Option, pub photo: PhotoId, pub level: SourceLevel, pub key: u64, pub rendered: Result, /// Set when this job's render was tried on the GPU and fell back to the CPU: why /// ([`lightcraft_gpu::Fallback::Failed`]). The result is the CPU render either way. pub gpu_fallback: Option, /// A source that was loaded by this job (to be inserted into the cache). pub loaded: Option, /// Set for a [`QuickJob`]'s stand-in: where the image came from. pub quick: Option, /// The display profile (id) the image is encoded for; `None`: sRGB (see /// [`crate::display::present`]). pub display: Option, } impl RenderJob { /// Render at draft quality (interactive drags). Gets its own result key. pub fn draft(mut self) -> Self { if self.request.quality != Quality::Draft { self.request.quality = Quality::Draft; self.key ^= 0x9e37_79b9_7f4a_7c15; } self } /// Draw a diagnostic overlay over the result (e.g. Point Color's visualized range). Gets its /// own result key. pub fn with_overlay(mut self, overlay: lightcraft_pipeline::Overlay) -> Self { if self.request.overlay != overlay { self.key ^= self.request.overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); self.request.overlay = overlay; self.key ^= overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); } // a diagnostic view (visualized range / spots) must never become the photo's cached preview if overlay != lightcraft_pipeline::Overlay::None { self.view_cache = None; } self } /// Soft-proof the result (see [`lightcraft_pipeline::Proof`]). Gets its own result key and is /// never kept as the photo's cached preview. pub fn with_proof(mut self, proof: Option) -> Self { if self.request.proof != proof { let k = |p: Option| p.map_or(0, |p| p.key()).wrapping_mul(0xc2b2_ae3d_27d4_eb4f); self.key ^= k(self.request.proof) ^ k(proof); self.request.proof = proof; } if proof.is_some() { self.view_cache = None; } self } /// Render with some colour grading wheels (shadows, midtones, highlights) switched off for /// viewing (the wheel eyes in the Edit panel). The photo's settings are untouched; the result /// gets its own key and is never cached as the photo's preview or thumbnail. pub fn with_hidden_grading(mut self, hidden: [bool; 3]) -> Self { if !hidden.iter().any(|h| *h) { return self; } let mut s = (*self.settings).clone(); let g = &mut s.grading; for (off, wheel) in hidden.into_iter().zip([&mut g.shadows, &mut g.midtones, &mut g.highlights]) { if off { *wheel = lightcraft_develop::Wheel::default(); } } let bits = hidden.iter().enumerate().fold(0u64, |b, (i, h)| b | ((*h as u64) << i)); self.key ^= s.hash64() ^ self.settings.hash64() ^ bits.wrapping_mul(0xd6e8_feb8_6659_fd93); self.settings = Arc::new(s); self.cache = None; self.view_cache = None; self.preview_cut = None; self } /// Reuse `stages` across this view's renders. pub fn with_stages(mut self, stages: Arc) -> Self { self.stages = Some(stages); self } /// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready /// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB. /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: /// [`crate::display::present`] converts their sRGB results. pub fn with_display(mut self, display: Option>) -> Self { // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { return self; } let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); self.key ^= k(&self.display) ^ k(&display); self.request.display = display.as_ref().map(|d| d.space); self.display = display; self } /// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting /// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace /// it, so a few such panics used to stop every render). pub fn run(self) -> RenderResult { let failed = self.failed(String::new()); crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed }) } /// This job's result when it fails with `e`. fn failed(&self, e: String) -> RenderResult { RenderResult { request_id: self.request_id, source_key: self.source_key, photo: self.photo, level: self.level, key: self.key, rendered: Err(e), gpu_fallback: None, loaded: None, quick: None, display: None, } } /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). pub fn with_slide(mut self, slide: Arc) -> Self { let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); self.key ^= k(&self.slide); self.key ^= k(&Some(slide.clone())); self.slide = Some(slide); self } /// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard). fn run_unguarded(self) -> RenderResult { let slide = self.slide.clone(); let mut r = self.run_photo(); if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { rendered.image = slide.compose(&rendered.image); rendered.deep = None; } r } /// The photo's render (without slide composition). fn run_photo(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) { let image = Arc::unwrap_or_clone(img); let histogram = Histogram::of_srgb8(&image); return RenderResult { request_id: self.request_id, source_key: self.source_key, photo: self.photo, level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram, deep: None }), gpu_fallback: None, loaded: None, quick: None, display: None, }; } // a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough // (the sensor data is never read for it), else rendered from `source` below if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) { if let Some((cache, key)) = &self.cache { cache.put_at(self.cache_generation, *key, Arc::new(image.clone())); } let histogram = Histogram::of_srgb8(&image); return RenderResult { request_id: self.request_id, source_key: self.source_key, photo: self.photo, level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram, deep: None }), gpu_fallback: None, loaded: None, quick: None, display: None, }; } let was_loaded = matches!(self.source, SourceRef::Loaded(_)); // The app is closing (issue #620): nobody is left to show this, and quitting waits for the // jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU. let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string()); let source = match closing() { Some(e) => Err(e), None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)), }; match source { Ok(source) => { // the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for let src = &source.image_for(self.settings.denoise_amount()); let info = source.info_or(self.info); // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); let (mut rendered, gpu_fallback) = develop_reporting(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); // for a display profile: the image goes to the display's values, the histogram // and the cached view preview stay sRGB let mut srgb = None; let display = match (&self.display, self.request.display) { (Some(d), Some(_)) => { let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); // (an HDR edit's HDR histogram stays) if let Some(h) = h.filter(|_| rendered.histogram.hdr.is_none()) { rendered.histogram = h; } srgb = s; Some(d.id()) } _ => None, }; if let Some((cache, key)) = &self.cache { cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); } if let Some((cache, key)) = &self.view_cache && self.request.quality == Quality::Full && (display.is_none() || srgb.is_some()) { cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } RenderResult { request_id: self.request_id, source_key: self.source_key, photo: self.photo, level: self.level, key: self.key, rendered: Ok(rendered), gpu_fallback, loaded: (!was_loaded).then_some(source), quick: None, display, } } Err(e) => self.failed(e), } } } /// Render `src`: on the GPU when `gpu` is set and a GPU is available (`lightcraft_gpu`), else on /// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see /// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`lightcraft_gpu::set_enabled`] forces the CPU. pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { develop_reporting(src, info, s, req, stages, gpu).0 } /// [`develop`], also saying why the render fell back from the GPU to the CPU when it did (`None`: /// it rendered on the GPU, or the GPU was not tried). The reason is this render's own, not the /// process-wide [`lightcraft_gpu::last_fallback`]. pub fn develop_reporting( src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool, ) -> (Rendered, Option) { let (mut rendered, fallback) = develop_image(src, info, s, req, stages, gpu); // an HDR edit's preview is its SDR rendition: its histogram shows the HDR range instead if let Some(h) = lightcraft_pipeline::hdr_histogram(src, info, s, req) { rendered.histogram = h; } (rendered, fallback) } fn develop_image( src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool, ) -> (Rendered, Option) { // LUT profiles have no GPU stage: they render on the CPU let fallback = if gpu && !lightcraft_pipeline::lut::is_lut_profile(&s.profile.id) && !s.masks.iter().any(|m| m.visible && m.refine > 0.0) && req.proof.is_none() { match lightcraft_gpu::try_render(src, info, s, req, stages) { Ok(r) => return (r, None), Err(lightcraft_gpu::Fallback::Failed(why)) => Some(why), Err(lightcraft_gpu::Fallback::NotTried) => None, } } else { None }; let rendered = match stages { Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st), None => lightcraft_pipeline::render(src, info, s, req), }; (rendered, fallback) } /// 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 request_id: u64, pub photo: PhotoId, pub key: u64, /// Cached renders to try first, in order: (cache, key). pub cached: Vec<(Arc, 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>, } impl QuickJob { /// The stand-in, from the first source that has one. A panic inside is this job's failure /// (see [`RenderJob::run`]). pub fn run(self) -> RenderResult { let (request_id, photo, key) = (self.request_id, self.photo, self.key); crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { request_id, source_key: None, photo, level: SourceLevel::Thumb, key, rendered: Err(e), gpu_fallback: None, loaded: None, quick: None, display: None, }) } fn run_unguarded(self) -> RenderResult { let (photo, key) = (self.photo, self.key); let request_id = self.request_id; let done = |rendered: Result, quick| RenderResult { request_id, source_key: None, photo, level: SourceLevel::Thumb, key, rendered, gpu_fallback: None, loaded: None, quick, display: None, }; let rendered = |image: Rgba8| { let histogram = Histogram::of_srgb8(&image); Ok(Rendered { image, histogram, deep: None }) }; 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 { request_id, 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) { (Some(h), _) => h.clone(), (None, Source::Demo { scene }) => format!("demo:{scene}"), (None, Source::File { path }) => format!("file:{path}:{}", p.file_size), } } pub fn source_info(p: &Photo) -> SourceInfo { // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. // (They are drawn afresh at every size, so they have no original size.) if matches!(p.source, Source::Demo { .. }) { return SourceInfo { raw: true, ..Default::default() }; } // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); SourceInfo { native_long, ..header_info(p) } } fn header_info(p: &Photo) -> SourceInfo { // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white // balance and the display tone curve, like any other rendered file. if p.develops_raw() { let relative = p.relative_wb(); let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: relative, // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`) legacy_relative_wb: p.fitted_look() && !relative, ..Default::default() } } else { SourceInfo::default() } } impl crate::Session { /// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look. pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option { self.build_job(id, max_w, max_h, before, apply_crop, None) } /// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`] /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { let b = thumb_bucket(long_edge); self.build_job(id, b, b, false, true, Some(b)) } fn build_job( &mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool, thumb_bucket: Option, ) -> Option { let p = self.catalog.photo(id)?.clone(); let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() }; let level = SourceLevel::for_size(if thumb_bucket.is_some() { max_w.max(max_h) // (grid thumbnails of tight crops stay cheap) } else { source_edge_needed(&p, &settings, apply_crop, max_w.max(max_h)) }); let source = self.media.source_ref(&p, level); let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; // the photo id is part of the key: two photos with the same settings and size must not // share a result (a view slot showing photo A would otherwise look current for photo B) // …and so is the file's content: a file changed on disk (Reload) renders afresh let content_key = content_key(&p); let source_key = Hasher128::new().str(&content_key).finish(); let content = source_key.0 as u64; // Temporary "before" settings must not replace the photo's cached develop hash. let settings_hash = if before { settings.hash64() } else { self.media.settings_hashes.get(id, &settings) }; let salt = self.media.denoise_salt(id, &settings); let key = settings_hash ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60 ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17) ^ salt; let cache = thumb_bucket.map(|b| { let mut k = Hasher128::new(); k.str(&content_key).u64(settings_hash).u64(b as u64).u64(RENDER_CACHE_VERSION).u64(crate::camera_profiles::cache_key()); // (only a render that uses a denoised picture has it in its key, so every other thumbnail stays valid) if salt != 0 { k.u64(salt); } (self.media.rendered.clone(), k.finish()) }); Some(RenderJob { request_id: 0, cache_generation: self.media.rendered.generation(), source_key: Some(source_key), photo: id, level, source, origin: p.source.clone(), info: source_info(&p), settings, request, key, cache, stages: None, view_cache: None, display: None, slide: None, preview_cut: None, }) } /// A render of `id` with `settings` in place of its own, fitting `max_w × max_h` (the loupe's /// temporary preview while hovering a preset or profile; nothing is committed or cached). pub fn preview_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool, settings: &DevelopSettings) -> Option { let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; job.settings = Arc::new(settings.clone()); job.key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (job.level as u64) << 60; Some(job) } /// Cache key of a variant thumbnail ([`Self::variant_job`]). pub fn variant_key(p: &Photo, settings: &DevelopSettings, edge: usize) -> Hash128 { Hasher128::new() .str(&content_key(p)) .str("variant") .u64(settings.hash64()) .u64(edge as u64) .u64(RENDER_CACHE_VERSION) .u64(crate::camera_profiles::cache_key()) .finish() } /// A thumbnail of `id` rendered with `settings` instead of its own (profile and preset /// browsers): from the thumbnail-level source, long edge `edge` (≤ 512), cropped. The result /// is cached (memory + the library's disk cache) under the photo's content and the settings /// hash, so a variant renders once; `key` is derived from the same hash, so a frontend can /// keep one texture per variant. pub fn variant_job(&mut self, id: PhotoId, settings: &DevelopSettings, edge: usize) -> Option { let p = self.catalog.photo(id)?.clone(); let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); let level = SourceLevel::Thumb; let source = self.media.source_ref(&p, level); let ck = Self::variant_key(&p, settings, edge); Some(RenderJob { request_id: 0, cache_generation: self.media.rendered.generation(), source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), photo: id, level, source, origin: p.source.clone(), info: source_info(&p), settings: Arc::new(settings.clone()), request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), cache: Some((self.media.rendered.clone(), ck)), stages: None, view_cache: None, display: None, slide: None, preview_cut: None, }) } /// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the /// photo's own look with its crop replaced by a square around the face (room for hair and chin), /// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size /// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's /// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the /// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source /// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the /// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows /// the content: a later launch shows the tile without reading the original. pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { let p = self.catalog.photo(id)?.clone(); // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. let orient = p.develop.orientation; let face = orient.map_norm_rect(face); let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; // finite, within the photo, and never wider than its short side (so the clamps below are valid) let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h); let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 }); let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0); let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h }; let mut settings = (*p.develop).clone(); settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() }; let edge = face_edge(edge); // pixels along the long edge that leave `edge` across the crop let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64); let level = SourceLevel::for_size(needed as usize); let source = self.media.source_ref(&p, level); let ck = Self::variant_key(&p, &settings, edge); let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize); Some(RenderJob { request_id: 0, cache_generation: self.media.rendered.generation(), source_key: Some(Hasher128::new().str(&content_key(&p)).finish()), photo: id, level, source, origin: p.source.clone(), info: source_info(&p), settings: Arc::new(settings), request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), cache: Some((self.media.rendered.clone(), ck)), stages: None, view_cache: None, display: None, slide: None, preview_cut, }) } /// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an /// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as /// imported, with a preview loader installed. `rect` is the square around the face, `edge` the /// tile, `needed` the source's long edge the develop render would ask for. fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option { let (path, loader) = self.embedded_of(p)?; if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0 { return None; } let want = needed.max(edge).max(1); let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize; let aspect = f64::from(p.width) / f64::from(p.height); Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge }) } /// 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) .u64(crate::camera_profiles::cache_key()) .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 { 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) } /// A render of one window of the loupe's frame, for a view zoomed past what one whole-frame /// render can hold: the frame is `full_w × full_h` (the size the loupe would draw it at) and /// the result is `window`'s pixels of it, at that scale, from the source level that size needs. /// Nothing is cached here (a window is only worth keeping while it is on screen). `None`: no /// such photo, or the window reads (spots, Auto Mask strokes) more than one render can hold. pub fn region_job( &mut self, id: PhotoId, full_w: usize, full_h: usize, window: lightcraft_pipeline::PixelWindow, apply_crop: bool, ) -> Option { self.region_job_of(id, full_w, full_h, window, apply_crop, false) } /// [`Self::region_job`] of the photo without its edits (its crop kept): the Before side. pub fn region_job_before( &mut self, id: PhotoId, full_w: usize, full_h: usize, window: lightcraft_pipeline::PixelWindow, apply_crop: bool, ) -> Option { self.region_job_of(id, full_w, full_h, window, apply_crop, true) } fn region_job_of( &mut self, id: PhotoId, full_w: usize, full_h: usize, window: lightcraft_pipeline::PixelWindow, apply_crop: bool, before: bool, ) -> Option { let mut job = self.render_job(id, full_w, full_h, before, apply_crop)?; // what the window's spots and Auto Mask strokes read must fit in one render: else the // caller keeps the whole-frame render (a window alone would come out wrong) let p = self.catalog.photo(id)?; let frame = lightcraft_pipeline::geometry::Frame::with_lens( p.width.max(1) as usize, p.height.max(1) as usize, &job.settings, apply_crop, p.embedded_lens.as_ref(), ); let ppl = frame.px_per_long(full_w); lightcraft_pipeline::spots::window_for_reads_checked(&job.settings, &frame, full_w, full_h, ppl, window.clamped(full_w, full_h))?; job.request.window = Some(window); job.key = Hasher128::new() .u64(job.key) .str("window") .u64(window.x as u64) .u64(window.y as u64) .u64(window.w as u64) .u64(window.h as u64) .finish() .0 as u64; 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, } } /// 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. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn scan_preview_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { match (&p.source, &self.media.preview_loader) { (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw => 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 { 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 { request_id: 0, 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, else `job` /// itself: the grid queues no render behind an unedited raw's quick thumbnail /// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the /// cell empty (issue #1448). `None` for other photos (render them directly). pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option { 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 { request_id: 0, photo: job.photo, key: job.key, cached: job.cache.clone().into_iter().collect(), embedded: Some((path, l, edge)), small: Some(Box::new(job.clone())), }) } /// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog /// has wrong is corrected too ([`Self::learn_as_shot_wb`]). pub fn accept(&mut self, r: &RenderResult) { if let Some(src) = &r.loaded && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) { if let Some(info) = &src.info { self.learn_as_shot_wb(r.photo, info); } self.media.insert_source(r.photo, r.level, src.clone()); } } /// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another: /// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's /// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not /// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot /// says and what a smart preview renders with. Only for raws edited in Kelvin. fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { let Some(p) = self.catalog.photo(id) else { return }; if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { return; } let read = (info.as_shot_temp, info.as_shot_tint); if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) { return; } if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) { log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0); } } /// Synchronous render (CLI, MCP, tests). pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; let r = job.run(); self.accept(&r); r.rendered } /// Synchronous render for export: like [`Self::render_now`], into colour space `space` with /// sample format `depth`. pub fn render_export( &mut self, id: PhotoId, max_w: usize, max_h: usize, space: lightcraft_pipeline::OutputSpace, depth: lightcraft_pipeline::OutputDepth, ) -> Result { let r = self.export_job(id, max_w, max_h, space, depth)?.run(); self.accept(&r); r.rendered } /// The job [`Self::render_export`] runs, to run elsewhere (e.g. a background export thread). pub fn export_job( &mut self, id: PhotoId, max_w: usize, max_h: usize, space: lightcraft_pipeline::OutputSpace, depth: lightcraft_pipeline::OutputDepth, ) -> Result { let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); job.source = self.media.source_ref_uncached(&p, job.level); if let SourceRef::File { denoise, .. } = &mut job.source { *denoise = Some(wanted); } } job.request.space = space; job.request.depth = depth; job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); Ok(job) } /// Prefer decoder facts to header-only metadata for pixel-statistics commands. pub fn source_info(&self, id: PhotoId) -> SourceInfo { let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default(); self.media.source_facts(id, header) } /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result, String> { let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); let r = self.media.source_ref(&p, level).load_source()?; let image = r.image.clone(); if let Some(info) = &r.info { self.learn_as_shot_wb(id, info); } self.media.insert_source(id, level, r); Ok(image) } } /// What an import learns from a file header (set by the app from `lightcraft-codecs`/`-raw`). #[derive(Clone, Debug, Default)] pub struct ProbeInfo { pub width: u32, pub height: u32, pub format: String, pub kind: MediaKind, pub file_size: u64, pub captured: Option, /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the /// capture date of a file whose metadata has none, as Lightroom does (exports saved without /// metadata would otherwise all land under "Unknown Date"). pub file_modified: Option, pub meta: lightcraft_catalog::Meta, pub as_shot_wb: Option<(f64, f64)>, /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit /// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it), /// Import → Copy verifies each copy against it instead of reading the source again. pub content_hash: Option, /// Lens corrections embedded in the file (DNG opcodes). pub embedded_lens: Option, /// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file. pub xmp: Option, /// A raw variant that can't be decoded yet: why. The file is described (and will be shown and /// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`]. pub preview_only: Option, } pub type FileProbe = Arc Result + Send + Sync>; #[cfg(test)] mod thumbnail_hash_tests { use super::*; fn session() -> crate::Session { let mut s = crate::Session::new(); let p = Photo::new(PhotoId(1), Source::File { path: "hash-fixture.jpg".into() }, "fixture.jpg", "JPEG", 6000, 4000, "2026-01-01"); s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); s } #[test] fn hashes_match_uncached_keys_and_are_not_recomputed_for_sizes_metadata_or_before() { let mut s = session(); for edge in [256, 384, 512, 256] { let p = s.catalog.photo(PhotoId(1)).unwrap().clone(); let b = thumb_bucket(edge); // Independent original formula: never asks the new hash cache for expected values. let settings = p.develop.hash64(); let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; let expected = settings ^ ((b as u64) << 40) ^ ((b as u64) << 20) ^ 1 ^ p.id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ content.rotate_left(17); let disk = Hasher128::new() .str(&content_key(&p)) .u64(settings) .u64(b as u64) .u64(RENDER_CACHE_VERSION) .u64(crate::camera_profiles::cache_key()) .finish(); let job = s.thumb_job(p.id, edge).unwrap(); assert_eq!(job.key, expected); assert_eq!(job.cache.unwrap().1, disk); } s.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 4 }).unwrap(); s.thumb_job(PhotoId(1), 256).unwrap(); s.render_job(PhotoId(1), 256, 256, true, true).unwrap(); s.thumb_job(PhotoId(1), 256).unwrap(); assert_eq!(s.media.settings_hashes.computations, 1); let mut edit = DevelopSettings::default(); edit.light.exposure = 1.0; let undo = s.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "edit".into(), edited: None }).unwrap(); s.thumb_job(PhotoId(1), 256).unwrap(); s.thumb_job(PhotoId(1), 384).unwrap(); assert_eq!(s.media.settings_hashes.computations, 2); s.catalog.apply(undo).unwrap(); s.thumb_job(PhotoId(1), 256).unwrap(); assert_eq!(s.media.settings_hashes.computations, 3); } #[test] fn hash_cache_is_bounded_weak_and_shared_settings_remain_independent() { let mut hashes = SettingsHashes::default(); let shared = Arc::new(DevelopSettings::default()); for id in 1..=SETTINGS_HASH_ENTRIES as u64 * 3 { assert_eq!(hashes.get(PhotoId(id), &shared), shared.hash64()); } assert_eq!(hashes.entries.len(), SETTINGS_HASH_ENTRIES); assert_eq!(hashes.order.len(), SETTINGS_HASH_ENTRIES); assert_eq!(Arc::strong_count(&shared), 1); let mut edit = (*shared).clone(); edit.light.exposure = 1.0; let edit = Arc::new(edit); let a = PhotoId(10000); let b = PhotoId(10001); let initial = hashes.get(a, &shared); assert_eq!(hashes.get(b, &shared), initial); assert_ne!(hashes.get(a, &edit), initial); assert_eq!(hashes.get(b, &shared), initial); let mut s = session(); s.thumb_job(PhotoId(1), 256).unwrap(); s.media.clear_sources(); assert!(s.media.settings_hashes.entries.is_empty()); assert!(s.media.settings_hashes.order.is_empty()); } #[test] fn source_from_before_content_reload_is_not_accepted() { let mut s = session(); s.media.file_loader = Some(Arc::new(|_, _| Ok((Rgb32f::new(8, 8), SourceInfo::default())))); let old = s.thumb_job(PhotoId(1), 256).unwrap().run(); assert!(old.loaded.is_some()); s.catalog .apply(Op::SetContent { id: PhotoId(1), width: 6000, height: 4000, file_size: 99, content_hash: Some("replacement".into()), preview_only: None, preview_checked: String::new(), }) .unwrap(); s.media.forget(PhotoId(1)); s.accept(&old); assert_eq!(s.media.source_usage().0, 0); } /// A panic while rendering (here in the decoder) used to end the pool's worker thread without /// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now, /// for renders and for stand-ins, answering the request it came from. #[test] fn a_render_that_panics_fails_instead_of_never_answering() { let mut s = session(); s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); let mut job = s.thumb_job(PhotoId(1), 256).unwrap(); job.request_id = 42; let (key, level, source_key) = (job.key, job.level, job.source_key); let r = job.run(); assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key)); assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug")); let quick = QuickJob { request_id: 7, photo: PhotoId(1), key: 9, cached: Vec::new(), embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), small: None, }; let r = quick.run(); assert_eq!((r.request_id, r.key), (7, 9)); assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug")); } } #[cfg(test)] mod tests { use super::*; #[test] fn renders_know_the_original_size_whichever_level_they_come_from() { // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the // 512-px thumbnail level denoises like a scaled-down export let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); assert_eq!(source_info(&p).native_long, Some(6000)); let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); let info = thumb.info_or(source_info(&p)); assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); assert_eq!(source_info(&unknown).native_long, None); assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); } #[test] fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { let mut plain = Rgb32f::new(8, 8); plain.data.fill([1.0; 3]); let mut source = DecodedSource::new(Arc::new(plain), None); source.denoised = Some(Arc::new(Twin::new(Arc::new(Rgb32f::new(8, 8))))); let reserved = source.bytes(); assert!(reserved >= 3 * 8 * 8 * 12); assert_eq!(source.image_for(0.5).data[0], [0.5; 3]); assert_eq!(source.bytes(), reserved, "creating a mix cannot evade the LRU's recorded cost"); let _ = source.image_for(0.25); assert_eq!(source.bytes(), reserved, "changing Amount replaces the reserved mix"); } #[test] fn hidden_grading_wheels_render_neutral_without_touching_the_photo() { let mut s = crate::Session::with_demo(); let id = s.active().unwrap(); s.execute("develop.set", &serde_json::json!({"values": {"grading.shadows.sat": 60, "grading.highlights.sat": 40}})).unwrap(); let job = s.loupe_job(id, 64, 64, true).unwrap(); let same = job.clone().with_hidden_grading([false; 3]); assert_eq!(same.key, job.key, "nothing hidden: the job is unchanged"); assert!(same.view_cache.is_some()); let hidden = job.clone().with_hidden_grading([true, false, false]); assert_eq!(hidden.settings.grading.shadows.sat, 0.0); assert_eq!(hidden.settings.grading.highlights.sat, 40.0); assert_ne!(hidden.key, job.key, "a hidden wheel gets its own result key"); assert!(hidden.view_cache.is_none() && hidden.cache.is_none(), "never cached as the photo's preview"); assert_eq!(s.catalog.photo(id).unwrap().develop.grading.shadows.sat, 60.0, "the edit is untouched"); } #[test] fn decoder_info_survives_render_jobs_cache_and_eviction() { let tone = lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| { let x = 0.01 * (i + 1) as f32; [x, (x * 2.0).min(0.9)] })) .unwrap(); let info = SourceInfo { raw: true, relative_wb: true, camera_tone: Some(tone), ..Default::default() }; let mut s = crate::Session::with_demo(); let id = s.active().unwrap(); let mut job = s.render_job(id, 64, 64, false, true).unwrap(); let loaded_info = info.clone(); job.source = SourceRef::File { path: "synthetic.arw".into(), max_edge: 64, loader: Some(Arc::new(move |_, _| { let mut image = Rgb32f::new(64, 64); image.data.fill([0.1; 3]); Ok((image, loaded_info.clone())) })), fallback: None, denoise: None, }; job.info = SourceInfo::default(); // Header facts cannot override decoder facts. job.settings = Arc::new(DevelopSettings::default()); let r = job.clone().run(); assert_eq!(r.loaded.as_ref().unwrap().info.as_ref(), Some(&info)); let expected = lightcraft_pipeline::render(&r.loaded.as_ref().unwrap().image, &info, &job.settings, &job.request); assert_eq!(r.rendered.as_ref().unwrap().image.data, expected.image.data); s.accept(&r); job.source = s.media.source_ref(s.catalog.photo(id).unwrap(), job.level); let again = job.run(); assert!(again.loaded.is_none()); assert_eq!(r.rendered.unwrap().image.data, again.rendered.unwrap().image.data); s.media.forget(id); assert!(s.media.get_source(id, r.level).is_none()); } /// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as /// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices /// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step), /// and the panel then reads As Shot in Kelvin while the stored settings keep their look. #[test] fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() { use lightcraft_develop::{WbMode, WbScale}; let mut s = crate::Session::new(); let id = s.catalog.alloc_photo_id(); let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00"); p.kind = MediaKind::Raw; p.meta.camera = "SONY ILCE-7M3".into(); p.as_shot_wb = Some((6500.0, 0.0)); let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0); (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0); assert_eq!(d.wb.scale, WbScale::Legacy); p.develop = Arc::new(d.clone()); s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); s.selection.ids = vec![id]; s.selection.active = Some(id); // the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known let header = s.source_info(id); assert!(header.raw && !header.relative_wb && header.legacy_relative_wb); assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0)); // the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices) let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() }; let mut pixels = Rgb32f::new(64, 64); for (i, px) in pixels.data.iter_mut().enumerate() { let v = (i % 64) as f32 / 64.0; *px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v]; } let mut job = s.render_job(id, 64, 64, false, true).unwrap(); let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone()); job.source = SourceRef::File { path: "/photos/DSC00001.ARW".into(), max_edge: 64, loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), fallback: None, denoise: None, }; let undo_before = s.undo.len(); let request = job.request; let r = job.run(); let new = r.rendered.as_ref().unwrap().image.data.clone(); // the old pipeline: the same settings against the relative-scale source facts of an older build let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; assert_eq!(new, old, "the look of an existing edit changed"); assert_ne!( new, lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data ); // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched s.accept(&r); let p = s.catalog.photo(id).unwrap(); assert_eq!(p.as_shot_wb, Some((4986.0, -2.0))); assert_eq!(*p.develop, d); assert_eq!(s.undo.len(), undo_before); assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id))); assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom); // the panel reads the white balance in effect in Kelvin: the value the relative edit renders as let info = s.source_info(id); assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0)); let shown = lightcraft_pipeline::local::effective_wb(&info, &d); assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0))); assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}"); // accepting again changes nothing more let log = s.pending_log.len(); s.accept(&r); assert_eq!(s.pending_log.len(), log); // a catalog-only (proxy) render of the same settings before the decode gave the same pixels too let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data; assert_eq!(proxy, old, "a smart preview of an existing edit changed"); } #[test] fn budget_evicts_least_recently_used_across_caches() { let img = |w: usize| Arc::new(Rgb32f::new(w, w)); let mut m = MediaCache::default(); let mb = 1 << 20; m.set_budget(30 * mb); // 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes m.insert(PhotoId(1), SourceLevel::Preview, img(1000)); m.insert(PhotoId(2), SourceLevel::Preview, img(1000)); assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2 m.insert(PhotoId(3), SourceLevel::Preview, img(1000)); assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none()); assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some()); // a thumbnail source and a rendered preview compete in the same budget m.insert(PhotoId(4), SourceLevel::Thumb, img(500)); m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000))); assert!(m.held() <= 30 * mb, "{}", m.held()); // the newest preview (the one on screen) is never evicted, even alone over budget m.set_budget(mb); assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some()); assert_eq!(m.usage().1.count, 1); assert_eq!(m.usage().0.count, 0); assert_eq!(m.rendered.mem_usage().0, 0); } // Given Compare zoomed to 1:1 (two photos' originals), both stay decoded; back to one view, // the least recently used goes, and the budget still evicts them like any source #[test] fn full_size_originals_are_kept_up_to_the_capacity() { let img = |w: usize| Arc::new(Rgb32f::new(w, w)); let mut m = MediaCache::default(); let mb = 1 << 20; m.set_budget(100 * mb); m.insert(PhotoId(1), SourceLevel::Full, img(1000)); m.insert(PhotoId(2), SourceLevel::Full, img(1000)); assert!(!m.has_source(PhotoId(1), SourceLevel::Full), "one original by default"); m.set_full_capacity(2); m.insert(PhotoId(1), SourceLevel::Full, img(1000)); assert!(m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); assert_eq!(m.usage().2.count, 2); // re-inserting a held photo replaces it instead of taking a second place m.insert(PhotoId(2), SourceLevel::Full, img(1000)); assert_eq!(m.usage().2.count, 2); // 1 is the least recently used now m.set_full_capacity(1); assert!(!m.has_source(PhotoId(1), SourceLevel::Full) && m.has_source(PhotoId(2), SourceLevel::Full)); m.set_full_capacity(0); assert_eq!(m.usage().2.count, 1, "never less than one"); m.set_full_capacity(2); m.insert(PhotoId(3), SourceLevel::Full, img(1000)); m.set_budget(15 * mb); assert_eq!(m.usage().2.count, 1, "the budget evicts the older original"); assert!(m.has_source(PhotoId(3), SourceLevel::Full)); } // Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay // (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) #[test] fn the_on_screen_original_does_not_evict_the_other_sources() { let img = |w: usize| Arc::new(Rgb32f::new(w, w)); let mut m = MediaCache::default(); let mb = 1 << 20; m.set_budget(20 * mb); m.set_on_screen(Some(PhotoId(1))); m.insert(PhotoId(1), SourceLevel::Preview, img(500)); m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); } } // A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): // evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo // on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. #[test] fn the_original_on_screen_stays_even_over_budget() { let img = |w: usize| Arc::new(Rgb32f::new(w, w)); let mut m = MediaCache::default(); let mb = 1 << 20; m.set_budget(20 * mb); m.set_on_screen(Some(PhotoId(1))); m.insert(PhotoId(1), SourceLevel::Preview, img(500)); m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB assert!(m.has_source(PhotoId(1), SourceLevel::Full)); m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour m.insert(PhotoId(3), SourceLevel::Thumb, img(100)); assert!(m.has_source(PhotoId(1), SourceLevel::Full)); m.set_on_screen(Some(PhotoId(2))); assert!(!m.has_source(PhotoId(1), SourceLevel::Full)); assert!(m.held() <= 20 * mb, "{}", m.held()); } #[test] fn work_gate_waits_for_room() { use crate::memory::WorkGate; let g = Arc::new(WorkGate::new(100)); let a = g.acquire(80); // more than fits waits until `a` is released; urgent work never waits let urgent = g.acquire_urgent(500); assert_eq!(g.usage().0, 580); drop(urgent); let g2 = g.clone(); let t = std::thread::spawn(move || { let _b = g2.acquire(50); g2.usage().0 }); std::thread::sleep(std::time::Duration::from_millis(50)); assert_eq!(g.usage().0, 80, "the second holder is still waiting"); drop(a); assert_eq!(t.join().unwrap(), 50); assert_eq!(g.usage().0, 0); // a single holder may exceed the limit drop(g.acquire(1000)); } #[test] fn levels_cover_sizes_and_large_exports_use_the_original() { assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); assert_eq!(SourceLevel::for_size(2560), SourceLevel::Preview); assert_eq!(SourceLevel::for_size(2561), SourceLevel::Full); let mut s = crate::Session::with_demo(); let p = s.catalog.photos().next().unwrap().clone(); let long = p.width.max(p.height) as usize; assert!(long > 2560, "demo photos are camera-sized"); // a full-size export reads the source at its native size, not an upscaled preview let job = s.render_job(p.id, long, long, false, true).unwrap(); assert_eq!(job.level, SourceLevel::Full); match &job.source { SourceRef::Demo { max_edge, .. } => assert_eq!(*max_edge, long), _ => panic!("demo source expected"), } let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); assert_eq!(loupe.level, SourceLevel::Preview); assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); // two photos with identical settings and size never share a result key let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); assert_ne!(a.key, b.key); } // Issue #323: a window of a 1:1 view is its own job: the window's pixels, at the zoom scale, // from the original, with a key of its own #[test] fn region_jobs_render_a_window_of_the_zoomed_frame() { use lightcraft_pipeline::PixelWindow; let mut s = crate::Session::with_demo(); let p = s.catalog.photos().next().unwrap().clone(); let (w, h) = (p.width as usize, p.height as usize); assert!(w.max(h) > 2560, "demo photos are camera-sized"); let win = PixelWindow { x: w / 3, y: h / 3, w: 300, h: 200 }; let job = s.region_job(p.id, w, h, win, true).unwrap(); assert_eq!(job.level, SourceLevel::Full, "a 1:1 window reads the original"); let img = job.run().rendered.unwrap().image; assert_eq!((img.width, img.height), (300, 200)); // other windows, and the whole-frame job, never share a key let other = s.region_job(p.id, w, h, PixelWindow { x: win.x + 64, ..win }, true).unwrap(); let whole = s.render_job(p.id, w, h, false, true).unwrap(); let again = s.region_job(p.id, w, h, win, true).unwrap(); assert_ne!(other.key, again.key); assert_ne!(whole.key, again.key); assert_eq!(s.region_job(p.id, w, h, win, true).unwrap().key, again.key, "the same window keeps its key"); // a window never writes the photo's view preview or the thumbnail cache assert!(again.view_cache.is_none() && again.cache.is_none()); } // Issue #323: a tight crop shown at its own pixels needs the original, not the 2560 px preview #[test] fn a_tight_crop_reads_the_source_level_its_pixels_need() { let mut s = crate::Session::with_demo(); let p = s.catalog.photos().next().unwrap().clone(); let long = p.width.max(p.height) as usize; assert!(long > 2560); let id = p.id; let mut d = (*s.develop_of(id).unwrap()).clone(); // the uncropped photo at 1600 px: the preview is plenty assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview); // a crop to 30 % of each side shown 1600 px long needs 1600 / 0.3 px of the source d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.2, y0: 0.2, x1: 0.5, y1: 0.5 }; s.set_develop(id, d, "Crop").unwrap(); assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Full); // …but with the crop tool open (the whole photo shown) it does not assert_eq!(s.render_job(id, 1600, 1600, false, false).unwrap().level, SourceLevel::Preview); // and a crop shown small enough still reads the preview assert_eq!(s.render_job(id, 600, 600, false, true).unwrap().level, SourceLevel::Preview); } // a window whose spot reads from further away than a render can hold is refused #[test] fn a_region_job_is_refused_when_a_spot_reads_beyond_what_fits() { use lightcraft_pipeline::PixelWindow; let mut s = crate::Session::with_demo(); let id = s.active().unwrap(); let p = s.catalog.photo(id).unwrap().clone(); let (w, h) = (p.width as usize * 8, p.height as usize * 8); let mut d = (*s.develop_of(id).unwrap()).clone(); d.spots.push(lightcraft_develop::Spot { points: vec![lightcraft_geom::Point::new(0.2, 0.5)], size: 0.01, source_offset: Some(lightcraft_geom::Point::new(0.6, 0.0)), ..Default::default() }); s.set_develop(id, d, "Spot").unwrap(); let win = PixelWindow { x: (w as f64 * 0.2) as usize - 100, y: h / 2 - 100, w: 400, h: 300 }; assert!(s.region_job(id, w, h, win, true).is_none()); // a window elsewhere is fine assert!(s.region_job(id, w, h, PixelWindow { x: 5000, y: 5000, w: 400, h: 300 }, true).is_some()); } // Issue #323: the Before side of a Before/After view at 1:1 is a window too, of the photo // without its edits (the crop is kept), with a key of its own #[test] fn a_before_window_shows_the_unedited_look() { use lightcraft_pipeline::PixelWindow; let mut s = crate::Session::with_demo(); let id = s.active().unwrap(); let p = s.catalog.photo(id).unwrap().clone(); let (w, h) = (p.width as usize, p.height as usize); let mut d = (*s.develop_of(id).unwrap()).clone(); d.light.exposure = 2.0; s.set_develop(id, d, "Exposure").unwrap(); let win = PixelWindow { x: w / 3, y: h / 3, w: 200, h: 150 }; let after = s.region_job(id, w, h, win, true).unwrap(); let before = s.region_job_before(id, w, h, win, true).unwrap(); assert_ne!(after.key, before.key); let (a, b) = (after.run().rendered.unwrap().image, before.run().rendered.unwrap().image); assert_eq!((a.width, a.height), (b.width, b.height)); let mean = |i: &Rgba8| i.data.iter().map(|p| p[1] as f64).sum::() / i.data.len() as f64; assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); } // LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of // the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and // at a corner, with tone, colour and detail edits on a camera-sized photo #[test] fn one_to_one_window_pixels_are_export_pixels() { use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow}; let mut s = crate::Session::with_demo(); let id = s.active().unwrap(); let p = s.catalog.photo(id).unwrap().clone(); let (w, h) = (p.width as usize, p.height as usize); assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}"); let mut d = (*s.develop_of(id).unwrap()).clone(); d.light.exposure = 0.7; d.light.contrast = 25.0; d.color.saturation = 20.0; s.set_develop(id, d, "Edits").unwrap(); let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; assert_eq!((export.width, export.height), (w, h), "a full-size export"); for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] { let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image; assert_eq!((tile.width, tile.height), (win.w, win.h)); let mut worst = 0u8; for y in 0..win.h { for x in 0..win.w { let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]); for k in 0..3 { worst = worst.max(a[k].abs_diff(b[k])); } } } assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels"); } } /// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here /// one nothing can decode) is not read again. #[test] fn a_thumbnail_is_cut_from_the_decoded_preview() { let mut s = crate::Session::new(); let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); let job = s.thumb_job(p.id, 512).unwrap(); assert!(matches!(job.source, SourceRef::Fit { .. })); let r = job.run(); let thumb = r.rendered.as_ref().unwrap(); assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); s.accept(&r); let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); } #[test] fn has_source_follows_what_the_session_accepted() { let mut s = crate::Session::with_demo(); let id = s.active().unwrap(); let p = s.catalog.photo(id).unwrap().clone(); let long = p.width.max(p.height) as usize; assert!(!s.media.has_source(id, SourceLevel::Full)); let r = s.render_job(id, long, long, false, true).unwrap().run(); s.accept(&r); assert!(s.media.has_source(id, SourceLevel::Full)); assert!(!s.media.has_source(id, SourceLevel::Preview)); assert!(!s.media.has_source(PhotoId(id.0 + 1), SourceLevel::Full)); } #[test] fn variant_jobs_have_their_own_keys_and_hit_the_cache() { let mut s = crate::Session::with_demo(); let id = s.active().unwrap(); let mine = (*s.develop_of(id).unwrap()).clone(); let with = |pid: &str| { let mut d = mine.clone(); d.profile.id = pid.into(); d }; let (a, b) = (with("lc.vivid"), with("lc.bw.sepia")); let ja = s.variant_job(id, &a, 128).unwrap(); let jb = s.variant_job(id, &b, 128).unwrap(); assert_ne!(ja.key, jb.key, "keys differ per settings"); assert_eq!(ja.key, s.variant_job(id, &a, 128).unwrap().key, "stable"); assert_ne!(ja.key, s.variant_job(id, &a, 256).unwrap().key, "and per size"); assert_ne!(ja.key, s.thumb_job(id, 128).unwrap().key, "not the photo's own thumbnail"); assert_eq!(ja.level, SourceLevel::Thumb); let (cache, ck) = ja.cache.clone().unwrap(); assert!(cache.get(ck).is_none()); let r = ja.run(); s.accept(&r); let first = r.rendered.unwrap().image; assert!(first.width.max(first.height) <= 128); assert!(cache.get(ck).is_some(), "rendered variants are cached"); assert!(cache.get(jb.cache.as_ref().unwrap().1).is_none()); // a second job for the same variant is served from the cache, pixel for pixel let again = s.variant_job(id, &a, 128).unwrap().run(); assert!(again.loaded.is_none()); assert_eq!(again.rendered.unwrap().image.as_bytes(), first.as_bytes()); let other = jb.run().rendered.unwrap().image; assert_ne!(other.as_bytes(), first.as_bytes()); // the photo's own settings never changed assert_eq!(*s.develop_of(id).unwrap(), mine); // hover previews: a loupe-sized job with other settings, its own key let pv = s.preview_job(id, 800, 600, true, &a).unwrap(); assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); assert_eq!(*pv.settings, a); } }