diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 98c8f4faee..b5f6b9c752 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -112,6 +112,9 @@ pub fn import_batch_done(s: &mut Session, report: crate::import::ImportReport, m s.execute("album.addPhotos", &json!({"id": a, "ids": imported}))?; report["album"] = json!(a); } + // the previews the library builds at import (library.previewSettings atImport) + let ids: Vec = imported.iter().map(|i| PhotoId(*i)).collect(); + crate::cmd::previews::build_after_import(s, &ids); Ok(report) } diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 1f04f91778..b4778ddac3 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -1,4 +1,4 @@ -//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size, +//! Build previews ahead of time: each photo's grlet ids: Vec = ids.iter().map(|id| id.0).collect(); thumbnail and its loupe view (standard size, //! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant. //! Runs on a background thread (the app keeps working; progress / cancel by command), or inline //! with `wait` (CLI, MCP, tests). @@ -15,6 +15,105 @@ use crate::{Result, Session}; /// The long edge of a standard-sized preview. pub const STANDARD_EDGE: usize = 2048; +/// When 1:1 previews are discarded on their own (the catalog setting "Automatically discard 1:1 +/// previews"): this long after they were built or last shown. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DiscardAfter { + Day, + Week, + #[default] + Month, + Never, +} + +impl DiscardAfter { + pub fn parse(s: &str) -> Option { + Some(match s { + "day" => DiscardAfter::Day, + "week" => DiscardAfter::Week, + "month" => DiscardAfter::Month, + "never" => DiscardAfter::Never, + _ => return None, + }) + } + + pub fn as_str(self) -> &'static str { + match self { + DiscardAfter::Day => "day", + DiscardAfter::Week => "week", + DiscardAfter::Month => "month", + DiscardAfter::Never => "never", + } + } + + /// The age past which a 1:1 preview goes (`None`: never). + pub fn max_age(self) -> Option { + let day = 24 * 3600; + match self { + DiscardAfter::Day => Some(std::time::Duration::from_secs(day)), + DiscardAfter::Week => Some(std::time::Duration::from_secs(7 * day)), + DiscardAfter::Month => Some(std::time::Duration::from_secs(30 * day)), + DiscardAfter::Never => None, + } + } +} + +/// Which previews an import builds once its photos are in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImportPreviews { + /// Thumbnails only, as the grid asks for them. + #[default] + Minimal, + Standard, + Full, +} + +impl ImportPreviews { + pub fn parse(s: &str) -> Option { + Some(match s { + "minimal" => ImportPreviews::Minimal, + "standard" => ImportPreviews::Standard, + "full" | "1:1" => ImportPreviews::Full, + _ => return None, + }) + } + + pub fn as_str(self) -> &'static str { + match self { + ImportPreviews::Minimal => "minimal", + ImportPreviews::Standard => "standard", + ImportPreviews::Full => "full", + } + } +} + +/// The preview store's settings, per library (saved in its `prefs.json`). +// TODO(P1.5): move into the catalog's own settings once its storage rework lands. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct PreviewPrefs { + /// Long edge of standard-sized previews (0 = [`STANDARD_EDGE`]). + pub standard_edge: u32, + pub discard_full: DiscardAfter, + pub at_import: ImportPreviews, +} + +impl PreviewPrefs { + pub fn standard_edge(&self) -> usize { + if self.standard_edge == 0 { STANDARD_EDGE } else { (self.standard_edge as usize).clamp(256, 8192) } + } + + pub fn json(&self) -> Value { + json!({ + "standardEdge": self.standard_edge(), + "discardFull": self.discard_full.as_str(), + "atImport": self.at_import.as_str(), + }) + } +} + /// A preview build in progress. #[derive(Debug, Default)] pub struct PreviewBuild { @@ -53,12 +152,20 @@ impl PreviewBuild { /// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at /// `edge` (`None` = 1:1, the photo's full size). fn jobs_for(s: &mut Session, id: dac_catalog::PhotoId, edge: Option) -> Vec { - let Some(p) = s.catalog.photo(id) else { return Vec::new() }; + let Some(p) = s.catalog.photo(id).cloned() else { return Vec::new() }; let full = p.width.max(p.height) as usize; let e = edge.unwrap_or(full).min(full.max(1)).max(1); let mut v = Vec::new(); v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])); - v.extend(s.loupe_job(id, e, e, true)); + let mut view = s.loupe_job(id, e, e, true); + // a 1:1 preview is kept under its own key, to be discarded on its own + if edge.is_none() + && let Some(j) = view.as_mut() + && let Some((_, key)) = j.view_cache.as_mut() + { + *key = Session::full_view_key(&p); + } + v.extend(view); v } @@ -79,13 +186,15 @@ fn run_all(jobs: Vec>, state: &PreviewBuild) { fn build(s: &mut Session, p: &Value) -> Result { const C: &str = "library.buildPreviews"; let edge = match str_param(p, "size").unwrap_or("standard") { - "standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)), + "standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(s.preview_prefs.standard_edge(), |e| e.clamp(256, 8192) as usize)), "full" | "1:1" => None, other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))), }; if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) { return Err(bad(C, "a preview build is already running")); } + // 1:1 previews past their time go first (the build may need the room) + auto_discard(s); // explicit ids, else the selection, else everything in view let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; let jobs: Vec> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); @@ -107,6 +216,100 @@ fn build(s: &mut Session, p: &Value) -> Result { Ok(state.json()) } +/// What discarding 1:1 previews did. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Discarded { + /// 1:1 previews removed. + pub removed: usize, + /// 1:1 previews still kept (of the photos looked at). + pub kept: usize, +} + +/// Discard the 1:1 previews of `ids` (memory and disk): all of them, or with `older_than` only +/// those not built or shown for that long. A preview held only in memory (no library on disk) has +/// no age and is kept unless all go. +pub fn discard_full(s: &Session, ids: &[dac_catalog::PhotoId], older_than: Option) -> Discarded { + let cache = &s.media.rendered; + let now = std::time::SystemTime::now(); + let mut d = Discarded::default(); + for id in ids { + let Some(p) = s.catalog.photo(*id) else { continue }; + let key = Session::full_view_key(p); + if !cache.contains(key) { + continue; + } + let stale = match older_than { + None => true, + Some(age) => cache.disk().and_then(|disk| disk.modified(key)).and_then(|m| now.duration_since(m).ok()).is_some_and(|a| a >= age), + }; + if stale && cache.remove(key) { + d.removed += 1; + } else { + d.kept += 1; + } + } + d +} + +/// Apply the library's "discard 1:1 previews after" setting to every photo. +pub fn auto_discard(s: &Session) -> Discarded { + let Some(age) = s.preview_prefs.discard_full.max_age() else { return Discarded::default() }; + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + discard_full(s, &ids, Some(age)) +} + +fn discard(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.discardPreviews"; + match str_param(p, "size").unwrap_or("full") { + "full" | "1:1" => {} + other => return Err(bad(C, format!("unknown size `{other}` (only full: standard previews go with library.clearPreviews)"))), + } + let d = if p.get("auto").and_then(Value::as_bool) == Some(true) { + auto_discard(s) + } else { + let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + discard_full(s, &ids, None) + }; + Ok(json!({"removed": d.removed, "kept": d.kept})) +} + +fn settings(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.previewSettings"; + let mut next = s.preview_prefs; + if let Some(v) = p.get("standardEdge") { + let e = v.as_u64().filter(|e| (256..=8192).contains(e)).ok_or_else(|| bad(C, "standardEdge must be 256–8192 px"))?; + next.standard_edge = e as u32; + } + if let Some(v) = p.get("discardFull") { + next.discard_full = v.as_str().and_then(DiscardAfter::parse).ok_or_else(|| bad(C, "discardFull must be day, week, month or never"))?; + } + if let Some(v) = p.get("atImport") { + next.at_import = v.as_str().and_then(ImportPreviews::parse).ok_or_else(|| bad(C, "atImport must be minimal, standard or full"))?; + } + if next != s.preview_prefs { + s.preview_prefs = next; + s.save_prefs()?; + } + Ok(s.preview_prefs.json()) +} + +/// The previews an import asks for ([`PreviewPrefs::at_import`]) of the photos it brought in, +/// built in the background like Build Previews. Errors are logged: the import itself is done. +pub fn build_after_import(s: &mut Session, ids: &[dac_catalog::PhotoId]) { + let size = match s.preview_prefs.at_import { + ImportPreviews::Minimal => return, + ImportPreviews::Standard => "standard", + ImportPreviews::Full => "full", + }; + if ids.is_empty() { + return; + } + let ids: Vec = ids.iter().map(|id| id.0).collect(); + if let Err(e) = build(s, &json!({"size": size, "ids": ids})) { + log::warn!("previews at import: {e}"); + } +} + /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, /// how to load the original at preview size). #[cfg(not(target_arch = "wasm32"))] @@ -349,6 +552,24 @@ pub fn specs() -> Vec { always, build ), + cmd!( + "library.discardPreviews", + "Discard 1:1 Previews", + [], + None, + "{size?: full, ids?, auto?: bool} — discard the 1:1 previews of the selected photos (else all in view), or with auto those past the library's discardFull age (library.previewSettings); standard previews stay → {removed, kept}", + always, + discard + ), + cmd!( + "library.previewSettings", + "Preview Settings", + [], + None, + "{standardEdge?: 256–8192 px, discardFull?: day|week|month|never, atImport?: minimal|standard|full} — the library's preview store: the size of standard previews, when 1:1 previews are discarded on their own, and which previews an import builds (saved with the library) → {standardEdge, discardFull, atImport}", + always, + settings + ), cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, repaired, running, cancelled, what, error} | null", always, |s, _| { Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json())) }), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 55009b96c7..6f1bdf611b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -182,6 +182,8 @@ pub struct Session { /// Untouched Local records of folders not browsed for this many days are forgotten when the /// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`. pub forget_local_days: u32, + /// The preview store settings (persisted in prefs.json). + pub preview_prefs: cmd::previews::PreviewPrefs, } impl Default for Session { @@ -258,6 +260,7 @@ impl Session { cache_mb: 0, smart_previews_dir: None, forget_local_days: dac_catalog::DEFAULT_FORGET_DAYS, + preview_prefs: Default::default(), } } @@ -776,6 +779,8 @@ mod tests_prefs; #[cfg(test)] mod tests_preset_files; #[cfg(test)] +mod tests_previews; +#[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 8cd000352b..f1fc66cc98 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -169,6 +169,8 @@ struct PrefsFile { /// Days after which untouched Local records of unbrowsed folders are forgotten (missing = /// the default, 0 = never). forget_local_days: Option, + /// Preview store settings (standard size, 1:1 discard, previews built at import). + previews: crate::cmd::previews::PreviewPrefs, } fn presets_json(s: &Session) -> String { @@ -352,6 +354,7 @@ impl Session { self.recent_keywords = prefs.recent_keywords; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; + self.preview_prefs = prefs.previews; self.forget_local_days = prefs.forget_local_days.unwrap_or(dac_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -583,6 +586,7 @@ impl Session { cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), + previews: self.preview_prefs, }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b69feaf211..95a81a4b29 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1082,6 +1082,13 @@ impl crate::Session { .finish() } + /// Key of the photo's 1:1 preview (Build 1:1 Previews): kept apart from the standard view + /// preview so it can be discarded on its own (`library.discardPreviews`). + pub(crate) fn full_view_key(p: &Photo) -> Hash128 { + let v = Self::view_key(p, true).0; + Hasher128::new().u64(v as u64).u64((v >> 64) as u64).str("1:1").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 { @@ -1179,7 +1186,11 @@ impl crate::Session { 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 mut cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))]; + // a 1:1 preview (Build 1:1 Previews) stands in when there is no standard one + if apply_crop { + cached.push((self.media.rendered.clone(), Self::full_view_key(&p))); + } 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()))); diff --git a/crates/engine/src/tests_previews.rs b/crates/engine/src/tests_previews.rs new file mode 100644 index 0000000000..cbc86ef340 --- /dev/null +++ b/crates/engine/src/tests_previews.rs @@ -0,0 +1,185 @@ +//! True 1:1 (P1.6): a window of the loupe at 1:1 has the pixels of the full-size export, and the +//! preview store's 1:1 previews, settings and discards. + +use dac_pipeline::{OutputDepth, OutputSpace, PixelWindow}; +use serde_json::json; + +use crate::Session; +use crate::cmd::previews::{DiscardAfter, discard_full}; + +/// The largest per-channel difference between the `visible` pixels of `part` (a render of +/// window `win`) and the same pixels of `full`. +fn max_diff(full: &dac_raster::Rgba8, part: &dac_raster::Rgba8, win: PixelWindow, visible: PixelWindow) -> u8 { + let mut worst = 0u8; + for y in visible.y..visible.y + visible.h { + for x in visible.x..visible.x + visible.w { + let a = part.data[(y - win.y) * part.width + (x - win.x)]; + let b = full.data[y * full.width + x]; + for c in 0..3 { + worst = worst.max(a[c].abs_diff(b[c])); + } + } + } + worst +} + +/// Render photo `id` at full size for export and windows of its 1:1 view, and compare them. +fn check_one_to_one(s: &mut Session, id: dac_catalog::PhotoId) { + let p = s.catalog.photo(id).unwrap().clone(); + let export = s.render_export(id, p.width as usize, p.height as usize, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; + let (w, h) = (export.width, export.height); + assert!(w.max(h) >= (p.width.max(p.height) as usize) - 1, "export is full size: {w}×{h} of {}×{}", p.width, p.height); + // the 1:1 frame is the export's frame: the loupe asks for windows of (w, h), with the margin + // of context it renders around what is on screen (as `region::margin_for` in the UI) + let margin = ((w.max(h) as f64 * 0.045).min(768.0) as usize).div_ceil(256).max(1) * 256; + for (fx, fy) in [(0.0, 0.0), (0.37, 0.52), (0.8, 0.7)] { + let x = ((w as f64 * fx) as usize).min(w.saturating_sub(512)); + let y = ((h as f64 * fy) as usize).min(h.saturating_sub(384)); + let visible = PixelWindow { x, y, w: 512.min(w), h: 384.min(h) }; + let (wx, wy) = (x.saturating_sub(margin), y.saturating_sub(margin)); + let win = PixelWindow { x: wx, y: wy, w: (x + visible.w + margin).min(w) - wx, h: (y + visible.h + margin).min(h) - wy }; + let job = s.region_job(id, w, h, win, true).expect("a window of the 1:1 view"); + let part = job.run().rendered.unwrap().image; + assert_eq!((part.width, part.height), (win.w, win.h)); + let d = max_diff(&export, &part, win, visible); + assert!(d <= 2, "1:1 window {win:?} differs from the export by {d} levels"); + } +} + +// Given a procedural photo with edits, every 1:1 window equals the full-resolution export +#[test] +fn one_to_one_pixels_equal_full_resolution_export_pixels() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); + s.execute("develop.set", &json!({"control": "effects.clarity", "value": 20})).unwrap(); + s.execute("develop.set", &json!({"control": "detail.sharpenAmount", "value": 60})).ok(); + check_one_to_one(&mut s, id); +} + +// Given corpus raws (`cargo xtask corpus --download`), the same holds for real files; skipped +// cleanly without them +#[test] +fn one_to_one_pixels_equal_export_pixels_on_corpus_raws() { + let dir = std::env::var_os("DAC_CORPUS_RAW") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/raw")); + let Ok(rd) = std::fs::read_dir(&dir) else { + eprintln!("skipped: no raw corpus at {}", dir.display()); + return; + }; + let files: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).take(3).collect(); + if files.is_empty() { + eprintln!("skipped: empty raw corpus"); + return; + } + let mut s = Session::new(); + let paths: Vec = files.iter().map(|p| p.to_string_lossy().to_string()).collect(); + s.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + for id in ids { + check_one_to_one(&mut s, id); + } +} + +// Given 1:1 previews built, they are kept apart from the standard ones and discarded on their own +#[test] +fn one_to_one_previews_are_discarded_on_their_own() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + s.execute("library.buildPreviews", &json!({"size": "full", "ids": [id.0], "wait": true})).unwrap(); + let full = Session::full_view_key(&p); + // (the view preview is written once the render hands it over) + assert!(s.media.rendered.contains(full), "1:1 preview built"); + let img = s.media.rendered.get(full).unwrap(); + assert_eq!(img.width.max(img.height), p.width.max(p.height) as usize, "at the photo's own size"); + // a month's policy keeps a fresh one + assert_eq!(discard_full(&s, &[id], DiscardAfter::Month.max_age()).removed, 0); + let r = s.execute("library.discardPreviews", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["removed"], 1); + assert!(!s.media.rendered.contains(full)); + let r = s.execute("library.discardPreviews", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["removed"], 0, "nothing left to discard"); +} + +// Given the preview settings, they are validated, applied and reported +#[test] +fn preview_settings_are_validated_and_applied() { + let mut s = Session::with_demo(); + let r = s.execute("library.previewSettings", &json!({})).unwrap(); + assert_eq!(r, json!({"standardEdge": 2048, "discardFull": "month", "atImport": "minimal"})); + let r = s.execute("library.previewSettings", &json!({"standardEdge": 1440, "discardFull": "never", "atImport": "full"})).unwrap(); + assert_eq!(r, json!({"standardEdge": 1440, "discardFull": "never", "atImport": "full"})); + assert!(s.execute("library.previewSettings", &json!({"standardEdge": 10})).is_err()); + assert!(s.execute("library.previewSettings", &json!({"discardFull": "fortnight"})).is_err()); + assert!(s.execute("library.previewSettings", &json!({"atImport": 3})).is_err()); + assert!(s.execute("library.discardPreviews", &json!({"size": "standard"})).is_err()); + assert_eq!(s.preview_prefs.standard_edge(), 1440, "a refused change leaves the settings"); +} + +/// Panning at 1:1 on a 45 MP photo (8256 × 5504) in a 2560 × 1440 canvas: the loupe draws the +/// window it has every frame (the GPU moves it) and renders a new window, snapped to a 256 px +/// grid with a margin, only when the view leaves the one it has. Measures each window render, +/// CPU and GPU. Opt in (release build recommended): +/// `cargo test --release -p dac-engine --lib pan_at_one_to_one -- --ignored --nocapture` +#[test] +#[ignore] +fn pan_at_one_to_one_on_45_megapixels() { + use std::sync::Arc; + let scene = dac_scenes::demo_library().into_iter().next().unwrap(); + let (fw, fh) = (8256usize, 5504usize); + let src = Arc::new(scene.render(fw, fh)); + let info = dac_pipeline::SourceInfo::default(); + let mut d = dac_develop::DevelopSettings::default(); + d.light.exposure = 0.3; + d.effects.clarity = 20.0; + let (cw, ch) = (2560usize, 1440usize); + let margin = ((fw.max(fh) as f64 * 0.045).min(768.0) as usize).div_ceil(256).max(1) * 256; + let window_for = |cx: usize, cy: usize| { + let snap = |lo: usize, hi: usize, full: usize| { + let a = lo.saturating_sub(margin) / 256 * 256; + let b = (hi + margin).div_ceil(256) * 256; + (a, b.min(full)) + }; + let (x0, x1) = snap(cx, cx + cw, fw); + let (y0, y1) = snap(cy, cy + ch, fh); + PixelWindow { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } + }; + for gpu in [false, true] { + if gpu && !dac_gpu::available() { + eprintln!("gpu: not available"); + continue; + } + let stages = dac_pipeline::StageCache::default(); + // a brisk pan: 40 px a frame for 3 s at 60 fps + let (mut cur, mut renders, mut total, mut worst) = (None::, 0usize, 0.0f64, 0.0f64); + let frames = 180; + for f in 0..frames { + let cx = (1000 + f * 40).min(fw - cw); + // the window there is still covers the view while the visible pixels are inside it + if cur.is_some_and(|w| cx >= w.x && cx + cw <= w.x + w.w) { + continue; + } + let want = window_for(cx, 2000); + let req = dac_pipeline::RenderRequest { window: Some(want), ..dac_pipeline::RenderRequest::fit(fw, fh) }; + let t = std::time::Instant::now(); + let r = crate::media::develop(&src, &info, &d, &req, Some(&stages), gpu); + let ms = t.elapsed().as_secs_f64() * 1e3; + assert_eq!((r.image.width, r.image.height), (want.w, want.h)); + total += ms; + worst = worst.max(ms); + renders += 1; + cur = Some(want); + } + let mean = total / renders.max(1) as f64; + // windows render in the background while the one there is drawn moved: the view stays + // sharp as long as a render finishes within the frames the margin buys + let frames_per_render = frames as f64 / renders.max(1) as f64; + let sharp_fps = 1000.0 / mean * frames_per_render; + eprintln!( + "{}: {renders} window renders in {frames} frames (margin {margin} px), mean {mean:.1} ms, worst {worst:.1} ms → sharp up to {sharp_fps:.0} fps at 40 px/frame", + if gpu { "gpu" } else { "cpu" } + ); + } +} diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 450a73370b..2d23b87e20 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -165,6 +165,25 @@ impl DiskCache { *t.get_or_insert_with(|| self.scan().iter().map(|f| f.1).sum()) } + /// When the file of `key` was last written or read (`None`: not on disk). + pub fn modified(&self, key: Hash128) -> Option { + std::fs::metadata(self.path(key)).ok().filter(|m| m.is_file()).and_then(|m| m.modified().ok()) + } + + /// Delete the file of `key`; returns the bytes freed (0: it was not there). + pub fn remove(&self, key: Hash128) -> u64 { + let p = self.path(key); + let Ok(m) = std::fs::symlink_metadata(&p) else { return 0 }; + if !m.is_file() || std::fs::remove_file(&p).is_err() { + return 0; + } + let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(total) = t.as_mut() { + *total = total.saturating_sub(m.len()); + } + m.len() + } + pub fn clear(&self) { for (p, _, _) in self.scan() { let _ = std::fs::remove_file(p); diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 5568bf34be..136531e32d 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -136,6 +136,18 @@ impl PreviewCache { } } + /// Drop one image (memory and disk); whether there was one. + pub fn remove(&self, key: Hash128) -> bool { + let in_mem = self.mem.lock().unwrap_or_else(|e| e.into_inner()).remove(&key).is_some(); + let on_disk = self.disk.as_ref().is_some_and(|d| d.remove(key) > 0); + in_mem || on_disk + } + + /// Whether `key` is held in memory or on disk (without reading it). + pub fn contains(&self, key: Hash128) -> bool { + self.mem.lock().unwrap_or_else(|e| e.into_inner()).contains(&key) || self.disk.as_ref().is_some_and(|d| d.modified(key).is_some()) + } + /// Stop this cache object for good, leaving its disk files alone: called when another cache /// object replaces it (a library reopened or another one opened). Jobs still holding it then /// can neither read nor write through it, so a later clear of the replacement's directory diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 4e9264798b..4f23423dfd 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -238,3 +238,20 @@ fn disk_cache_never_touches_foreign_files() { assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); let _ = std::fs::remove_dir_all(&dir); } + +// Given one image discarded, only it leaves memory and disk; a missing key is a no-op +#[test] +fn remove_drops_one_image_from_memory_and_disk() { + let dir = temp_dir("remove"); + let cache = PreviewCache::with_disk(1 << 20, &dir, 1 << 20); + let (a, b) = (hash_bytes(b"a"), hash_bytes(b"b")); + cache.put(a, Arc::new(gradient(8, 8, 0))); + cache.put(b, Arc::new(gradient(8, 8, 9))); + assert!(cache.contains(a) && cache.disk().unwrap().modified(a).is_some()); + assert!(cache.remove(a)); + assert!(!cache.contains(a) && cache.get(a).is_none()); + assert!(cache.contains(b)); + assert!(!cache.remove(a), "already gone"); + cache.clear(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 0f615b4f8b..caf49e8136 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -32,6 +32,7 @@ "Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos", "Automatic ({} MB)": "Automatisch ({} MB)", "Build 1:1 Previews ({scope})": "1:1-Vorschauen erstellen ({scope})", + "Discard 1:1 Previews ({scope})": "1:1-Vorschauen verwerfen ({scope})", "Build Smart Previews ({scope})": "Smart-Vorschauen erstellen ({scope})", "Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})", "Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 4679948faa..2e8aab799a 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -32,6 +32,7 @@ "Auto Sync: edits apply to {n} photos": "Sincronización automática: las ediciones se aplican a {n} fotos", "Automatic ({} MB)": "Automático ({} MB)", "Build 1:1 Previews ({scope})": "Generar vistas previas 1:1 ({scope})", + "Discard 1:1 Previews ({scope})": "Descartar vistas previas 1:1 ({scope})", "Build Smart Previews ({scope})": "Generar vistas previas inteligentes ({scope})", "Build Standard-Sized Previews ({scope})": "Generar vistas previas de tamaño estándar ({scope})", "Building previews for {} photos…": "Generando vistas previas de {} fotos…", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index cbca18b3c2..0c265e30cb 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -114,6 +114,7 @@ "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック → 復元、または完全に削除してから読み込み直し", "Auto ({})": "自動 ({})", "Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})", + "Discard 1:1 Previews ({scope})": "1:1プレビューを破棄({scope})", "Build Smart Previews ({scope})": "スマートプレビューを作成({scope})", "Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})", "Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index c4e85a31f4..a93847c146 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -21,6 +21,7 @@ "Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos", "Automatic ({} MB)": "Automático ({} MB)", "Build 1:1 Previews ({scope})": "Gerar pré-visualizações 1:1 ({scope})", + "Discard 1:1 Previews ({scope})": "Descartar pré-visualizações 1:1 ({scope})", "Build Smart Previews ({scope})": "Gerar pré-visualizações inteligentes ({scope})", "Build Standard-Sized Previews ({scope})": "Gerar pré-visualizações em tamanho padrão ({scope})", "Click {}:1": "Clique em {}:1", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 6c5d907544..f063369b56 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -34,6 +34,7 @@ "Auto Sync: edits apply to {n} photos": "Автосинхронизация: правки применяются к фото: {n}", "Automatic ({} MB)": "Автоматически ({} МБ)", "Build 1:1 Previews ({scope})": "Создать просмотры 1:1 ({scope})", + "Discard 1:1 Previews ({scope})": "Удалить просмотры 1:1 ({scope})", "Build Smart Previews ({scope})": "Создать смарт-просмотры ({scope})", "Build Standard-Sized Previews ({scope})": "Создать просмотры стандартного размера ({scope})", "Building previews for {} photos…": "Создание просмотров для фото: {}…", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 87f32be981..e6012845b5 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -114,6 +114,7 @@ "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 → 恢复,或永久删除后重新导入", "Auto ({})": "自动({})", "Build 1:1 Previews ({scope})": "生成 1:1 预览({scope})", + "Discard 1:1 Previews ({scope})": "丢弃 1:1 预览({scope})", "Build Smart Previews ({scope})": "生成智能预览({scope})", "Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})", "Discard Smart Previews ({scope})": "丢弃智能预览({scope})", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index ac06a0f7a3..7b873631e8 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -18,6 +18,7 @@ "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片", "Automatic ({} MB)": "自動({} MB)", "Build 1:1 Previews ({scope})": "建立 1:1 預覽({scope})", + "Discard 1:1 Previews ({scope})": "捨棄 1:1 預覽({scope})", "Build Smart Previews ({scope})": "建立智慧型預覽({scope})", "Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})", "Click {}:1": "點擊時 {}:1", diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index e03af2a8ec..e0113dcb47 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -374,6 +374,14 @@ fn expanded(app: &DacApp, name: &str) -> Option> { None, ), item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None), + item( + "library.discardPreviews", + json!({"size": "full"}), + crate::i18n::tr_format!("Discard 1:1 Previews ({scope})", scope = crate::i18n::tr(scope)), + None, + !running, + None, + ), MenuNode::Separator, // (read and written on a worker thread: the originals may be on a slow drive) item(