mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
Smart previews always lived in <library>/Smart Previews, so a large set of proxies filled the library's drive. The folder is now a per-library setting (prefs.json): - library.smartPreviewsLocation shows the effective folder (path, count, size, availability) or changes it; changing it needs an explicit choice for the previews already built (move, leave or discard), and reset returns to the library's own folder. - The new folder must accept writes before anything moves. A folder whose parent is missing (an unplugged drive) is an error and is never created on the current drive; building also refuses while a chosen folder is unavailable. - Building, discarding, status and offline rendering all use the chosen folder. - Settings -> Performance -> Smart previews shows the path before building, what it holds, the existing-previews choice, Choose Folder... and Use Library Folder. The thumbnail cache stays in the library. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
213 lines
8.4 KiB
Rust
213 lines
8.4 KiB
Rust
//! 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<Vec<u8>, String> {
|
|
// scale so all but the brightest 0.05 % fit into 0..1
|
|
let mut lum: Vec<f32> = 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<Rgb32f, String> {
|
|
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<Existing> {
|
|
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<String>) {
|
|
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 dst.exists() {
|
|
// named by content: the copy already there is the same preview
|
|
std::fs::remove_file(&src)
|
|
} else {
|
|
std::fs::rename(&src, &dst).or_else(|_| std::fs::copy(&src, &dst).and_then(|_| std::fs::remove_file(&src)))
|
|
}
|
|
}
|
|
};
|
|
match r {
|
|
Ok(()) => done += 1,
|
|
Err(err) => failed.push(format!("{}: {err}", src.display())),
|
|
}
|
|
}
|
|
(done, failed)
|
|
}
|
|
|
|
/// Load the proxy at `path`.
|
|
pub fn load(path: &Path) -> Result<Arc<Rgb32f>, 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 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());
|
|
}
|
|
}
|