diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 0d8999989..093a4676e 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -177,7 +177,8 @@ fn build(s: &mut Session, p: &Value) -> Result { /// 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, whether the photo can take a preview-fitted look: -/// vendor raws and DNGs (whose tone can come from their preview). +/// vendor raws and DNGs (whose tone can come from their preview); only those proxies go stale when +/// the fit changes; an older encoding makes any proxy stale). #[cfg(not(target_arch = "wasm32"))] type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); @@ -210,15 +211,16 @@ struct SmartCounts { failed: Vec, } -/// `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. +/// `source` if it reads the photo's original, else `None`. A source decoded from a smart preview +/// (it now carries the proxy's stored facts), a proxy standing in for an offline original, and the +/// proxy fallback of a file source are not the original: refreshing a stale proxy from one of them +/// would stamp its older look as current. #[cfg(not(target_arch = "wasm32"))] fn original_only(source: crate::media::SourceRef) -> Option { 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)), + SourceRef::Loaded(d) if d.info.is_some() && !d.from_proxy => Some(SourceRef::Loaded(d)), _ => None, } } @@ -257,10 +259,11 @@ fn smart_run( } } else { // 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 + // full drive) is (re)built, and so is one written by an older encoding or camera-look + // fit (see `smart::ENCODE_VERSION` and `camera_preview::LOOK_VERSION`) when its + // original can be read let damaged = path.exists(); - let stale = look && damaged && crate::smart::is_stale(&path); + let stale = damaged && crate::smart::is_stale_for(&path, look); if damaged && !stale && crate::smart::is_valid(&path) { n.built += 1; } else { @@ -273,7 +276,10 @@ fn smart_run( let r = source .ok_or_else(|| "nothing to build from".to_string()) .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(|src| { + let info = src.info_or(Default::default()); + crate::smart::encode(&src.image, Some(&info)) + }) .and_then(|b| { let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?; // Only create the leaf shard: do not recreate a missing external drive. @@ -365,14 +371,14 @@ fn smart(s: &mut Session, p: &Value) -> Result { ) } -/// 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. +/// Bring smart previews up to date, once per fit-and-encoding version: when the smart previews +/// folder has not been checked at this build's versions (its marker, [`crate::smart::look_checked`]), +/// a worker thread (the UI never waits on the drives) looks at the proxies of the photos on disk +/// and rebuilds the stale ones from their originals — one whose stored pixels are an older encoding +/// (every photo), or, for a raw that takes the per-file camera look or a DNG, one written by an older fit. +/// 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> { let dir = s.media.smart_dir.clone()?; @@ -380,10 +386,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option = - s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && preview_fitted_look(p)).collect(); - let jobs: Vec = - photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); + let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. })).collect(); + let jobs: Vec = photos + .iter() + .map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), preview_fitted_look(p))) + .collect(); std::thread::Builder::new() .name("lc-smart-look".into()) .spawn(move || refresh_stale(&dir, jobs)) @@ -397,7 +404,7 @@ fn refresh_stale(dir: &std::path::Path, jobs: Vec) { let stale: Vec = jobs .into_iter() .map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look)) - .filter(|(_, path, _, _)| crate::smart::is_stale(path)) + .filter(|(_, path, _, look)| crate::smart::is_stale_for(path, *look)) .collect(); if !stale.is_empty() { // `custom`: the folder is there already, and must not be recreated if it went away since diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 4a6ee689e..5ea53e484 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -183,11 +183,14 @@ pub struct DecodedSource { pub camera_tone: Option, /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. pub denoised: Option>, + /// These pixels came from the photo's smart preview (an offline original), not from its file. + /// A refresh must never rebuild a proxy from itself: that would stamp its older look as current. + pub from_proxy: bool, } impl DecodedSource { pub fn new(image: Arc, info: Option) -> Self { - DecodedSource { image, info, camera_tone: None, denoised: None } + DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false } } /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, @@ -308,6 +311,7 @@ impl SourceRef { info: Some(info), camera_tone: None, denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), + from_proxy: false, }) }) } else { diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 46b18e0c9..d3e042a55 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -3,15 +3,19 @@ //! 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. 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. +//! at render time and is not baked into the pixels, and the file's other decoder facts (its colour +//! model, gain table map and semantic mattes) ride in a tail after the JPEG, so offline edits +//! interpret the stored pixels the same way the online source did. use std::path::{Path, PathBuf}; use std::sync::Arc; use lightcraft_catalog::Photo; use lightcraft_color::transfer::linear_to_srgb; +use lightcraft_pipeline::SourceInfo; +use lightcraft_pipeline::masks::{MatteKind, Mattes}; use lightcraft_pipeline::tone::CameraTone; -use lightcraft_raster::{Rgb32f, Rgba8}; +use lightcraft_raster::{Image, Rgb32f, Rgba8}; const MAGIC: &[u8] = b"LCSP1\n"; @@ -70,15 +74,73 @@ const JPEG_QUALITY: u8 = 97; /// tracks the camera-look fit that is baked into those pixels. const ENC_FIELD: &str = "enc"; -/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG -/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which -/// makes every proxy from before this version stale. Version 2 is the first to record the field at -/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]). +/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping, JPEG +/// settings or stored facts change, so proxies an older build wrote are rebuilt: a missing field +/// reads as 0, which makes every proxy from before this version stale. Version 2 is the first to +/// record the field at all (a version 1 proxy is stored at JPEG quality 92, which this build +/// raises to [`JPEG_QUALITY`]); version 3 is the first to store the decoder facts in the tail. A +/// stale proxy whose original is offline is kept as it is (see [`is_stale`]). /// Crate-visible so the look-marker test pins the marker format to it instead of to a literal. -pub(crate) const ENCODE_VERSION: u64 = 2; +pub(crate) const ENCODE_VERSION: u64 = 3; -/// 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> { +/// The decoder facts a render interprets its settings against, stored with the proxy's pixels so +/// an offline original edits like it did online (the catalog's header facts cannot carry them). +/// In the proxy's tail, not its header: a real file's gain table map runs far past any header +/// budget (up to 12 336 gains). +#[derive(serde::Serialize, serde::Deserialize)] +struct SmartFacts { + raw: bool, + as_shot_temp: f64, + as_shot_tint: f64, + relative_wb: bool, + legacy_relative_wb: bool, + lens: Option, + camera_color: Option, + local_tone: Option, + /// The semantic mattes whose bytes follow the facts, in this order. + #[serde(default)] + mattes: Vec, +} + +/// One stored matte's shape; its `len` bytes of 0..255 alpha follow the facts. +#[derive(serde::Serialize, serde::Deserialize)] +struct StoredMatte { + kind: u8, + w: usize, + h: usize, + len: usize, +} + +/// The facts and mattes a proxy's tail holds: the length-prefixed JSON, then one byte plane per +/// stored matte. `None` for an old proxy or a malformed tail; like a missing curve, the offline +/// render then keeps its header facts. +fn stored_facts(head: &serde_json::Value, bytes: &[u8]) -> Option<(SmartFacts, Mattes)> { + let tail = usize::try_from(head.get("tail")?.as_u64()?).ok()?; + if tail == 0 || tail > bytes.len() { + return None; + } + let t = bytes.get(bytes.len() - tail..)?; + let n = u32::from_le_bytes(t.get(..4)?.try_into().ok()?) as usize; + let facts: SmartFacts = serde_json::from_slice(t.get(4..4 + n)?).ok()?; + let mut mattes = Mattes::default(); + let mut at = 4 + n; + for m in &facts.mattes { + let kind = MatteKind::from_code(m.kind)?; + if m.w == 0 || m.h == 0 || m.w.checked_mul(m.h)? != m.len { + return None; + } + mattes.push(kind, Image { width: m.w, height: m.h, data: t.get(at..at.checked_add(m.len)?)?.to_vec() }); + at += m.len; + } + if at != t.len() { + return None; + } + Some((facts, mattes)) +} + +/// Encode a source image and its decoder facts (the camera tone curve and the [`SourceInfo`] the +/// render interprets settings against, if given) as a smart preview. +pub fn encode(img: &Rgb32f, info: Option<&SourceInfo>) -> 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() { @@ -113,28 +175,79 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String LOOK_FIELD: crate::camera_preview::LOOK_VERSION, ENC_FIELD: ENCODE_VERSION, }); - if let Some(t) = tone { - head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; + // The tail: the decoder facts (and the file's semantic mattes) after the JPEG, so the header + // stays small. `tail` records its byte count from the end of the file, so a header rewritten + // in place (an edited look version) keeps pointing at it. + let mut tail: Vec = Vec::new(); + if let Some(i) = info { + if let Some(t) = &i.camera_tone { + head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; + } + let matte_list: Vec = i + .mattes + .as_deref() + .map(|m| m.iter().map(|(kind, a)| StoredMatte { kind: kind.code(), w: a.width, h: a.height, len: a.data.len() }).collect()) + .unwrap_or_default(); + let facts = SmartFacts { + raw: i.raw, + as_shot_temp: i.as_shot_temp, + as_shot_tint: i.as_shot_tint, + relative_wb: i.relative_wb, + legacy_relative_wb: i.legacy_relative_wb, + lens: i.lens, + camera_color: i.camera_color.as_deref().cloned(), + local_tone: i.local_tone.as_deref().cloned(), + mattes: matte_list, + }; + let facts = serde_json::to_vec(&facts).map_err(|e| e.to_string())?; + let n = u32::try_from(facts.len()).map_err(|_| "smart preview facts too large")?; + tail.extend_from_slice(&n.to_le_bytes()); + tail.extend_from_slice(&facts); + if let Some(m) = i.mattes.as_deref() { + for (_, a) in m.iter() { + tail.extend_from_slice(&a.data); + } + } + head["tail"] = serde_json::json!(tail.len()); } out.extend_from_slice(head.to_string().as_bytes()); out.push(b'\n'); out.extend_from_slice(&jpg); + out.extend_from_slice(&tail); Ok(out) } -/// 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> { +/// Decode a smart preview back into a source image, its stored camera tone curve and the stored +/// decoder facts (an invalid curve or tail is ignored, like a missing one). +pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option, 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())?; + let tail = head.get("tail").and_then(serde_json::Value::as_u64).and_then(|t| usize::try_from(t).ok()).unwrap_or(0); + let start = MAGIC.len() + nl + 1; + let end = bytes.len().saturating_sub(tail); + if end <= start { + return Err("bad smart preview".into()); + } + let d = lightcraft_codecs::decode(bytes.get(start..end).ok_or("bad smart preview")?, 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, tone)) + let info = stored_facts(&head, bytes).map(|(f, mattes)| SourceInfo { + raw: f.raw, + as_shot_temp: f.as_shot_temp, + as_shot_tint: f.as_shot_tint, + relative_wb: f.relative_wb, + legacy_relative_wb: f.legacy_relative_wb, + lens: f.lens, + camera_color: f.camera_color.map(Arc::new), + camera_tone: tone, + mattes: (!mattes.is_empty()).then(|| Arc::new(mattes)), + local_tone: f.local_tone.map(Arc::new), + }); + Ok((img, tone, info)) } /// Where a library keeps its smart previews. @@ -276,15 +389,26 @@ pub fn is_valid(path: &Path) -> bool { } 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)); + let json = rest.get(..nl).and_then(|h| serde_json::from_slice::(h).ok()); + let size_ok = json.as_ref().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 tail = json.and_then(|h| h.get("tail").and_then(serde_json::Value::as_u64)).and_then(|t| usize::try_from(t).ok()).unwrap_or(0); + if tail == 0 { + // no tail: the file ends at the JPEG's end marker + let mut end = [0u8; 2]; + return f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]; + } + // with a tail, the JPEG must end right before it and the tail must be there in full + let Ok(meta) = f.metadata() else { return false }; + let Ok(file_len) = usize::try_from(meta.len()) else { return false }; + let Some(jpeg_end) = file_len.checked_sub(tail) else { return false }; + if jpeg_end <= MAGIC.len() + nl + 3 { + return false; + } let mut end = [0u8; 2]; - f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] + f.seek(SeekFrom::Start((jpeg_end - 2) as u64)).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 @@ -299,12 +423,15 @@ fn header_encode_version(head: &serde_json::Value) -> u64 { head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) } -/// Whether the complete proxy at `path` is out of date for this build: 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), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG -/// quality). 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 { +/// Whether the complete proxy at `path` is out of date for a photo and must be rebuilt: written by +/// an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG quality) — this +/// reaches every proxy — or, when the photo takes the per-file camera look (`look`, +/// [`lightcraft_catalog::Photo::fitted_look`]), 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; the fit only reaches +/// those raws, so a JPEG's or DNG's proxy is never stale for it). 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_for(path: &Path, look: bool) -> bool { use std::io::Read; if !is_valid(path) { return false; @@ -316,9 +443,14 @@ pub fn is_stale(path: &Path) -> bool { } 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::(h).ok()) - .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION) + rest.get(..nl).and_then(|h| serde_json::from_slice::(h).ok()).is_some_and(|h| { + header_encode_version(&h) < ENCODE_VERSION || (look && header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION)) + }) +} + +/// [`is_stale_for`] for a photo that takes the per-file camera look: both conditions apply. +pub fn is_stale(path: &Path) -> bool { + is_stale_for(path, true) } /// The marker file in a smart previews folder: the camera-look fit version and encode version its @@ -356,7 +488,13 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> { /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None }) + decode(&b).map(|(image, camera_tone, info)| crate::media::DecodedSource { + image: Arc::new(image), + info, + camera_tone, + denoised: None, + from_proxy: true, + }) } #[cfg(test)] @@ -453,7 +591,8 @@ mod tests { })) .unwrap(); let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let bytes = encode(&img, Some(&tone)).unwrap(); + let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() }; + let bytes = encode(&img, Some(&info)).unwrap(); assert_eq!(decode(&bytes).unwrap().1, Some(tone)); let dir = temp("tone"); let path = dir.join("t.lcsp"); @@ -473,15 +612,50 @@ mod tests { 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(); + let (pixels, tone, _) = decode(&hostile).unwrap(); assert_eq!((pixels.width, tone), (img.width, None)); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn facts_and_mattes_travel_with_the_proxy() { + let img = lightcraft_scenes::demo_library()[0].render(32, 20); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] }); + let info = SourceInfo { + raw: true, + as_shot_temp: 4200.0, + as_shot_tint: -5.0, + legacy_relative_wb: true, + mattes: Some(std::sync::Arc::new(mattes)), + ..Default::default() + }; + let bytes = encode(&img, Some(&info)).unwrap(); + let (_, _, back) = decode(&bytes).unwrap(); + let back = back.expect("the facts are read back"); + assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true)); + assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160); + // a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts) + let dir = temp("facts"); + let path = dir.join("f.lcsp"); + std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap(); + assert!(!is_valid(&path)); + let _ = std::fs::remove_dir_all(&dir); + // an old proxy (no tail) has no facts, and neither has one with a malformed tail + assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none()); + 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(); + head["tail"] = serde_json::json!(bytes.len()); + let mut lying = MAGIC.to_vec(); + lying.extend_from_slice(head.to_string().as_bytes()); + lying.extend_from_slice(&bytes[nl..]); + assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted"); + } + #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); - let (back, tone) = decode(&encode(&img, None).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; diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 48c31f7c6..e8e03e7a6 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -479,6 +479,129 @@ fn smart_previews_stand_in_for_offline_originals() { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #1534: the decoder facts a render interprets its settings against (a camera colour model, +/// the file's local tone map, its semantic mattes) must be stored with the smart preview, so an +/// edit made while the original is offline renders the same picture as the online source instead +/// of the header-facts fallback. +#[test] +fn smart_previews_keep_the_decoder_facts_offline_edits_need() { + use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, WbMode}; + use lightcraft_pipeline::SourceInfo; + use lightcraft_pipeline::masks::{MatteKind, Mattes}; + use lightcraft_raster::Rgb32f; + + // A smooth scene: the proxy's 8-bit sRGB round trip stays within a code step, so a mismatch + // left between the offline and the online render is metadata the proxy failed to keep. + let pixels = Rgb32f::from_fn(96, 64, |x, y| { + let t = ((x * 7 + y * 3) % 96) as f32 / 96.0; + [0.02 + t * 1.2, 0.02 + t * 0.6, 0.02 + t * 0.25] + }); + let camera_color = lightcraft_pipeline::CameraColor { + tags: lightcraft_raw::ColorData { + illuminant: [21, 17], + color_matrix: [Some(lightcraft_color::Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])), None], + as_shot_neutral: Some([0.5, 1.0, 0.7]), + baseline_exposure: 0.3, + ..Default::default() + }, + developed_for: lightcraft_color::Xy { x: 0.32, y: 0.35 }, + }; + let mut mattes = Mattes::default(); + mattes.push( + MatteKind::Sky, + lightcraft_raster::Image { width: 96, height: 64, data: (0..96 * 64usize).map(|i| if (i % 96) / 24 == 1 { 255 } else { 0 }).collect() }, + ); + let local_tone = lightcraft_pipeline::local_tone::LocalTone { + map: lightcraft_raw::gaintable::GainTableMap { + points_v: 1, + points_h: 2, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], + gamma: 1.0, + gains: vec![1.0, 4.0], + }, + placement: lightcraft_raw::gaintable::SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal }, + }; + // the facts differ per case; the rest is the raw source's usual shape + let info = |camera_color: Option, + local_tone: Option, + mattes: Option| SourceInfo { + raw: true, + as_shot_temp: 5200.0, + as_shot_tint: 8.0, + camera_color: camera_color.map(std::sync::Arc::new), + local_tone: local_tone.map(std::sync::Arc::new), + mattes: mattes.map(std::sync::Arc::new), + ..Default::default() + }; + + /// Build the proxy of `pixels`+`info`, apply `edit`, then render the proxy's own pixels and + /// stored facts online, and the same proxy offline (the original renamed away): how many + /// pixels differ. Identical stored pixels and identical edits must render identically; what + /// the proxy failed to keep shows up as a difference. + fn offline_diff(tag: &str, pixels: &Rgb32f, info: &SourceInfo, edit: DevelopSettings) -> usize { + let src = temp_dir(&format!("smartmeta-src-{tag}")); + let lib = temp_dir(&format!("smartmeta-lib-{tag}")); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(r["built"], 1, "{r}"); + s.set_develop(id, edit, "reproduction").unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let stored = crate::smart::load(&crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap())).unwrap(); + let mut online = s.render_job(id, 96, 96, false, true).unwrap(); + // the proxy's own (post-JPEG) pixels, with the facts the original source had: the offline + // render must reach the same picture through the facts the proxy stored + online.source = crate::media::SourceRef::Loaded(Box::new(crate::media::DecodedSource::new(stored.image, Some(info.clone())))); + let online = online.run().rendered.unwrap().image; + // the drive goes away + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + s.media.rendered.clear(); + s.media.clear_sources(); + s.media.forget(id); + let offline = s.render_now(id, 96, 96).expect("renders from the smart preview").image; + assert_eq!(online.data.len(), offline.data.len(), "{tag}: same grid"); + let differing = online.data.iter().zip(&offline.data).filter(|(a, b)| a != b).count(); + let _ = std::fs::remove_dir_all(src.with_extension("offline")); + let _ = std::fs::remove_dir_all(&lib); + differing + } + + // a Kelvin white balance is evaluated through the camera's colour model + let mut wb = DevelopSettings::default(); + wb.wb.mode = WbMode::Custom; + wb.wb.temp = 3000.0; + wb.wb.tint = 20.0; + let differing = offline_diff("wb", &pixels, &info(Some(camera_color), None, None), wb); + assert_eq!(differing, 0, "the camera colour model is kept offline"); + + // an AI mask uses the mattes that came with the file + let mut sky = DevelopSettings::default(); + sky.masks.push(Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], + adjust: LocalAdjustments { exposure: 2.0, ..Default::default() }, + ..Default::default() + }); + let differing = offline_diff("sky", &pixels, &info(None, None, Some(mattes)), sky); + assert_eq!(differing, 0, "the file's mattes are kept offline"); + + // Camera local tone mapping reads the file's gain table map + let mut tone = DevelopSettings::default(); + tone.profile.camera_local_tone = true; + tone.light.exposure = -3.0; + let differing = offline_diff("tone", &pixels, &info(None, Some(local_tone), None), tone); + assert_eq!(differing, 0, "the local tone map is kept offline"); +} + /// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed /// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not /// reported as present and is rebuilt. @@ -1522,6 +1645,99 @@ fn smart_look_refresh_for(format: &str) { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file +/// camera look. A DNG keeps an absolute white balance, so it never takes that look; at head its +/// existing proxy was kept at an older encoding even with its original online, so the decoder +/// facts the newer encoding carries never reached it through a normal rebuild. +#[test] +fn a_proxy_with_an_older_encoding_is_refreshed_without_the_camera_look() { + let src = temp_dir("smartenc-src"); + let lib = temp_dir("smartenc-lib"); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // the test loader decodes the catalogue photo's "DNG" pixels + let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]); + let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() }; + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let mut raw = (**s.catalog.photo(id).unwrap()).clone(); + raw.format = "DNG".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().develops_raw() && !s.catalog.photo(id).unwrap().fitted_look(), "a DNG does not take the camera look"); + s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION)); + + // an existing proxy from the previous encoding, its original online: the migration must reach + // it through a normal rebuild even though the photo never takes the camera look + edit_smart_header(&file, |h| h["enc"] = json!(crate::smart::ENCODE_VERSION - 1)); + let stale_bytes = std::fs::read(&file).unwrap(); + assert!(crate::smart::is_stale(&file), "the older encoding is stale even without the camera look"); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(1), Some(1), Some(0)), "{r}"); + assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION), "migrated to the current encoding"); + assert!(!crate::smart::is_stale(&file)); + assert_ne!(std::fs::read(&file).unwrap(), stale_bytes, "the proxy was rebuilt"); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + +/// Issue #1534 (review): a cached proxy source is not the original. After an offline edit rendered +/// from a stale proxy, Build Smart Previews read that cached source back and refreshed the proxy +/// from itself, stamping its older look as current. A stale refresh reads the original only, so an +/// offline original leaves the stored look — and the bytes — as they are. +#[test] +fn a_stale_refresh_never_rebuilds_a_proxy_from_itself() { + let src = temp_dir("smartself-src"); + let lib = temp_dir("smartself-lib"); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]); + let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() }; + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + // a raw that takes the per-file camera look, so an older fit makes its proxy stale + 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().fitted_look()); + s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(r["built"], 1, "{r}"); + edit_smart_header(&file, |h| h["look_version"] = json!(0)); + let stale_bytes = std::fs::read(&file).unwrap(); + assert!(crate::smart::is_stale(&file), "the stored look is behind this build's fit"); + + // the original goes offline, then a preview-size render loads the proxy into the source cache + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + s.media.forget(id); + let _ = s.render_now(id, 600, 600).expect("renders from the smart preview"); + + // Build Smart Previews must not take that cached proxy for the original: it can't refresh a + // stale proxy from itself + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(std::fs::read(&file).unwrap(), stale_bytes, "the offline proxy is preserved"); + assert!(crate::smart::is_stale(&file), "still stale: the original was not read"); + let _ = std::fs::remove_dir_all(src.with_extension("offline")); + 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() { diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 455cec022..34df1f8a9 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -59,7 +59,7 @@ pub use tone::ToneMap; /// A raw source's own colour model, for white balance: like Lightroom, a white balance /// re-evaluates the camera's colour matrices at the chosen white (camera-space white balance, DNG /// spec ch. 6) instead of adapting the as-shot rendering ([`local::wb_matrix_for`]). -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct CameraColor { /// The file's colour tags (its profile look left out: it is applied at load). pub tags: lightcraft_raw::ColorData, diff --git a/crates/pipeline/src/local_tone.rs b/crates/pipeline/src/local_tone.rs index c38a25c9c..bb7adfe51 100644 --- a/crates/pipeline/src/local_tone.rs +++ b/crates/pipeline/src/local_tone.rs @@ -19,7 +19,7 @@ use crate::{Plan, SourceInfo}; use lightcraft_develop::DevelopSettings; /// A raw's gain table map and where its developed source sits in the active area. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct LocalTone { pub map: GainTableMap, pub placement: SourcePlacement, diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index c9a7dada9..36cc29192 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -25,6 +25,33 @@ pub enum MatteKind { Glasses, } +impl MatteKind { + /// A stable code for serialization (e.g. a smart preview's stored mattes); never renumber. + pub fn code(self) -> u8 { + match self { + MatteKind::Sky => 0, + MatteKind::Person => 1, + MatteKind::Skin => 2, + MatteKind::Hair => 3, + MatteKind::Teeth => 4, + MatteKind::Glasses => 5, + } + } + + /// [`MatteKind::code`]'s inverse (`None` for a code this build doesn't know). + pub fn from_code(code: u8) -> Option { + match code { + 0 => Some(MatteKind::Sky), + 1 => Some(MatteKind::Person), + 2 => Some(MatteKind::Skin), + 3 => Some(MatteKind::Hair), + 4 => Some(MatteKind::Teeth), + 5 => Some(MatteKind::Glasses), + _ => None, + } + } +} + /// Segmentation mattes that came with the source: 0..255 alpha planes, each covering the whole /// (EXIF-oriented) source image at its own resolution. Several mattes of one kind (e.g. one per /// person) are combined with max. @@ -56,6 +83,12 @@ impl Mattes { self.mattes.iter().map(|(_, m)| m.data.len()).sum() } + /// The stored mattes, in insertion order (kind and plane), e.g. for storing them with a + /// photo's proxy source. + pub fn iter(&self) -> impl Iterator)> { + self.mattes.iter().map(|(k, m)| (*k, m)) + } + fn of(&self, kinds: &[MatteKind]) -> Vec<&Image> { self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect() }