# Conflicts:
#	crates/engine/src/cmd/previews.rs
#	docs/camera-preview-colour.md
This commit is contained in:
Brandon Thomas
2026-10-10 04:01:27 -04:00
8 changed files with 390 additions and 12 deletions
+13
View File
@@ -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,
+95 -9
View File
@@ -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
+1 -1
View File
@@ -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;
+5
View File
@@ -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(())
}
+55 -1
View File
@@ -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()))?;
+206
View File
@@ -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");