Merge PR #1809: engine: keep the decoder facts a smart preview's offline edits need

crates/engine/src/cmd/previews.rs: the opening refresh takes every photo on disk (#1809) with the preview_fitted_look flag of #1545 (vendor raws and DNGs); SmartJob and refresh docs name both.
This commit is contained in:
aiongg
2026-10-22 02:38:42 +00:00
7 changed files with 493 additions and 59 deletions
+29 -22
View File
@@ -177,7 +177,8 @@ 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, 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<crate::media::SourceRef>, bool);
@@ -210,15 +211,16 @@ struct SmartCounts {
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.
/// `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<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)),
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<Value> {
)
}
/// 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<std::thread::JoinHandle<()>> {
let dir = s.media.smart_dir.clone()?;
@@ -380,10 +386,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
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 { .. }) && preview_fitted_look(p)).collect();
let jobs: Vec<SmartJob> =
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<SmartJob> = 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<SmartJob>) {
let stale: Vec<SmartJob> = 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
+5 -1
View File
@@ -183,11 +183,14 @@ pub struct DecodedSource {
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
/// The same picture developed from the AI-denoised mosaic, when the photo has one cached.
pub denoised: Option<Arc<Twin>>,
/// 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<Rgb32f>, info: Option<SourceInfo>) -> 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 {
+208 -34
View File
@@ -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<Vec<u8>, 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<lightcraft_develop::EmbeddedLens>,
camera_color: Option<lightcraft_pipeline::CameraColor>,
local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>,
/// The semantic mattes whose bytes follow the facts, in this order.
#[serde(default)]
mattes: Vec<StoredMatte>,
}
/// 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<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() {
@@ -113,28 +175,79 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, 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<u8> = 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<StoredMatte> = 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<CameraTone>), 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<CameraTone>, Option<SourceInfo>), 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::<CameraTone>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<crate::media::DecodedSource, String> {
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;
+216
View File
@@ -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<lightcraft_pipeline::CameraColor>,
local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>,
mattes: Option<Mattes>| 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() {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+33
View File
@@ -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<Self> {
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<Item = (MatteKind, &Image<u8>)> {
self.mattes.iter().map(|(k, m)| (*k, m))
}
fn of(&self, kinds: &[MatteKind]) -> Vec<&Image<u8>> {
self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect()
}