From a10bc04b8bc72e3cd0c0a19389ba414a17e6958f Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:27:54 +0300 Subject: [PATCH] smart previews: store proxies at JPEG quality 97 and version the encoding (#709) The proxy's largest visible error against the original was the JPEG encoder's quantised 8x8 DCT DC - each block's own mean, luma and chroma - which shows as block steps across the frame and a small constant colour shift, and grows with saturation. Quality 92 -> 97 shrinks those quantisers to about a third (block -mean dE76 0.138 vs 0.202 on the fidelity test's smooth scene), at about twice the file size; what remains is the 8-bit sRGB quantisation both encoders share, which only a lossless proxy format can close. The header gains an `enc` field (encode version 2) and the folder's look marker becomes " ": a proxy or marker from before the encode version is rebuilt/rescanned once, so existing libraries pick the new quality up without any command. Old files still decode bit-identically (the scale mapping is untouched, decode ignores `enc`), and a new proxy read by an older build is left alone by its staleness check. The fidelity test pins the gain with a frozen copy of the old encoder and a block-mean dE76 metric (per-pixel dE is dominated by the shared 8-bit floor); the 0.8 bound is the geometric midpoint between the no-op ratio 1.000 and the 0.632 floor cap the 8-bit quantisation sets on this scene. --- crates/engine/src/smart.rs | 256 ++++++++++++++++++++++++++++-- crates/engine/src/tests_import.rs | 10 +- 2 files changed, 249 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 3628f188c..46b18e0c9 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -55,6 +55,28 @@ const HEADER_MAX: u64 = 4096; /// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve. const LOOK_FIELD: &str = "look_version"; +/// The JPEG quality a smart preview's pixels are stored at. The original 92 left the 8×8 DCT's +/// quantised DC — each block's own mean, luma and chroma — visible as block steps across the frame +/// and as a small constant colour shift; that is the largest part of what a proxy gets wrong +/// against the original, and the only part a parameter can reach. 97 shrinks those quantisers to +/// roughly 1/2.6 of their steps, which is what removes the steps, at about twice the file size +/// (~1 MB per preview at 2560 px). What is left is the 8-bit sRGB quantisation itself, which only +/// a lossless proxy format can close. (ICED: the measured change is printed by the fidelity test +/// below; drop this to 95 for ~1.4× the size and roughly half the gain.) +const JPEG_QUALITY: u8 = 97; + +/// The header field that records which version of this module's own encoding wrote a proxy's pixels +/// (the source mapping above and [`JPEG_QUALITY`]). Not to be confused with [`LOOK_FIELD`], which +/// 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`]). +/// 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; + /// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview. pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String> { // scale so all but the brightest 0.05 % fit into 0..1 @@ -78,13 +100,19 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String let rgba = Rgba8 { width: img.width, height: img.height, data }; let jpg = lightcraft_codecs::encode_jpeg( &lightcraft_codecs::EncodeImage::rgba8(&rgba), - 92, + JPEG_QUALITY, lightcraft_codecs::ChromaSubsampling::S444, &Default::default(), ) .map_err(|e| e.to_string())?; let mut out = MAGIC.to_vec(); - let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + let mut head = serde_json::json!({ + "w": img.width, + "h": img.height, + "scale": scale, + 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())?; } @@ -265,10 +293,17 @@ fn header_look_version(head: &serde_json::Value) -> u64 { head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) } -/// Whether the complete proxy at `path` was 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). 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. +/// The encode version a header records: a missing, negative, fractional or otherwise malformed +/// value counts as 0 (older than any real version), like [`header_look_version`]. +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 { use std::io::Read; if !is_valid(path) { @@ -283,15 +318,18 @@ pub fn is_stale(path: &Path) -> bool { 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)) + .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION) } -/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked -/// at ([`look_checked`]). +/// The marker file in a smart previews folder: the camera-look fit version and encode version its +/// proxies were last checked at ([`look_checked`]). const LOOK_MARKER: &str = "look-version"; -/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's -/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not. +/// Whether the smart previews folder `dir` has been checked for out-of-date proxies at this build's +/// camera-look fit version and encode version (or newer ones). A missing or unreadable marker counts +/// as not, and so does one written before the encode version was recorded — it only says the look +/// was checked, so this build scans the folder once more and rebuilds the proxies [`is_stale`] +/// finds. pub fn look_checked(dir: &Path) -> bool { use std::io::Read; let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false }; @@ -299,14 +337,20 @@ pub fn look_checked(dir: &Path) -> bool { if f.take(64).read_to_string(&mut text).is_err() { return false; } - text.trim().parse::().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION)) + let mut versions = text.split_whitespace(); + let (Some(look), Some(enc)) = (versions.next(), versions.next()) else { return false }; + match (look.parse::(), enc.parse::()) { + (Ok(look), Ok(enc)) => look >= u64::from(crate::camera_preview::LOOK_VERSION) && enc >= ENCODE_VERSION, + _ => false, + } } -/// Record that the proxies in `dir` were checked at this build's camera-look fit version. +/// Record that the proxies in `dir` were checked at this build's camera-look fit version and encode +/// version. pub fn mark_look_checked(dir: &Path) -> Result<(), String> { let path = dir.join(LOOK_MARKER); - lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes()) - .map_err(|e| format!("{}: {e}", path.display())) + let text = format!("{} {}\n", crate::camera_preview::LOOK_VERSION, ENCODE_VERSION); + lightcraft_catalog::safe_file::write_atomic(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) } /// Load the proxy at `path`. @@ -451,4 +495,186 @@ mod tests { 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); + } } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 185993494..5ea368d61 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1404,14 +1404,20 @@ fn smart_preview_look_marker_is_read_safely() { let dir = temp_dir("smartlook-marker"); assert!(!crate::smart::look_checked(&dir), "no marker"); let cur = crate::camera_preview::LOOK_VERSION; + let enc = crate::smart::ENCODE_VERSION; for (text, checked) in [ (String::new(), false), ("banana".into(), false), ("-1".into(), false), (format!("{}", cur.saturating_sub(1)), false), ("9".repeat(200), false), - (format!("{cur}\n"), true), - (format!(" {}\n", cur + 1), true), + // Written before the encode version was recorded: only says the look was checked, so the + // folder is scanned once more and the old-encoding proxies are rebuilt. + (format!("{cur}\n"), false), + (format!("{cur} {}\n", enc.saturating_sub(1)), false), + (format!("{cur} banana\n"), false), + (format!("{cur} {enc}\n"), true), + (format!(" {} {}\n", cur + 1, enc + 1), true), ] { std::fs::write(dir.join("look-version"), &text).unwrap(); assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}");