diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 9f72e0f48..b936830f8 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -500,357 +500,4 @@ pub fn load(path: &Path) -> Result { } #[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 sharded_and_legacy_proxies_are_counted_and_moved() { - let base = temp("shards"); - let from = base.join("from"); - let to = base.join("to"); - std::fs::create_dir_all(from.join("a1")).unwrap(); - std::fs::create_dir(&to).unwrap(); - let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp"; - let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp"; - std::fs::write(from.join(old), b"legacy").unwrap(); - std::fs::write(from.join("a1").join(new), b"sharded").unwrap(); - std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap(); - std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap(); - assert_eq!(stats(&from), (2, 13)); - let (moved, failed) = migrate(&from, &to, Existing::Move); - assert_eq!((moved, failed), (2, Vec::::new())); - assert_eq!(stats(&from), (0, 0)); - assert_eq!(stats(&to), (2, 13)); - assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy"); - assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded"); - assert!(from.join("a1").join("notes.txt").exists()); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { - let base = temp("valid"); - let (a, b) = (base.join("a"), base.join("b")); - std::fs::create_dir_all(&a).unwrap(); - std::fs::create_dir_all(&b).unwrap(); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let good = encode(&img, 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] { - std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); - assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); - } - assert!(!is_valid(&b.join("missing.lcsp"))); - // moving onto a damaged copy replaces it instead of deleting the good one - assert_eq!(migrate(&a, &b, Existing::Move).0, 1); - assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); - let _ = std::fs::remove_dir_all(&base); - } - - #[test] - fn 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 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"); - 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 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(); - 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 { - 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()); - } - - /// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into - /// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never - /// enters what is measured. Built from a rendered demo scene so the dimensions and the flat - /// row-major layout are the real ones. - fn smooth_scene(w: usize, h: usize) -> Rgb32f { - let mut img = lightcraft_scenes::demo_library()[0].render(w, h); - let (w, h) = (img.width.max(1), img.height.max(1)); - for (i, px) in img.data.iter_mut().enumerate() { - let u = (i % w) as f32 / (w - 1).max(1) as f32; - let v = (i / w) as f32 / (h - 1).max(1) as f32; - let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step - let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; - let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; - *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); - } - img - } - - /// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The - /// input is clamped into 0..1, the only range a proxy ever stores. - fn lab(c: [f32; 3]) -> [f64; 3] { - let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); - let (x, y, z) = ( - (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, - 0.2126 * r + 0.7152 * g + 0.0722 * b, - (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, - ); - let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; - let (fx, fy, fz) = (f(x), f(y), f(z)); - [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] - } - - /// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a - /// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. - fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { - let n = a.data.len().min(b.data.len()); - if n == 0 { - return 0.0; - } - (0..n) - .map(|i| { - let (p, q) = (lab(a.data[i]), lab(b.data[i])); - ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() - }) - .sum::() - / n as f64 - } - - /// A deliberately frozen copy of the pixel mapping and container this module used before the - /// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the - /// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on - /// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the - /// quality raise bought instead of trusting a written-down number. - fn encode_at_q92(img: &Rgb32f) -> Vec { - let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); - let scale = if lum.is_empty() { - 1.0 - } else { - let k = ((lum.len() as f64) * 0.9995) as usize; - let k = k.min(lum.len() - 1); - let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); - v.max(1.0) - }; - let data: Vec<[u8; 4]> = img - .data - .iter() - .map(|c| { - let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; - [e(c[0]), e(c[1]), e(c[2]), 255] - }) - .collect(); - let rgba = Rgba8 { width: img.width, height: img.height, data }; - let jpg = lightcraft_codecs::encode_jpeg( - &lightcraft_codecs::EncodeImage::rgba8(&rgba), - 92, - lightcraft_codecs::ChromaSubsampling::S444, - &Default::default(), - ) - .unwrap(); - // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field - let mut out = MAGIC.to_vec(); - let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); - out.extend_from_slice(head.to_string().as_bytes()); - out.push(b'\n'); - out.extend_from_slice(&jpg); - out - } - - /// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one - /// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its - /// mean, and rebuilds the whole block from it, so a proxy differs from the original by a - /// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels - /// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share - /// eightfold, which is what a per-pixel ΔE drowns in. - fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { - let w = a.width.min(b.width).max(1); - let h = a.height.min(b.height).max(1); - let (mut sum, mut n) = (0.0f64, 0usize); - for by in (0..h).step_by(8) { - for bx in (0..w).step_by(8) { - let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); - for y in by..(by + 8).min(h) { - for x in bx..(bx + 8).min(w) { - if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { - for k in 0..3 { - sa[k] += f64::from(ca[k]); - sb[k] += f64::from(cb[k]); - } - count += 1; - } - } - } - let d = f64::from(count.max(1)); - let (pa, pb) = ( - lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), - lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), - ); - sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); - n += 1; - } - } - sum / n.max(1) as f64 - } - - /// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit - /// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, - /// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the - /// quality raise is reverted and never trips on the honest result (measured 0.683). - const PROXY_BLOCK_GAIN: f64 = 0.8; - - #[test] - fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { - let img = smooth_scene(256, 160); - let ours = decode(&encode(&img, None).unwrap()).unwrap().0; - let before = decode(&encode_at_q92(&img)).unwrap().0; - let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); - let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); - println!( - "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ - (per-pixel {:.3} vs {:.3})", - block.0, block.1, pixel.0, pixel.1 - ); - assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); - assert!( - block.0 <= PROXY_BLOCK_GAIN * block.1, - "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ - (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", - block.0, - PROXY_BLOCK_GAIN * block.1, - block.1 - ); - } - - #[test] - fn a_proxy_from_an_older_encode_is_stale() { - let dir = temp("encode-version"); - let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let bytes = encode(&img, None).unwrap(); - let path = dir.join("p.lcsp"); - std::fs::write(&path, &bytes).unwrap(); - assert!(!is_stale(&path), "this build's own proxy is up to date"); - // one written before the encode version was recorded is not - let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); - let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); - assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); - let mut older = head.as_object().cloned().unwrap_or_default(); - older.remove(ENC_FIELD); - let mut old = MAGIC.to_vec(); - old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); - old.extend_from_slice(&bytes[nl..]); - std::fs::write(&path, &old).unwrap(); - assert!(is_valid(&path), "losing the field keeps the file readable"); - assert!(is_stale(&path), "an older encoding is rebuilt"); - // the marker records both versions, and one from before the encode version forces a scan - assert!(!look_checked(&dir)); - mark_look_checked(&dir).unwrap(); - assert!(look_checked(&dir)); - std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); - assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/engine/src/smart/tests.rs b/crates/engine/src/smart/tests.rs new file mode 100644 index 000000000..882fad689 --- /dev/null +++ b/crates/engine/src/smart/tests.rs @@ -0,0 +1,352 @@ +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 sharded_and_legacy_proxies_are_counted_and_moved() { + let base = temp("shards"); + let from = base.join("from"); + let to = base.join("to"); + std::fs::create_dir_all(from.join("a1")).unwrap(); + std::fs::create_dir(&to).unwrap(); + let old = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.lcsp"; + let new = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.lcsp"; + std::fs::write(from.join(old), b"legacy").unwrap(); + std::fs::write(from.join("a1").join(new), b"sharded").unwrap(); + std::fs::write(from.join("a1").join("notes.txt"), b"user file").unwrap(); + std::fs::write(from.join("notes.lcsp.txt"), b"keep").unwrap(); + assert_eq!(stats(&from), (2, 13)); + let (moved, failed) = migrate(&from, &to, Existing::Move); + assert_eq!((moved, failed), (2, Vec::::new())); + assert_eq!(stats(&from), (0, 0)); + assert_eq!(stats(&to), (2, 13)); + assert_eq!(std::fs::read(to.join("b2").join(old)).unwrap(), b"legacy"); + assert_eq!(std::fs::read(to.join("a1").join(new)).unwrap(), b"sharded"); + assert!(from.join("a1").join("notes.txt").exists()); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn damaged_proxies_are_invalid_and_never_kept_over_good_ones() { + let base = temp("valid"); + let (a, b) = (base.join("a"), base.join("b")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let good = encode(&img, 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] { + std::fs::write(b.join("1.lcsp"), &good[..cut]).unwrap(); + assert!(!is_valid(&b.join("1.lcsp")), "cut at {cut}"); + } + assert!(!is_valid(&b.join("missing.lcsp"))); + // moving onto a damaged copy replaces it instead of deleting the good one + assert_eq!(migrate(&a, &b, Existing::Move).0, 1); + assert_eq!(std::fs::read(b.join("1.lcsp")).unwrap(), good); + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn 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 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"); + 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 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(); + 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 { + 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()); +} + +/// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into +/// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never +/// enters what is measured. Built from a rendered demo scene so the dimensions and the flat +/// row-major layout are the real ones. +fn smooth_scene(w: usize, h: usize) -> Rgb32f { + let mut img = lightcraft_scenes::demo_library()[0].render(w, h); + let (w, h) = (img.width.max(1), img.height.max(1)); + for (i, px) in img.data.iter_mut().enumerate() { + let u = (i % w) as f32 / (w - 1).max(1) as f32; + let v = (i / w) as f32 / (h - 1).max(1) as f32; + let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step + let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; + let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; + *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); + } + img +} + +/// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The +/// input is clamped into 0..1, the only range a proxy ever stores. +fn lab(c: [f32; 3]) -> [f64; 3] { + let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); + let (x, y, z) = ( + (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, + 0.2126 * r + 0.7152 * g + 0.0722 * b, + (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, + ); + let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a +/// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. +fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let n = a.data.len().min(b.data.len()); + if n == 0 { + return 0.0; + } + (0..n) + .map(|i| { + let (p, q) = (lab(a.data[i]), lab(b.data[i])); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }) + .sum::() + / n as f64 +} + +/// A deliberately frozen copy of the pixel mapping and container this module used before the +/// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the +/// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on +/// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the +/// quality raise bought instead of trusting a written-down number. +fn encode_at_q92(img: &Rgb32f) -> Vec { + let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); + let scale = if lum.is_empty() { + 1.0 + } else { + let k = ((lum.len() as f64) * 0.9995) as usize; + let k = k.min(lum.len() - 1); + let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); + v.max(1.0) + }; + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let rgba = Rgba8 { width: img.width, height: img.height, data }; + let jpg = lightcraft_codecs::encode_jpeg( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + 92, + lightcraft_codecs::ChromaSubsampling::S444, + &Default::default(), + ) + .unwrap(); + // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field + let mut out = MAGIC.to_vec(); + let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + out.extend_from_slice(head.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(&jpg); + out +} + +/// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one +/// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its +/// mean, and rebuilds the whole block from it, so a proxy differs from the original by a +/// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels +/// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share +/// eightfold, which is what a per-pixel ΔE drowns in. +fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let w = a.width.min(b.width).max(1); + let h = a.height.min(b.height).max(1); + let (mut sum, mut n) = (0.0f64, 0usize); + for by in (0..h).step_by(8) { + for bx in (0..w).step_by(8) { + let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); + for y in by..(by + 8).min(h) { + for x in bx..(bx + 8).min(w) { + if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { + for k in 0..3 { + sa[k] += f64::from(ca[k]); + sb[k] += f64::from(cb[k]); + } + count += 1; + } + } + } + let d = f64::from(count.max(1)); + let (pa, pb) = ( + lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), + lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), + ); + sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); + n += 1; + } + } + sum / n.max(1) as f64 +} + +/// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit +/// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, +/// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the +/// quality raise is reverted and never trips on the honest result (measured 0.683). +const PROXY_BLOCK_GAIN: f64 = 0.8; + +#[test] +fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { + let img = smooth_scene(256, 160); + let ours = decode(&encode(&img, None).unwrap()).unwrap().0; + let before = decode(&encode_at_q92(&img)).unwrap().0; + let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); + let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); + println!( + "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ + (per-pixel {:.3} vs {:.3})", + block.0, block.1, pixel.0, pixel.1 + ); + assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); + assert!( + block.0 <= PROXY_BLOCK_GAIN * block.1, + "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ + (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", + block.0, + PROXY_BLOCK_GAIN * block.1, + block.1 + ); +} + +#[test] +fn a_proxy_from_an_older_encode_is_stale() { + let dir = temp("encode-version"); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let bytes = encode(&img, None).unwrap(); + let path = dir.join("p.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + assert!(!is_stale(&path), "this build's own proxy is up to date"); + // one written before the encode version was recorded is not + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); + let mut older = head.as_object().cloned().unwrap_or_default(); + older.remove(ENC_FIELD); + let mut old = MAGIC.to_vec(); + old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); + old.extend_from_slice(&bytes[nl..]); + std::fs::write(&path, &old).unwrap(); + assert!(is_valid(&path), "losing the field keeps the file readable"); + assert!(is_stale(&path), "an older encoding is rebuilt"); + // the marker records both versions, and one from before the encode version forces a scan + assert!(!look_checked(&dir)); + mark_look_checked(&dir).unwrap(); + assert!(look_checked(&dir)); + std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); + assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); + let _ = std::fs::remove_dir_all(&dir); +}