P1.6: preview store: 1:1 previews under their own key, discard (manual and day/week/month/never), per-library preview settings, previews at import; test that 1:1 windows equal full-size export pixels

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Frahn
2026-10-10 12:00:51 +11:00
co-authored by Claude Opus 5.5
parent a9cef6548c
commit 4e711892df
17 changed files with 497 additions and 5 deletions
+3
View File
@@ -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<PhotoId> = imported.iter().map(|i| PhotoId(*i)).collect();
crate::cmd::previews::build_after_import(s, &ids);
Ok(report)
}
+225 -4
View File
@@ -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<u64> = 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<DiscardAfter> {
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<std::time::Duration> {
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<ImportPreviews> {
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<usize>) -> Vec<RenderJob> {
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<Vec<RenderJob>>, state: &PreviewBuild) {
fn build(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec<RenderJob>> = 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<Value> {
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<std::time::Duration>) -> 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<Value> {
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<Value> {
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<u64> = 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<CommandSpec> {
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()))
}),
+5
View File
@@ -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;
+4
View File
@@ -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<u32>,
/// 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(()) };
+12 -1
View File
@@ -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<RenderJob> {
@@ -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())));
+185
View File
@@ -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<String> = 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::<PixelWindow>, 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" }
);
}
}
+19
View File
@@ -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::time::SystemTime> {
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);
+12
View File
@@ -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
+17
View File
@@ -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);
}
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -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})",
@@ -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",
+1
View File
@@ -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…": "Создание просмотров для фото: {}…",
@@ -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})",
@@ -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",
+8
View File
@@ -374,6 +374,14 @@ fn expanded(app: &DacApp, name: &str) -> Option<Vec<MenuNode>> {
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(