//! Smart previews: a compact proxy of each photo (its decoded preview-size source, about 1 MB) //! kept in the library, so photos stay editable — and exportable at proxy size — while their //! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into //! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. use std::path::{Path, PathBuf}; use std::sync::Arc; use lightcraft_catalog::Photo; use lightcraft_color::transfer::linear_to_srgb; use lightcraft_raster::{Rgb32f, Rgba8}; const MAGIC: &[u8] = b"LCSP1\n"; /// The proxy's file name for a photo (by its content, so copies share one). pub fn file_name(p: &Photo) -> String { let h = lightcraft_preview::Hasher128::new().str(&crate::media::content_key(p)).finish(); format!("{:032x}.lcsp", h.0) } /// Encode a source image as a smart preview. pub fn encode(img: &Rgb32f) -> Result, String> { // scale so all but the brightest 0.05 % fit into 0..1 let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); let scale = if lum.is_empty() { 1.0 } else { let k = ((lum.len() as f64) * 0.9995) as usize; let k = k.min(lum.len() - 1); let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); v.max(1.0) }; let data: Vec<[u8; 4]> = img .data .iter() .map(|c| { let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; [e(c[0]), e(c[1]), e(c[2]), 255] }) .collect(); let rgba = Rgba8 { width: img.width, height: img.height, data }; let jpg = lightcraft_codecs::encode_jpeg( &lightcraft_codecs::EncodeImage::rgba8(&rgba), 92, lightcraft_codecs::ChromaSubsampling::S444, &Default::default(), ) .map_err(|e| e.to_string())?; let mut out = MAGIC.to_vec(); out.extend_from_slice(format!("{{\"w\":{},\"h\":{},\"scale\":{scale}}}\n", img.width, img.height).as_bytes()); out.extend_from_slice(&jpg); Ok(out) } /// Decode a smart preview back into a source image. pub fn decode(bytes: &[u8]) -> Result { let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?; let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?; let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?; let scale = head["scale"].as_f64().unwrap_or(1.0) as f32; let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?; // the decoder undoes the sRGB encoding; the values are the source's own primaries let mut img = d.image; img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale)); Ok(img) } /// Where a library keeps its smart previews. pub fn dir(library: &Path) -> PathBuf { library.join("Smart Previews") } /// Check that smart previews can be written to `dir`: it must be (or, when its parent exists, /// become) a directory that accepts a new file. Never creates missing parents, so an unplugged /// drive's mount point is not silently recreated on the system drive. pub fn check_writable(dir: &Path) -> Result<(), String> { if !dir.is_dir() { let parent_ok = dir.parent().is_some_and(|p| p.is_dir()); if !parent_ok { return Err(format!("{} is not available — reconnect the drive or choose another folder", dir.display())); } std::fs::create_dir(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; } let probe = dir.join(format!(".lightcraft-write-test-{}", std::process::id())); std::fs::write(&probe, b"x").map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?; let _ = std::fs::remove_file(&probe); Ok(()) } /// The number of smart previews in `dir` and their total size in bytes. pub fn stats(dir: &Path) -> (usize, u64) { let Ok(rd) = std::fs::read_dir(dir) else { return (0, 0) }; rd.flatten() .filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) .fold((0, 0), |(n, b), e| (n + 1, b + e.metadata().map_or(0, |m| m.len()))) } /// What to do with the smart previews in the old folder when the location changes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Existing { /// Move them to the new folder (copy and delete across drives). Move, /// Leave them where they are (they stop being used; build again in the new folder). Leave, /// Delete them. Discard, } impl Existing { pub fn parse(s: &str) -> Option { match s { "move" => Some(Existing::Move), "leave" => Some(Existing::Leave), "discard" => Some(Existing::Discard), _ => None, } } } /// Apply `what` to the smart previews in `from` for the new folder `to` → (moved or removed, failed). pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { let (mut done, mut failed) = (0, Vec::new()); if what == Existing::Leave { return (done, failed); } let Ok(rd) = std::fs::read_dir(from) else { return (done, failed) }; for e in rd.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "lcsp")) { let src = e.path(); let r = match what { Existing::Discard => std::fs::remove_file(&src), _ => { let dst = to.join(e.file_name()); if is_valid(&dst) { // named by content: the complete copy already there is the same preview std::fs::remove_file(&src) } else { // across drives: copied atomically (never a partial proxy at the new place) std::fs::rename(&src, &dst).or_else(|_| { std::fs::read(&src) .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&dst, &b)) .and_then(|()| std::fs::remove_file(&src)) }) } } }; match r { Ok(()) => done += 1, Err(err) => failed.push(format!("{}: {err}", src.display())), } } (done, failed) } /// A cheap check that the smart preview at `path` is complete: our header, then a JPEG from its /// start marker to its end marker. A file cut short by a crash, a full drive or an unplugged one /// fails it (and is then rebuilt by Build Smart Previews, not counted as there). pub fn is_valid(path: &Path) -> bool { use std::io::{Read, Seek, SeekFrom}; let Ok(mut f) = std::fs::File::open(path) else { return false }; let mut head = Vec::with_capacity(256); if (&mut f).take(256).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 }; let size_ok = rest .get(..nl) .and_then(|h| serde_json::from_slice::(h).ok()) .is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0)); if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) { return false; } let mut end = [0u8; 2]; f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] } /// Load the proxy at `path`. pub fn load(path: &Path) -> Result, String> { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; decode(&b).map(Arc::new) } #[cfg(test)] mod tests { use super::*; fn temp(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("lc-smart-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn writable_check_does_not_create_missing_parents() { let base = temp("writable"); assert!(check_writable(&base.join("new")).is_ok(), "one missing level is created"); assert!(base.join("new").is_dir()); let err = check_writable(&base.join("gone/deeper")).unwrap_err(); assert!(err.contains("not available"), "{err}"); assert!(!base.join("gone").exists(), "no fallback onto the current drive"); let _ = std::fs::remove_dir_all(&base); } #[test] fn migrate_moves_leaves_or_discards() { let base = temp("migrate"); let (a, b) = (base.join("a"), base.join("b")); std::fs::create_dir_all(&a).unwrap(); std::fs::create_dir_all(&b).unwrap(); std::fs::write(a.join("1.lcsp"), b"one").unwrap(); std::fs::write(a.join("note.txt"), b"keep").unwrap(); assert_eq!(stats(&a), (1, 3)); assert_eq!(migrate(&a, &b, Existing::Leave).0, 0); assert_eq!(stats(&a).0, 1); assert_eq!(migrate(&a, &b, Existing::Move).0, 1); assert_eq!((stats(&a).0, stats(&b).0), (0, 1)); assert!(a.join("note.txt").exists(), "only smart previews are touched"); assert_eq!(migrate(&b, &a, Existing::Discard).0, 1); assert_eq!(stats(&b).0, 0); let _ = std::fs::remove_dir_all(&base); } #[test] fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { let base = temp("valid"); let (a, b) = (base.join("a"), base.join("b")); std::fs::create_dir_all(&a).unwrap(); std::fs::create_dir_all(&b).unwrap(); let img = lightcraft_scenes::demo_library()[0].render(64, 40); let good = encode(&img).unwrap(); std::fs::write(a.join("1.lcsp"), &good).unwrap(); assert!(is_valid(&a.join("1.lcsp"))); for cut in [good.len() - 1, good.len() / 2, 10, 0] { std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); } assert!(!is_valid(&b.join("missing.lcsp"))); // moving onto a damaged copy replaces it instead of deleting the good one assert_eq!(migrate(&a, &b, Existing::Move).0, 1); assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); let _ = std::fs::remove_dir_all(&base); } #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); let back = decode(&encode(&img).unwrap()).unwrap(); assert_eq!((back.width, back.height), (img.width, img.height)); let mut err = 0.0f64; for (a, b) in img.data.iter().zip(&back.data) { for k in 0..3 { let (x, y) = (linear_to_srgb(a[k].clamp(0.0, 1.0)), linear_to_srgb(b[k].clamp(0.0, 1.0))); err += ((x - y) as f64).abs(); } } let mean = err / (img.data.len() * 3) as f64; assert!(mean < 0.02, "mean encoded error {mean}"); assert!(decode(b"nope").is_err()); } }