mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
Raw variants the decoder can't read yet (e.g. Nikon Huffman-compressed NEF from a D3200/D5100) silently fell back to the camera's embedded JPEG, so users edited a JPEG with the camera's picture style (e.g. Monochrome) baked in without being told. - catalog: Photo.preview_only (serde default; old catalogs load), set / cleared by Op::SetContent with an exact inverse; Photo::develops_raw(). - engine: ProbeInfo.preview_only carries the decoder's Unsupported reason into import, the import review (previewOnly) and catalog.query (previewOnly); photo.inspect includes it. photo.reload and photo.relink re-probe and clear/set it when the file decodes (or not) now. - source_info: a preview-only raw is a rendered source (relative WB scale, display tone curve) instead of raw:true/5500 K + filmic tone map on a display-referred JPEG; import uses the JPEG default preset for it; the Edit panel shows the rendered-file WB sliders. - UI: "Preview" badge on grid thumbnails, a pill on the loupe, a notice in Edit and Info (amber caution token, Info icon drawn in code). - Tests: catalog serde/undo, engine import -> reopen -> reload/relink, headless UI badge/notice widgets. Refs #10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
876 lines
38 KiB
Rust
876 lines
38 KiB
Rust
//! 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::sync::Arc;
|
||
|
||
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
|
||
use lightcraft_develop::DevelopSettings;
|
||
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
|
||
use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||
pub const RENDER_CACHE_VERSION: u64 = 4;
|
||
|
||
/// 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];
|
||
|
||
/// 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,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app).
|
||
pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), String> + Send + Sync>;
|
||
|
||
/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than
|
||
/// `max_edge` (set by the app). `None`: no usable preview.
|
||
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
|
||
|
||
#[derive(Clone)]
|
||
pub enum SourceRef {
|
||
Loaded(Arc<Rgb32f>),
|
||
Demo {
|
||
scene: Box<lightcraft_scenes::Scene>,
|
||
max_edge: usize,
|
||
},
|
||
File {
|
||
path: String,
|
||
max_edge: usize,
|
||
loader: Option<FileLoader>,
|
||
},
|
||
/// A smart preview standing in for a missing original.
|
||
Smart {
|
||
path: std::path::PathBuf,
|
||
},
|
||
}
|
||
|
||
impl SourceRef {
|
||
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
|
||
match self {
|
||
SourceRef::Loaded(a) => Ok(a.clone()),
|
||
SourceRef::Demo { scene, max_edge } => Ok(Arc::new(scene.render_fit(*max_edge))),
|
||
SourceRef::File { path, max_edge, loader } => match loader {
|
||
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
|
||
None => Err(format!("no decoder available for {path}")),
|
||
},
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
SourceRef::Smart { path } => crate::smart::load(path),
|
||
#[cfg(target_arch = "wasm32")]
|
||
SourceRef::Smart { .. } => Err("smart previews are not available here".into()),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub struct MediaCache {
|
||
/// Decoded thumbnail-level sources (LRU by bytes).
|
||
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
|
||
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
|
||
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
|
||
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
|
||
full: Option<(PhotoId, Arc<Rgb32f>, u64)>,
|
||
/// 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<FileLoader>,
|
||
pub file_probe: Option<FileProbe>,
|
||
pub preview_loader: Option<PreviewLoader>,
|
||
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
|
||
pub file_bytes: Option<crate::merge::ByteReader>,
|
||
/// The library's smart previews folder (originals offline: render from the proxy).
|
||
pub smart_dir: Option<std::path::PathBuf>,
|
||
scenes: Vec<lightcraft_scenes::Scene>,
|
||
/// Rendered thumbnails (memory, plus disk once a library is attached).
|
||
pub rendered: Arc<PreviewCache>,
|
||
}
|
||
|
||
impl Default for MediaCache {
|
||
fn default() -> Self {
|
||
let budget = crate::memory::cache_share(crate::memory::budget());
|
||
MediaCache {
|
||
thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)),
|
||
previews: Vec::new(),
|
||
full: None,
|
||
preview_capacity: 0,
|
||
budget,
|
||
file_loader: None,
|
||
file_probe: None,
|
||
preview_loader: None,
|
||
file_bytes: None,
|
||
smart_dir: None,
|
||
scenes: Vec::new(),
|
||
rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Memory for rendered previews out of the cache share.
|
||
fn rendered_budget(share: usize) -> usize {
|
||
RENDERED_MEM_BYTES.min(share / 4)
|
||
}
|
||
|
||
fn source_bytes(img: &Rgb32f) -> usize {
|
||
img.data.len() * 12 + 64
|
||
}
|
||
|
||
impl MediaCache {
|
||
/// Keep rendered thumbnails on disk in `dir` as well.
|
||
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
|
||
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
|
||
}
|
||
|
||
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
|
||
pub fn clear_sources(&mut self) {
|
||
self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget));
|
||
self.previews.clear();
|
||
self.full = None;
|
||
}
|
||
|
||
/// 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.
|
||
pub fn enforce_budget(&mut self) {
|
||
let mut held = self.held();
|
||
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.as_ref().map(|e| (e.2, 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| source_bytes(&self.previews.remove(i).1)),
|
||
2 => self.full.take().map(|e| source_bytes(&e.1)),
|
||
_ => 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<Arc<Rgb32f>> {
|
||
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.as_mut().filter(|e| e.0 == id).map(|e| {
|
||
e.2 = lightcraft_preview::next_tick();
|
||
e.1.clone()
|
||
}),
|
||
}
|
||
}
|
||
|
||
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
|
||
let tick = lightcraft_preview::next_tick();
|
||
match level {
|
||
SourceLevel::Thumb => {
|
||
let cost = source_bytes(&img);
|
||
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 = Some((id, img, tick)),
|
||
}
|
||
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);
|
||
if self.full.as_ref().is_some_and(|e| e.0 == id) {
|
||
self.full = None;
|
||
}
|
||
}
|
||
|
||
/// (decoded thumbnail sources, bytes).
|
||
pub fn source_usage(&self) -> (usize, usize) {
|
||
(self.thumbs.len(), self.thumbs.cost())
|
||
}
|
||
|
||
/// 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| source_bytes(&e.1)).sum());
|
||
let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default();
|
||
(Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full)
|
||
}
|
||
|
||
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
|
||
if let Some(a) = self.get(p.id, level) {
|
||
return SourceRef::Loaded(a);
|
||
}
|
||
// 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
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let (Source::File { path }, Some(dir)) = (&p.source, &self.smart_dir)
|
||
&& !std::path::Path::new(path).exists()
|
||
{
|
||
let sp = dir.join(crate::smart::file_name(p));
|
||
if sp.exists() {
|
||
return SourceRef::Smart { path: sp };
|
||
}
|
||
}
|
||
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 },
|
||
}
|
||
}
|
||
Source::File { path } => SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone() },
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Everything needed to render one photo, detached from the session.
|
||
#[derive(Clone)]
|
||
pub struct RenderJob {
|
||
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<DevelopSettings>,
|
||
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<PreviewCache>, 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<Arc<StageCache>>,
|
||
/// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]).
|
||
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||
}
|
||
|
||
pub struct RenderResult {
|
||
pub photo: PhotoId,
|
||
pub level: SourceLevel,
|
||
pub key: u64,
|
||
pub rendered: Result<Rendered, String>,
|
||
/// A source that was loaded by this job (to be inserted into the cache).
|
||
pub loaded: Option<Arc<Rgb32f>>,
|
||
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
|
||
pub quick: Option<QuickSource>,
|
||
}
|
||
|
||
impl RenderJob {
|
||
/// 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<lightcraft_pipeline::Proof>) -> Self {
|
||
if self.request.proof != proof {
|
||
let k = |p: Option<lightcraft_pipeline::Proof>| 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
|
||
}
|
||
|
||
/// Reuse `stages` across this view's renders.
|
||
pub fn with_stages(mut self, stages: Arc<StageCache>) -> Self {
|
||
self.stages = Some(stages);
|
||
self
|
||
}
|
||
|
||
pub fn run(self) -> RenderResult {
|
||
if let Some((cache, key)) = &self.cache
|
||
&& let Some(img) = cache.get(*key)
|
||
{
|
||
let image = Arc::unwrap_or_clone(img);
|
||
let histogram = Histogram::of_srgb8(&image);
|
||
return RenderResult {
|
||
photo: self.photo,
|
||
level: self.level,
|
||
key: self.key,
|
||
rendered: Ok(Rendered { image, histogram, deep: None }),
|
||
loaded: None,
|
||
quick: None,
|
||
};
|
||
}
|
||
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
|
||
match self.source.load() {
|
||
Ok(src) => {
|
||
// 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 rendered = develop(&src, &self.info, &self.settings, &self.request, self.stages.as_deref(), gpu);
|
||
if let Some((cache, key)) = &self.cache {
|
||
cache.put(*key, Arc::new(rendered.image.clone()));
|
||
}
|
||
if let Some((cache, key)) = &self.view_cache
|
||
&& self.request.quality == Quality::Full
|
||
{
|
||
cache.put_deferred(*key, Arc::new(rendered.image.clone()));
|
||
}
|
||
RenderResult {
|
||
photo: self.photo,
|
||
level: self.level,
|
||
key: self.key,
|
||
rendered: Ok(rendered),
|
||
loaded: (!was_loaded).then_some(src),
|
||
quick: None,
|
||
}
|
||
}
|
||
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None, quick: None },
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered {
|
||
// LUT profiles have no GPU stage: they render on the CPU
|
||
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()
|
||
&& let Some(r) = lightcraft_gpu::render(src, info, s, req, stages)
|
||
{
|
||
return r;
|
||
}
|
||
match stages {
|
||
Some(st) => lightcraft_pipeline::render_cached(src, info, s, req, st),
|
||
None => lightcraft_pipeline::render(src, info, s, req),
|
||
}
|
||
}
|
||
|
||
/// Where a [`QuickJob`]'s stand-in image came from.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum QuickSource {
|
||
/// The photo's own develop render for its current settings (cached view or thumbnail render).
|
||
Cached,
|
||
/// The camera's embedded JPEG (unedited raws).
|
||
Embedded,
|
||
/// A thumbnail-level develop render made for the occasion.
|
||
Small,
|
||
}
|
||
|
||
/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached
|
||
/// renders, the embedded preview of an unedited raw, a thumbnail-level render.
|
||
#[derive(Clone)]
|
||
pub struct QuickJob {
|
||
pub photo: PhotoId,
|
||
pub key: u64,
|
||
/// Cached renders to try first, in order: (cache, key).
|
||
pub cached: Vec<(Arc<PreviewCache>, Hash128)>,
|
||
/// Embedded preview of an unedited raw: (path, loader, max edge).
|
||
pub embedded: Option<(String, PreviewLoader, usize)>,
|
||
/// Last resort: run this thumbnail job.
|
||
pub small: Option<Box<RenderJob>>,
|
||
}
|
||
|
||
impl QuickJob {
|
||
pub fn run(self) -> RenderResult {
|
||
let (photo, key) = (self.photo, self.key);
|
||
let done = |rendered: Result<Rendered, String>, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick };
|
||
let rendered = |image: Rgba8| {
|
||
let histogram = Histogram::of_srgb8(&image);
|
||
Ok(Rendered { image, histogram, 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 { 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.
|
||
if matches!(p.source, Source::Demo { .. }) {
|
||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None };
|
||
}
|
||
// 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() { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, lens: p.embedded_lens } } 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<RenderJob> {
|
||
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<RenderJob> {
|
||
let b = THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(THUMB_SIZES[THUMB_SIZES.len() - 1]);
|
||
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<usize>,
|
||
) -> Option<RenderJob> {
|
||
let p = self.catalog.photo(id)?.clone();
|
||
let level = SourceLevel::for_size(max_w.max(max_h));
|
||
let source = self.media.source_ref(&p, level);
|
||
let settings = if before { Arc::new(self.before_settings(&p)) } else { p.develop.clone() };
|
||
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 = Hasher128::new().str(&content_key(&p)).finish().0 as u64;
|
||
let key = settings.hash64()
|
||
^ ((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);
|
||
let cache = thumb_bucket.map(|b| {
|
||
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
|
||
(self.media.rendered.clone(), k)
|
||
});
|
||
Some(RenderJob {
|
||
photo: id,
|
||
level,
|
||
source,
|
||
origin: p.source.clone(),
|
||
info: source_info(&p),
|
||
settings,
|
||
request,
|
||
key,
|
||
cache,
|
||
stages: None,
|
||
view_cache: 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<RenderJob> {
|
||
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).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<RenderJob> {
|
||
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 {
|
||
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,
|
||
})
|
||
}
|
||
|
||
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
|
||
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
|
||
Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish()
|
||
}
|
||
|
||
/// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the
|
||
/// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`].
|
||
pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option<RenderJob> {
|
||
let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?;
|
||
let p = self.catalog.photo(id)?;
|
||
job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop)));
|
||
Some(job)
|
||
}
|
||
|
||
/// The embedded preview of an unedited raw (path, loader), when the app installed a loader.
|
||
fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> {
|
||
match (&p.source, &self.media.preview_loader) {
|
||
(Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view
|
||
/// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail.
|
||
pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option<QuickJob> {
|
||
let p = self.catalog.photo(id)?.clone();
|
||
let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?;
|
||
// (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview)
|
||
let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))];
|
||
let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish();
|
||
let key = h.0 as u64;
|
||
let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge())));
|
||
Some(QuickJob { photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) })
|
||
}
|
||
|
||
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
|
||
/// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries
|
||
/// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for
|
||
/// other photos (render them directly).
|
||
pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option<QuickJob> {
|
||
let p = self.catalog.photo(job.photo)?.clone();
|
||
let (path, l) = self.embedded_of(&p)?;
|
||
let edge = job.request.max_w.max(job.request.max_h);
|
||
Some(QuickJob {
|
||
photo: job.photo,
|
||
key: job.key,
|
||
cached: job.cache.clone().into_iter().collect(),
|
||
embedded: Some((path, l, edge)),
|
||
small: None,
|
||
})
|
||
}
|
||
|
||
/// Accept a finished job's loaded source into the cache.
|
||
pub fn accept(&mut self, r: &RenderResult) {
|
||
if let Some(src) = &r.loaded {
|
||
self.media.insert(r.photo, r.level, src.clone());
|
||
}
|
||
}
|
||
|
||
/// Synchronous render (CLI, MCP, tests).
|
||
pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result<Rendered, String> {
|
||
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<Rendered, String> {
|
||
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<RenderJob, String> {
|
||
let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
|
||
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)
|
||
}
|
||
|
||
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
|
||
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
|
||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||
let r = self.media.source_ref(&p, level).load()?;
|
||
self.media.insert(id, level, r.clone());
|
||
Ok(r)
|
||
}
|
||
}
|
||
|
||
/// 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<String>,
|
||
pub meta: lightcraft_catalog::Meta,
|
||
pub as_shot_wb: Option<(f64, f64)>,
|
||
/// Hash of the file's bytes (hex), for duplicate detection.
|
||
pub content_hash: Option<String>,
|
||
/// Lens corrections embedded in the file (DNG opcodes).
|
||
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
||
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
|
||
pub xmp: Option<String>,
|
||
/// 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<String>,
|
||
}
|
||
|
||
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|