mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-10 18:39:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a9cef6548c
commit
4e711892df
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(()) };
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user