mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 16:31:32 -06:00
Merge PR #590
# Conflicts: # crates/engine/src/cmd/previews.rs # docs/camera-preview-colour.md
This commit is contained in:
@@ -12,6 +12,19 @@ use lightcraft_raster::{
|
||||
};
|
||||
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
|
||||
|
||||
/// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone
|
||||
/// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because
|
||||
/// they keep the curve and the colour the fit produced; one written by an older version is rebuilt
|
||||
/// from its original when that is online, and kept as it is while it is offline.
|
||||
///
|
||||
/// **Bump this whenever a change makes the fit give a different result for the same file** (the
|
||||
/// fit, its gates, the fallbacks, the camera profiles' effect on it). Do not bump it for changes
|
||||
/// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies.
|
||||
///
|
||||
/// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO)
|
||||
/// and the ILCE-7CR profile (#528, #583, #568, #616).
|
||||
pub const LOOK_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
pub matrix: Mat3,
|
||||
|
||||
@@ -125,9 +125,16 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// how to load the original at preview size, whether the photo takes the per-file camera look —
|
||||
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those
|
||||
/// proxies go stale when the fit changes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>);
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
||||
|
||||
/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look
|
||||
/// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static SMART_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// What Build / Discard Smart Previews did.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -137,11 +144,29 @@ struct SmartCounts {
|
||||
built: usize,
|
||||
/// Of those, damaged ones (cut short by a crash or a full drive) that were rebuilt.
|
||||
repaired: usize,
|
||||
/// Of those, proxies written by an older camera-look fit that were rebuilt from their online originals.
|
||||
refreshed: usize,
|
||||
/// Of those kept as they were: out of date, but the original can't be read now (offline), so
|
||||
/// the stored look stays and the next run with the original online rebuilds them.
|
||||
stale_kept: usize,
|
||||
removed: usize,
|
||||
/// `[[id, why]]`
|
||||
failed: Vec<Value>,
|
||||
}
|
||||
|
||||
/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart
|
||||
/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy
|
||||
/// fallback of a file source are not the original.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn original_only(source: crate::media::SourceRef) -> Option<crate::media::SourceRef> {
|
||||
use crate::media::SourceRef;
|
||||
match source {
|
||||
SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }),
|
||||
SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The file-system half of Build / Discard Smart Previews (runs on a worker thread in the app).
|
||||
/// `custom`: the folder was chosen (on another drive, perhaps): it must be there, it is never
|
||||
/// recreated. With `state`, progress and cancel.
|
||||
@@ -162,21 +187,27 @@ fn smart_run(
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
for (id, path, source) in jobs {
|
||||
for (id, path, source, look) in jobs {
|
||||
if state.is_some_and(|(st, _)| st.cancel.load(Ordering::Relaxed)) {
|
||||
break;
|
||||
}
|
||||
let _one = SMART_WRITE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if discard {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
n.removed += 1;
|
||||
}
|
||||
} else {
|
||||
// a complete proxy is kept; a missing or damaged one (cut short by a crash or a full
|
||||
// drive) is (re)built
|
||||
// a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a
|
||||
// full drive) is (re)built, and so is one written by an older camera-look fit (see
|
||||
// `camera_preview::LOOK_VERSION`) when its original can be read
|
||||
let damaged = path.exists();
|
||||
if damaged && crate::smart::is_valid(&path) {
|
||||
let stale = look && damaged && crate::smart::is_stale(&path);
|
||||
if damaged && !stale && crate::smart::is_valid(&path) {
|
||||
n.built += 1;
|
||||
} else {
|
||||
// a stale proxy is rebuilt from the original only: never from itself (the file
|
||||
// fallback or a source decoded from a proxy would stamp the old look as current)
|
||||
let source = if stale { source.and_then(original_only) } else { source };
|
||||
// atomic: a failed write leaves no partial proxy that would pass for a built one;
|
||||
// and synced (issue #134): built so the photo can be edited while its original
|
||||
// is offline, when it is the only copy — the sync is small next to the decode
|
||||
@@ -188,13 +219,21 @@ fn smart_run(
|
||||
match r {
|
||||
Ok(()) => {
|
||||
n.built += 1;
|
||||
if damaged {
|
||||
if stale {
|
||||
n.refreshed += 1;
|
||||
} else if damaged {
|
||||
n.repaired += 1;
|
||||
if let Some((st, _)) = state {
|
||||
st.repaired.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if stale => {
|
||||
// offline (or unreadable) original: the proxy is all there is, keep its look
|
||||
log::info!("smart preview {} keeps its older look: {e}", path.display());
|
||||
n.built += 1;
|
||||
n.stale_kept += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
n.failed.push(json!([id.0, e]));
|
||||
if let Some((st, _)) = state {
|
||||
@@ -233,7 +272,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let path = dir.join(crate::smart::file_name(&ph));
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source));
|
||||
jobs.push((id, path, source, ph.relative_wb()));
|
||||
}
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
@@ -255,7 +294,54 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(state.json());
|
||||
}
|
||||
let r = smart_run(&dir, custom, discard, jobs, None).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"built": r.built, "repaired": r.repaired, "removed": r.removed, "failed": r.failed}))
|
||||
Ok(
|
||||
json!({"built": r.built, "repaired": r.repaired, "refreshed": r.refreshed, "staleKept": r.stale_kept, "removed": r.removed, "failed": r.failed}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when
|
||||
/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker,
|
||||
/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the
|
||||
/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG,
|
||||
/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose
|
||||
/// original can't be read (offline) is kept as it is, still marked stale in its header: Build
|
||||
/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none
|
||||
/// is built, and a missing folder is never created. Returns the thread, if started.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::thread::JoinHandle<()>> {
|
||||
let dir = s.media.smart_dir.clone()?;
|
||||
if !dir.is_dir() || crate::smart::look_checked(&dir) {
|
||||
return None;
|
||||
}
|
||||
let max_edge = crate::media::SourceLevel::Preview.max_edge();
|
||||
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect();
|
||||
let jobs: Vec<SmartJob> =
|
||||
photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-look".into())
|
||||
.spawn(move || refresh_stale(&dir, jobs))
|
||||
.map_err(|e| log::warn!("smart previews: could not start the look refresh: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// The body of [`refresh_stale_smart_previews`]: rebuild the stale proxies among `jobs`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn refresh_stale(dir: &std::path::Path, jobs: Vec<SmartJob>) {
|
||||
let stale: Vec<SmartJob> = jobs.into_iter().filter(|(_, path, _, _)| crate::smart::is_stale(path)).collect();
|
||||
if !stale.is_empty() {
|
||||
// `custom`: the folder is there already, and must not be recreated if it went away since
|
||||
match smart_run(dir, true, false, stale, None) {
|
||||
Ok(n) => log::info!("smart previews: {} refreshed to the current camera look, {} kept (original offline)", n.refreshed, n.stale_kept),
|
||||
Err(e) => {
|
||||
// not marked: the next opening tries again
|
||||
log::warn!("smart previews: look refresh: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::smart::mark_look_checked(dir) {
|
||||
log::warn!("smart previews: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The smart previews folder: where it is, whether it is custom, what it holds, whether it can be
|
||||
|
||||
@@ -16,7 +16,7 @@ extern crate lightcraft_denoise_core as lightcraft_denoise;
|
||||
|
||||
pub mod activity;
|
||||
pub mod availability;
|
||||
mod camera_preview;
|
||||
pub(crate) mod camera_preview;
|
||||
pub mod camera_profiles;
|
||||
pub mod cmd;
|
||||
pub mod config;
|
||||
|
||||
@@ -419,6 +419,11 @@ impl Session {
|
||||
lib.forgot_local = Some(plan);
|
||||
}
|
||||
}
|
||||
// smart previews written by an older camera-look fit are brought up to date in the background
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if on_disk {
|
||||
crate::cmd::previews::refresh_stale_smart_previews(self);
|
||||
}
|
||||
self.library_identity = std::sync::Arc::new(());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ pub fn file_name(p: &Photo) -> String {
|
||||
/// The most header [`is_valid`] reads (the JSON line before the JPEG).
|
||||
const HEADER_MAX: u64 = 4096;
|
||||
|
||||
/// The header field that records which version of the camera-look fit (`camera_preview::LOOK_VERSION`)
|
||||
/// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve.
|
||||
const LOOK_FIELD: &str = "look_version";
|
||||
|
||||
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
|
||||
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
|
||||
// scale so all but the brightest 0.05 % fit into 0..1
|
||||
@@ -53,7 +57,7 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = MAGIC.to_vec();
|
||||
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale});
|
||||
let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
|
||||
if let Some(t) = tone {
|
||||
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -187,6 +191,56 @@ pub fn is_valid(path: &Path) -> bool {
|
||||
f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
|
||||
}
|
||||
|
||||
/// The fit version a header records: a missing, negative, fractional or otherwise malformed value
|
||||
/// counts as 0 (older than any real version).
|
||||
fn header_look_version(head: &serde_json::Value) -> u64 {
|
||||
head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's
|
||||
/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a
|
||||
/// newer build is left alone (rebuilding it here would only undo that build's work), as is one
|
||||
/// that can't be read: those are [`is_valid`]'s business.
|
||||
pub fn is_stale(path: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
if !is_valid(path) {
|
||||
return false;
|
||||
}
|
||||
let Ok(f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
if f.take(HEADER_MAX).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
|
||||
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
|
||||
rest.get(..nl)
|
||||
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
|
||||
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION))
|
||||
}
|
||||
|
||||
/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked
|
||||
/// at ([`look_checked`]).
|
||||
const LOOK_MARKER: &str = "look-version";
|
||||
|
||||
/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's
|
||||
/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not.
|
||||
pub fn look_checked(dir: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false };
|
||||
let mut text = String::new();
|
||||
if f.take(64).read_to_string(&mut text).is_err() {
|
||||
return false;
|
||||
}
|
||||
text.trim().parse::<u64>().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION))
|
||||
}
|
||||
|
||||
/// Record that the proxies in `dir` were checked at this build's camera-look fit version.
|
||||
pub fn mark_look_checked(dir: &Path) -> Result<(), String> {
|
||||
let path = dir.join(LOOK_MARKER);
|
||||
lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes())
|
||||
.map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
|
||||
@@ -1131,6 +1131,212 @@ fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Rewrite the JSON header line of the smart preview at `file`.
|
||||
fn edit_smart_header(file: &Path, edit: impl FnOnce(&mut Value)) {
|
||||
let bytes = std::fs::read(file).unwrap();
|
||||
let start = b"LCSP1\n".len();
|
||||
let nl = start + bytes[start..].iter().position(|b| *b == b'\n').unwrap();
|
||||
let mut head: Value = serde_json::from_slice(&bytes[start..nl]).unwrap();
|
||||
edit(&mut head);
|
||||
let mut out = bytes[..start].to_vec();
|
||||
out.extend_from_slice(head.to_string().as_bytes());
|
||||
out.extend_from_slice(&bytes[nl..]);
|
||||
std::fs::write(file, out).unwrap();
|
||||
}
|
||||
|
||||
fn smart_header(file: &Path) -> Value {
|
||||
let bytes = std::fs::read(file).unwrap();
|
||||
let start = b"LCSP1\n".len();
|
||||
let nl = start + bytes[start..].iter().position(|b| *b == b'\n').unwrap();
|
||||
serde_json::from_slice(&bytes[start..nl]).unwrap()
|
||||
}
|
||||
|
||||
/// A camera tone curve with a recognisable shape (`k` changes it).
|
||||
fn test_tone(k: f32) -> lightcraft_pipeline::tone::CameraTone {
|
||||
lightcraft_pipeline::tone::CameraTone::new(std::array::from_fn(|i| {
|
||||
let x = 0.004 * 1.17f32.powi(i as i32);
|
||||
[x, 0.95 * (1.0 - (-k * x).exp())]
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `plain`, but reporting `tone` as the camera look the (current) fit gives the file; counts its calls.
|
||||
fn look_loader(
|
||||
plain: crate::media::FileLoader,
|
||||
tone: lightcraft_pipeline::tone::CameraTone,
|
||||
calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
) -> crate::media::FileLoader {
|
||||
std::sync::Arc::new(move |path, max_edge| {
|
||||
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let (img, mut info) = plain(path, max_edge)?;
|
||||
info.camera_tone = Some(tone);
|
||||
Ok((img, info))
|
||||
})
|
||||
}
|
||||
|
||||
/// The follow-up to the camera tone curve change (#499): a smart preview keeps the look the fit
|
||||
/// gave it, so one written by an older fit is rebuilt when its original is online, kept while the
|
||||
/// original is offline, and not rebuilt again once current. Only raws that take the per-file
|
||||
/// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version.
|
||||
#[test]
|
||||
fn smart_previews_from_an_older_look_fit_are_refreshed() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let src = temp_dir("smartlook-src");
|
||||
let lib = temp_dir("smartlook-lib");
|
||||
write_png(&src.join("a.png"), 3);
|
||||
write_png(&src.join("b.png"), 9);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap();
|
||||
let find = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id;
|
||||
let (id, png) = (find(&s, "a.png"), find(&s, "b.png"));
|
||||
// `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels)
|
||||
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
|
||||
raw.format = "ARW".into();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb());
|
||||
s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let file = dir.join(crate::smart::file_name(s.catalog.photo(id).unwrap()));
|
||||
let png_file = dir.join(crate::smart::file_name(s.catalog.photo(png).unwrap()));
|
||||
let (old, new) = (test_tone(1.5), test_tone(3.5));
|
||||
let calls = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let plain = s.media.file_loader.clone().unwrap();
|
||||
s.media.file_loader = Some(look_loader(plain.clone(), old, calls.clone()));
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION), "the writer stamps the fit version");
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
|
||||
// current header: kept as it is, the original is not even read
|
||||
let before = std::fs::read(&file).unwrap();
|
||||
calls.store(0, Ordering::SeqCst);
|
||||
s.media.file_loader = Some(look_loader(plain.clone(), new, calls.clone()));
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
assert!(std::fs::read(&file).unwrap() == before, "a current proxy is not rebuilt");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
// old headers (no field), originals offline: kept unchanged, and still marked stale
|
||||
for f in [&file, &png_file] {
|
||||
edit_smart_header(f, |h| {
|
||||
h.as_object_mut().unwrap().remove("look_version");
|
||||
});
|
||||
}
|
||||
assert!(crate::smart::is_stale(&file), "no field = version 0");
|
||||
let old_bytes = std::fs::read(&file).unwrap();
|
||||
let png_bytes = std::fs::read(&png_file).unwrap();
|
||||
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
||||
s.media.forget(id);
|
||||
s.media.forget(png);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(2), Some(0), Some(1)), "{r}");
|
||||
assert!(r["failed"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(std::fs::read(&file).unwrap() == old_bytes, "offline: the stored preview and curve are untouched");
|
||||
assert!(crate::smart::is_stale(&file), "and it stays marked for the next time");
|
||||
assert_eq!(crate::smart::decode(&old_bytes).unwrap().1, Some(old), "the old look is still there");
|
||||
// ... and the opening refresh keeps it too, then marks the folder checked
|
||||
assert!(!crate::smart::look_checked(&dir));
|
||||
crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap();
|
||||
assert!(std::fs::read(&file).unwrap() == old_bytes);
|
||||
assert!(crate::smart::look_checked(&dir));
|
||||
|
||||
// the original comes back: the scan has run at this version, so the next opening leaves it;
|
||||
// Build Smart Previews rebuilds it with the current look
|
||||
std::fs::rename(src.with_extension("offline"), &src).unwrap();
|
||||
calls.store(0, Ordering::SeqCst);
|
||||
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none(), "checked once per version");
|
||||
assert!(std::fs::read(&file).unwrap() == old_bytes);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(1), 1), "{r}");
|
||||
assert_eq!(smart_header(&file)["look_version"], json!(crate::camera_preview::LOOK_VERSION));
|
||||
assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, Some(new), "rebuilt with the current look");
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
assert!(std::fs::read(&png_file).unwrap() == png_bytes, "a PNG's proxy is never rebuilt for the look");
|
||||
|
||||
// a folder checked at an older version (or not at all) is scanned once at the next opening
|
||||
edit_smart_header(&file, |h| h["look_version"] = json!(0));
|
||||
std::fs::write(dir.join("look-version"), b"0\n").unwrap();
|
||||
assert!(!crate::smart::look_checked(&dir));
|
||||
s.media.forget(id);
|
||||
calls.store(0, Ordering::SeqCst);
|
||||
crate::cmd::previews::refresh_stale_smart_previews(&mut s).unwrap().join().unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "only the raw's original is read");
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
assert!(std::fs::read(&png_file).unwrap() == png_bytes);
|
||||
assert!(crate::smart::look_checked(&dir));
|
||||
// once only
|
||||
let rebuilt = std::fs::read(&file).unwrap();
|
||||
assert!(crate::cmd::previews::refresh_stale_smart_previews(&mut s).is_none());
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["refreshed"].as_u64(), calls.load(Ordering::SeqCst)), (Some(0), 1), "{r}");
|
||||
assert!(std::fs::read(&file).unwrap() == rebuilt);
|
||||
|
||||
// Build Smart Previews refreshes a stale one the same way
|
||||
edit_smart_header(&file, |h| h["look_version"] = json!(0));
|
||||
s.media.file_loader = Some(plain);
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!(r["refreshed"], 1, "{r}");
|
||||
assert_eq!(crate::smart::decode(&std::fs::read(&file).unwrap()).unwrap().1, None, "this fit gives no curve: none is kept");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// The look marker: missing, damaged or older than this build's fit version counts as unchecked.
|
||||
#[test]
|
||||
fn smart_preview_look_marker_is_read_safely() {
|
||||
let dir = temp_dir("smartlook-marker");
|
||||
assert!(!crate::smart::look_checked(&dir), "no marker");
|
||||
let cur = crate::camera_preview::LOOK_VERSION;
|
||||
for (text, checked) in [
|
||||
(String::new(), false),
|
||||
("banana".into(), false),
|
||||
("-1".into(), false),
|
||||
(format!("{}", cur.saturating_sub(1)), false),
|
||||
("9".repeat(200), false),
|
||||
(format!("{cur}\n"), true),
|
||||
(format!(" {}\n", cur + 1), true),
|
||||
] {
|
||||
std::fs::write(dir.join("look-version"), &text).unwrap();
|
||||
assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}");
|
||||
}
|
||||
std::fs::remove_file(dir.join("look-version")).unwrap();
|
||||
crate::smart::mark_look_checked(&dir).unwrap();
|
||||
assert!(crate::smart::look_checked(&dir));
|
||||
assert!(crate::smart::mark_look_checked(&dir.join("missing")).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A hostile or damaged `look_version` never panics: anything that is not a non-negative integer
|
||||
/// counts as 0 (rebuilt when the original is online); a version from a newer build is left alone.
|
||||
#[test]
|
||||
fn malformed_smart_preview_look_versions_are_handled() {
|
||||
let dir = temp_dir("smartlook-bad");
|
||||
let img = lightcraft_scenes::demo_library()[0].render(32, 20);
|
||||
let file = dir.join("a.lcsp");
|
||||
std::fs::write(&file, crate::smart::encode(&img, None).unwrap()).unwrap();
|
||||
let cur = u64::from(crate::camera_preview::LOOK_VERSION);
|
||||
for bad in [json!("1"), json!(-1), json!(1.5), json!(null), json!([]), json!({}), json!(true), json!(1e30), json!(-0.0)] {
|
||||
let shown = bad.to_string();
|
||||
edit_smart_header(&file, |h| h["look_version"] = bad);
|
||||
assert!(crate::smart::is_valid(&file), "{shown}");
|
||||
assert!(crate::smart::is_stale(&file), "{shown} counts as version 0");
|
||||
assert!(crate::smart::decode(&std::fs::read(&file).unwrap()).is_ok(), "{shown}");
|
||||
}
|
||||
for fine in [json!(cur), json!(cur + 1), json!(u64::MAX)] {
|
||||
let shown = fine.to_string();
|
||||
edit_smart_header(&file, |h| h["look_version"] = fine);
|
||||
assert!(!crate::smart::is_stale(&file), "{shown}");
|
||||
}
|
||||
// not a header at all, or missing
|
||||
assert!(!crate::smart::is_stale(&dir.join("missing.lcsp")));
|
||||
std::fs::write(&file, b"LCSP1\n{not json\n\xFF\xD8").unwrap();
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_gone_since_the_import_review_is_not_imported() {
|
||||
let src = temp_dir("gone-since-review");
|
||||
|
||||
Reference in New Issue
Block a user