diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 71d9283a..05960f7e 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -161,7 +161,7 @@ impl WorkerCore { let src = match (&job.origin, original) { (Source::File { .. }, Some(bytes)) => { let (image, info) = lightcraft_engine::files::load_bytes(bytes, job.max_edge)?; - DecodedSource { image: Arc::new(image), info: Some(info) } + DecodedSource::new(Arc::new(image), Some(info)) } (Source::File { path }, None) => return Err(format!("{path}: original not found in browser storage")), (origin @ Source::Demo { .. }, _) => self.media.origin_ref(origin, job.max_edge).load_source()?, @@ -171,7 +171,7 @@ impl WorkerCore { src } }; - let info = src.info.unwrap_or(job.info()); + let info = src.info_or(job.info()); Ok(match &job.stages { Some(view) => { let st = self.stages.entry(view.clone()).or_default().clone(); diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 8a2bf7e5..d3b63389 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -163,8 +163,8 @@ fn smart_run( // atomic: a failed write leaves no partial proxy that would pass for a built one let r = source .ok_or_else(|| "nothing to build from".to_string()) - .and_then(|s| s.load()) - .and_then(|src| crate::smart::encode(&src)) + .and_then(|s| s.load_source()) + .and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref())) .and_then(|b| lightcraft_catalog::safe_file::write_atomic(&path, &b).map_err(|e| format!("{}: {e}", path.display()))); match r { Ok(()) => { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 023c3780..7fd8a596 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -76,6 +76,21 @@ pub type PreviewLoader = Arc Option + Send + Sync> pub struct DecodedSource { pub image: Arc, pub info: Option, + /// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the + /// catalog's header facts lack (used when `info` is `None`). + pub camera_tone: Option, +} + +impl DecodedSource { + pub fn new(image: Arc, info: Option) -> Self { + DecodedSource { image, info, camera_tone: None } + } + + /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) + /// with any stored camera tone curve. + pub fn info_or(&self, header: SourceInfo) -> SourceInfo { + self.info.unwrap_or(SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }) + } } #[derive(Clone)] @@ -110,24 +125,22 @@ impl SourceRef { SourceRef::Demo { scene, max_edge } => Arc::new(scene.render_fit(*max_edge)), SourceRef::File { path, max_edge, loader, fallback } => { let r = match loader { - Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource { image: Arc::new(image), info: Some(info) }), + Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), None => Err(format!("no decoder available for {path}")), }; // An offline original renders from its smart preview (no decoder facts: header ones). return match (r, fallback) { #[cfg(not(target_arch = "wasm32"))] - (Err(e), Some(sp)) if crate::smart::is_valid(sp) => { - crate::smart::load(sp).map(|image| DecodedSource { image, info: None }).map_err(|e2| format!("{e}; smart preview: {e2}")) - } + (Err(e), Some(sp)) if crate::smart::is_valid(sp) => crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}")), (r, _) => r, }; } #[cfg(not(target_arch = "wasm32"))] - SourceRef::Smart { path } => crate::smart::load(path)?, + SourceRef::Smart { path } => return crate::smart::load(path), #[cfg(target_arch = "wasm32")] SourceRef::Smart { .. } => return Err("smart previews are not available here".into()), }; - Ok(DecodedSource { image, info: None }) + Ok(DecodedSource::new(image, None)) } } @@ -265,16 +278,24 @@ impl MediaCache { } } - fn source_facts(&self, id: PhotoId) -> Option { - self.thumbs - .peek(&id) - .and_then(|s| s.info) - .or_else(|| self.previews.iter().find(|e| e.0 == id).and_then(|e| e.1.info)) - .or_else(|| self.full.as_ref().filter(|e| e.0 == id).and_then(|e| e.1.info)) + /// The facts of photo `id`'s cached sources: the decoder's when one has them, else `header` + /// (with a smart preview's stored camera tone curve). + fn source_facts(&self, id: PhotoId, header: SourceInfo) -> SourceInfo { + let cached = || { + self.thumbs + .peek(&id) + .into_iter() + .chain(self.previews.iter().filter(|e| e.0 == id).map(|e| &e.1)) + .chain(self.full.iter().filter(|e| e.0 == id).map(|e| &e.1)) + }; + match cached().find(|s| s.info.is_some()).or_else(|| cached().next()) { + Some(s) => s.info_or(header), + None => header, + } } pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { - self.insert_source(id, level, DecodedSource { image: img, info: None }); + self.insert_source(id, level, DecodedSource::new(img, None)); } fn insert_source(&mut self, id: PhotoId, level: SourceLevel, img: DecodedSource) { @@ -465,7 +486,7 @@ impl RenderJob { match self.source.load_source() { Ok(source) => { let src = &source.image; - let info = source.info.unwrap_or(self.info); + let info = source.info_or(self.info); // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); @@ -784,10 +805,8 @@ impl crate::Session { /// Prefer decoder facts to header-only metadata for pixel-statistics commands. pub fn source_info(&self, id: PhotoId) -> SourceInfo { - if let Some(info) = self.media.source_facts(id) { - return info; - } - self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default() + let header = self.catalog.photo(id).map(|p| source_info(p)).unwrap_or_default(); + self.media.source_facts(id, header) } /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 038f3330..8deff7bd 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -1,13 +1,16 @@ //! 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. +//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header +//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied +//! at render time and is not baked into the pixels. use std::path::{Path, PathBuf}; use std::sync::Arc; use lightcraft_catalog::Photo; use lightcraft_color::transfer::linear_to_srgb; +use lightcraft_pipeline::tone::CameraTone; use lightcraft_raster::{Rgb32f, Rgba8}; const MAGIC: &[u8] = b"LCSP1\n"; @@ -18,8 +21,11 @@ pub fn file_name(p: &Photo) -> String { format!("{:032x}.lcsp", h.0) } -/// Encode a source image as a smart preview. -pub fn encode(img: &Rgb32f) -> Result, String> { +/// The most header [`is_valid`] reads (the JSON line before the JPEG). +const HEADER_MAX: u64 = 4096; + +/// 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, 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() { @@ -47,22 +53,29 @@ pub fn encode(img: &Rgb32f) -> Result, String> { ) .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()); + let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale}); + if let Some(t) = tone { + head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; + } + out.extend_from_slice(head.to_string().as_bytes()); + out.push(b'\n'); out.extend_from_slice(&jpg); Ok(out) } -/// Decode a smart preview back into a source image. -pub fn decode(bytes: &[u8]) -> Result { +/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid +/// curve is ignored, like a missing one). +pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option), 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 tone = head.get("tone").and_then(|t| serde_json::from_value::(t.clone()).ok()); 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) + Ok((img, tone)) } /// Where a library keeps its smart previews. @@ -157,8 +170,8 @@ pub fn migrate(from: &Path, to: &Path, what: Existing) -> (usize, Vec) { 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() { + let mut head = Vec::with_capacity(1024); + if (&mut f).take(HEADER_MAX).read_to_end(&mut head).is_err() { return false; } let Some(rest) = head.strip_prefix(MAGIC) else { return false }; @@ -175,9 +188,9 @@ pub fn is_valid(path: &Path) -> bool { } /// Load the proxy at `path`. -pub fn load(path: &Path) -> Result, String> { +pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - decode(&b).map(Arc::new) + decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone }) } #[cfg(test)] @@ -228,7 +241,7 @@ mod tests { 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(); + let good = encode(&img, None).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] { @@ -242,11 +255,45 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn camera_tone_travels_with_the_proxy() { + let tone = CameraTone::new(std::array::from_fn(|i| { + let x = 0.004 * 1.17f32.powi(i as i32); + [x, 0.95 * (1.0 - (-2.7 * x).exp())] + })) + .unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let bytes = encode(&img, Some(&tone)).unwrap(); + assert_eq!(decode(&bytes).unwrap().1, Some(tone)); + let dir = temp("tone"); + let path = dir.join("t.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + // the longer header is still read by the validity check, and the curve by `load` + assert!(is_valid(&path)); + let loaded = load(&path).unwrap(); + assert_eq!(loaded.camera_tone, Some(tone)); + let header = lightcraft_pipeline::SourceInfo { raw: true, ..Default::default() }; + assert_eq!(loaded.info_or(header).camera_tone, Some(tone)); + // a hostile curve (reversing knots) is ignored, not trusted + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + let mut knots: Vec<[f32; 2]> = serde_json::from_value(head["tone"]["knots"].clone()).unwrap(); + knots.swap(3, 4); + head["tone"]["knots"] = serde_json::json!(knots); + let mut hostile = MAGIC.to_vec(); + hostile.extend_from_slice(head.to_string().as_bytes()); + hostile.extend_from_slice(&bytes[nl..]); + let (pixels, tone) = decode(&hostile).unwrap(); + assert_eq!((pixels.width, tone), (img.width, None)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); - let back = decode(&encode(&img).unwrap()).unwrap(); + let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap(); assert_eq!((back.width, back.height), (img.width, img.height)); + assert!(tone.is_none()); let mut err = 0.0f64; for (a, b) in img.data.iter().zip(&back.data) { for k in 0..3 {