From ec6637f238e3f738dd79b05423821dde291b9cbc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:01:53 -0400 Subject: [PATCH 1/3] =?UTF-8?q?M5.4b:=20faster=20raw=20path=20=E2=80=94=20?= =?UTF-8?q?bin=20previews/thumbnails=20straight=20from=20the=20mosaic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lightcraft-raw: RawImage::develop_binned(k) — k×k superpixel binning of the CFA (Bayer k even, X-Trans k multiple of 3), black/white normalised row by row, default crop only, clipping preserved for highlight reconstruction. 2560 px previews of a 24 MP raw bin 2×, thumbnails 4–8×; exports / 1:1 still demosaic (AHD) at full size. - highlight::reconstruct and its pyramid fill are rayon-parallel (bit-exact). - X-Trans directional demosaic: precomputed per-phase neighbour offsets and no reflection in the interior, no per-pixel allocation (bit-exact, tested against the reference formulation): 2.9 s → 0.75 s CPU at 24 MP. - files::load_bytes runs on a rayon worker so its short parallel loops don't each wait for the pool to wake (dominant cost on a loaded machine); LIGHTCRAFT_PROFILE prints per-stage source timings; files::bin_factor picks the block size. - render_bench ONLY=source: per-file decode / normalise / demosaic / binning / load. - Fix: u64 overflow on hostile IFD sizes in DNG/ARW/NEF raw-IFD selection (proptest). load_bytes(2560) CPU time, ARW a7 III 24 MP: 2347 → 240 ms; X-T20: 3120 → 1220 ms. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/Cargo.toml | 1 + crates/engine/examples/render_bench.rs | 37 +++ crates/engine/src/files.rs | 60 ++++- crates/pipeline/src/lib.rs | 3 +- crates/raw/src/binned.rs | 194 +++++++++++++++ crates/raw/src/demosaic/xtrans.rs | 236 +++++++++++++++++-- crates/raw/src/dng.rs | 2 +- crates/raw/src/highlight.rs | 87 +++---- crates/raw/src/lib.rs | 4 +- crates/raw/src/vendor/arw.rs | 2 +- crates/raw/src/vendor/nef.rs | 2 +- crates/raw/tests/robust.proptest-regressions | 7 + 13 files changed, 561 insertions(+), 75 deletions(-) create mode 100644 crates/raw/src/binned.rs create mode 100644 crates/raw/tests/robust.proptest-regressions diff --git a/Cargo.lock b/Cargo.lock index 5bcb8ca4..111bb6e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2093,6 +2093,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "log", + "rayon", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 59922a08..dcaab969 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,6 +25,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +rayon = { workspace = true } ab_glyph = { workspace = true } [dev-dependencies] diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 8e0027f8..c1cb9040 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -88,6 +88,43 @@ fn main() { let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5); let only = std::env::var("ONLY").unwrap_or_default(); let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o)); + if only == "source" { + // opening a photo: where the time goes before the first sharp loupe (per file) + for path in &args { + let bytes = std::fs::read(path).expect("read"); + println!("{path}"); + let raw = lightcraft_raw::decode(&bytes).expect("decode"); + println!(" {}×{} cpp {} cfa {:?}", raw.width, raw.height, raw.cpp, raw.cfa.as_ref().map(|c| c.name())); + println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode")))); + println!(" {:<36} {}", "normalize:", best(n, || drop(raw.normalized().expect("norm")))); + let norm = raw.normalized().expect("norm"); + println!(" {:<36} {}", "demosaic AHD:", best(n, || drop(lightcraft_raw::demosaic(&norm, lightcraft_raw::Method::Ahd)))); + if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) { + println!(" {:<36} {}", format!("binned ×{k} (2560 preview):"), best(n, || drop(raw.develop_binned(k, 0.99)))); + let img = raw.develop_binned(k, 0.99).expect("bin").expect("binnable"); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + println!( + " {:<36} {}", + "highlight reconstruct (binned):", + best(n, || { + let mut i = img.clone(); + lightcraft_raw::highlight::reconstruct(&mut i, t.wb, 0.99); + }) + ); + println!(" {:<36} {}", "clone (binned):", best(n, || drop(img.clone()))); + println!(" {:<36} {}", "fit 2560 (binned):", best(n, || drop(fit(&img, 2560, 2560, Filter::Box)))); + } + println!(" {:<36} {}", "embedded preview (2560):", best(n, || drop(lightcraft_engine::files::load_embedded_preview(&bytes, 2560)))); + for edge in [512usize, 2560, usize::MAX] { + println!( + " {:<36} {}", + format!("load_bytes({edge}):"), + best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, edge).expect("load"))) + ); + } + } + return; + } let (full, info) = match args.first() { Some(path) => { let bytes = std::fs::read(path).expect("read"); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index f9d08522..cd1cc4b5 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -167,8 +167,29 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { }) } +/// Sensor clip level (normalised) for highlight reconstruction. +const HIGHLIGHT_CLIP: f32 = 0.99; + +/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels: +/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the +/// 2560 px preview). `None` = demosaic at full size. +pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option { + let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); + let long = if c.width > 1 && c.height > 1 { c.width.max(c.height) } else { raw.active_area.width.max(raw.active_area.height) }; + let need = max_edge.saturating_mul(9) / 10; + [8usize, 6, 4, 3, 2].into_iter().find(|&k| long / k >= need.max(1) && raw.can_bin(k)) +} + /// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented. +/// +/// Runs on a rayon worker: its many short parallel loops then start on the worker's own queue +/// instead of each one waking the pool from outside and waiting for it (which costs more than the +/// loops themselves when the machine is busy). pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + rayon::scope(|_| load_bytes_now(bytes, max_edge)) +} + +fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { if lightcraft_raw::probe(bytes).is_some() { let mut raw = match lightcraft_raw::decode(bytes) { Ok(r) => r, @@ -181,11 +202,26 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in. let lens = embedded_lens(&raw); raw.opcodes.list3.retain(|op| !is_lens_opcode(op)); - let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - let mut img = raw.develop(method).map_err(|e| e.to_string())?; + // Previews and thumbnails bin the mosaic straight to (about) the size they need; only + // larger levels (exports, 1:1) demosaic the whole sensor. + let t0 = std::time::Instant::now(); + let binned = match bin_factor(&raw, max_edge) { + Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?, + None => None, + }; + let mut img = match binned { + Some(img) => img, + None => { + let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + raw.develop(method).map_err(|e| e.to_string())? + } + }; + let mut stages = vec![("develop", t0.elapsed())]; let xy = lightcraft_raw::color::as_shot_white_xy(&raw); let t = lightcraft_raw::color::camera_transform(&raw, xy); - lightcraft_raw::highlight::reconstruct(&mut img, t.wb, 0.99); + stages.push(("transform", t0.elapsed())); + lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP); + stages.push(("highlights", t0.elapsed())); let m = t.matrix.to_f32(); let gain = 2f32.powf(t.baseline_exposure as f32); let wb = t.wb; @@ -197,7 +233,23 @@ pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), ((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0), ] }); - let img = fit(&img, max_edge, max_edge, Filter::Box).oriented(raw.orientation); + stages.push(("colour", t0.elapsed())); + let img = fit(&img, max_edge, max_edge, Filter::Box); + stages.push(("fit", t0.elapsed())); + let img = img.oriented(raw.orientation); + stages.push(("orient", t0.elapsed())); + if lightcraft_pipeline::profiling() { + let mut prev = std::time::Duration::ZERO; + let parts: Vec = stages + .iter() + .map(|(n, t)| { + let d = *t - prev; + prev = *t; + format!("{n} {:.1}", d.as_secs_f64() * 1e3) + }) + .collect(); + eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); + } let (temp, tint) = xy_to_temp_tint(xy); return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens })); } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 3ccee51b..4ba0d886 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -351,9 +351,8 @@ pub(crate) fn is_bw(s: &DevelopSettings) -> bool { s.treatment == Treatment::Bw || s.profile.id == "lc.mono" } -/// Parallel map over output rows with index. /// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr. -pub(crate) fn profiling() -> bool { +pub fn profiling() -> bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some()) } diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs new file mode 100644 index 00000000..fde6c2c0 --- /dev/null +++ b/crates/raw/src/binned.rs @@ -0,0 +1,194 @@ +//! Reduced-size development straight from the mosaic ("superpixel" binning). +//! +//! When the caller needs an image at most half (or a third, a quarter…) of the sensor size — a +//! 2560 px loupe preview or a grid thumbnail of a 24 MP raw — demosaicing the full mosaic and then +//! downscaling throws most of the work away. Instead every `k × k` block of CFA samples becomes one +//! output pixel whose channels are the means of the block's samples of that colour. Every block must +//! contain all three colours (Bayer: `k` even; X-Trans: `k` a multiple of 3). There is no +//! interpolation, so there are no demosaicing artefacts; the colour planes are offset by less than +//! one output pixel, which is invisible at these scales. +//! +//! Clipping is preserved for [`crate::highlight::reconstruct`]: a channel whose block contains a +//! sample at or above `clip` takes that block's maximum sample of the colour (instead of a mean that +//! would fall just below the clip level and escape reconstruction). + +use crate::{Normalized, RawData, RawImage, Result, Rgb32f}; +use rayon::prelude::*; + +impl RawImage { + /// Can `k × k` blocks be binned (single-plane CFA data, every `k × k` window containing R, G + /// and B; Bayer: `k` even, so that every block has the same layout)? + pub fn can_bin(&self, k: usize) -> bool { + let Some(cfa) = &self.cfa else { return false }; + if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { + return false; + } + // every window position relative to the pattern + (0..cfa.height).all(|j| (0..cfa.width).all(|i| block_layout(cfa, i, j, k).1.iter().all(|&n| n > 0))) + } + + /// Like [`RawImage::develop`] at `1/k` of the size (camera RGB, white = 1.0, default crop applied, + /// not oriented). `None` when the data cannot be binned ([`RawImage::can_bin`]) or carries an + /// `OpcodeList3` (whose operations are defined at full resolution); callers then demosaic. + pub fn develop_binned(&self, k: usize, clip: f32) -> Result> { + self.validate()?; + if !self.can_bin(k) || !self.opcodes.list3.is_empty() { + return Ok(None); + } + let Some(cfa) = &self.cfa else { return Ok(None) }; + let a = self.active_area; + // bin the default crop (relative to the active area) only + let c = self.crop.clipped(a.width, a.height); + let c = if c.width < k || c.height < k { crate::Rect::new(0, 0, a.width, a.height) } else { c }; + let (bw, bh) = (c.width / k, c.height / k); + if bw == 0 || bh == 0 { + return Ok(None); + } + // Opcode lists 1/2 need the normalised copy; plain files are normalised row by row. + let norm: Option = if self.opcodes.list1.is_empty() && self.opcodes.list2.is_empty() { None } else { Some(self.normalized()?) }; + let cfa = cfa.shifted(a.x, a.y); + let range = self.white_at(0) - self.black.mean(); + let scale = if range > 0.0 { 1.0 / range } else { 1.0 }; + let uniform_black = + self.black.delta_h.is_empty() && self.black.delta_v.is_empty() && self.black.values.iter().all(|&v| v == self.black.values[0]); + let black0 = self.black.at(0, 0, 0, 1); + // colour of every sample of a block, per pattern phase of the block's corner + let (pw, ph) = (cfa.width, cfa.height); + let layouts: Vec<(Vec, [f32; 3])> = (0..ph) + .flat_map(|j| (0..pw).map(move |i| (i, j))) + .map(|(i, j)| { + let (l, n) = block_layout(&cfa, i, j, k); + (l, n.map(|n| 1.0 / n.max(1) as f32)) + }) + .collect(); + let wlen = bw * k; + let mut out = Rgb32f::new(bw, bh); + out.data.par_chunks_mut(bw).enumerate().for_each(|(by, row)| { + // the block row's k sample rows, normalised + let mut rows = vec![0f32; k * wlen]; + for dy in 0..k { + let y = c.y + by * k + dy; + let dst = &mut rows[dy * wlen..(dy + 1) * wlen]; + match (&norm, &self.data) { + (Some(nm), _) => dst.copy_from_slice(&nm.data[y * nm.width + c.x..][..wlen]), + (None, RawData::U16(d)) if uniform_black => { + let src = &d[(a.y + y) * self.width + a.x + c.x..][..wlen]; + for (o, &v) in dst.iter_mut().zip(src) { + *o = (v as f32 - black0) * scale; + } + } + (None, data) => { + let base = (a.y + y) * self.width + a.x + c.x; + for (x, o) in dst.iter_mut().enumerate() { + *o = (data.get(base + x) - self.black.at(c.x + x, y, 0, 1)) * scale; + } + } + } + } + let py = (c.y + by * k) % ph; + for (bx, px) in row.iter_mut().enumerate() { + let (layout, inv) = &layouts[py * pw + (c.x + bx * k) % pw]; + let (mut sum, mut max) = ([0f32; 3], [f32::MIN; 3]); + for dy in 0..k { + let src = &rows[dy * wlen + bx * k..][..k]; + for (&v, &col) in src.iter().zip(&layout[dy * k..(dy + 1) * k]) { + let col = col as usize; + sum[col] += v; + max[col] = max[col].max(v); + } + } + for ch in 0..3 { + px[ch] = if max[ch] >= clip { max[ch] } else { sum[ch] * inv[ch] }; + } + } + }); + Ok(Some(out)) + } +} + +/// Colours of the `k × k` samples of a block whose corner sits at pattern position `(i, j)` +/// (row-major) and the count of each colour. +fn block_layout(cfa: &crate::Cfa, i: usize, j: usize, k: usize) -> (Vec, [u32; 3]) { + let mut n = [0u32; 3]; + let l: Vec = (0..k * k) + .map(|t| { + let c = cfa.color_at(i + t % k, j + t / k).min(2); + n[c as usize] += 1; + c + }) + .collect(); + (l, n) +} + +#[cfg(test)] +mod tests { + use crate::demosaic::mosaic_from_rgb; + use crate::*; + + fn raw_from(n: &Normalized, black: f32, white: f32) -> RawImage { + let data = n.data.iter().map(|&v| (v * (white - black) + black).round() as u16).collect(); + RawImage { + format: RawFormat::Dng, + width: n.width, + height: n.height, + cpp: 1, + data: RawData::U16(data), + cfa: n.cfa.clone(), + bits: 14, + black: BlackLevel::uniform(black), + white: vec![white], + active_area: Rect::new(0, 0, n.width, n.height), + crop: Rect::new(0, 0, n.width, n.height), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + } + } + + #[test] + fn binning_matches_block_means_and_keeps_clipping() { + let img = Rgb32f::from_fn(24, 18, |x, y| [0.1 + x as f32 * 0.01, 0.2 + y as f32 * 0.01, 0.3]); + let mut raw = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("GRBG").unwrap()), 512.0, 16383.0); + assert!(raw.can_bin(2) && raw.can_bin(4) && !raw.can_bin(3)); + let b = raw.develop_binned(2, 0.99).unwrap().unwrap(); + assert_eq!((b.width, b.height), (12, 9)); + let p = b.get(3, 2); + // red sample of block (3,2) under GRBG is at (7,4); blue at (6,5); greens average (6,4)+(7,5) + assert!((p[0] - img.get(7, 4)[0]).abs() < 1e-3, "{p:?}"); + assert!((p[2] - 0.3).abs() < 1e-3); + assert!((p[1] - (img.get(6, 4)[1] + img.get(7, 5)[1]) / 2.0).abs() < 1e-3); + // a single clipped green sample keeps the block's green clipped + if let RawData::U16(d) = &mut raw.data { + d[4 * 24 + 6] = 16383; + } + assert!(raw.develop_binned(2, 0.99).unwrap().unwrap().get(3, 2)[1] >= 0.99); + // default crop is honoured at the binned scale + raw.crop = Rect::new(2, 2, 20, 12); + let c = raw.develop_binned(2, 0.99).unwrap().unwrap(); + assert_eq!((c.width, c.height), (10, 6)); + // X-Trans: 3×3 blocks hold all colours, 2×2 don't + let x = raw_from(&mosaic_from_rgb(&img, &Cfa::xtrans()), 0.0, 1000.0); + assert!(x.can_bin(3) && x.can_bin(6) && !x.can_bin(2)); + let xb = x.develop_binned(3, 0.99).unwrap().unwrap(); + assert_eq!((xb.width, xb.height), (8, 6)); + assert!((xb.get(4, 3)[2] - 0.3).abs() < 2e-3); + } + + #[test] + fn binning_matches_demosaic_downscaled_on_smooth_images() { + let img = Rgb32f::from_fn(64, 48, |x, y| [0.2 + 0.3 * (x as f32 / 64.0), 0.4 - 0.2 * (y as f32 / 48.0), 0.25]); + let raw = raw_from(&mosaic_from_rgb(&img, &Cfa::bayer("RGGB").unwrap()), 0.0, 4095.0); + let b = raw.develop_binned(2, 0.99).unwrap().unwrap(); + let full = lightcraft_raster::resample::half(&raw.develop(Method::Ahd).unwrap()); + for y in 2..b.height - 2 { + for x in 2..b.width - 2 { + for c in 0..3 { + assert!((b.get(x, y)[c] - full.get(x, y)[c]).abs() < 0.01, "({x},{y})"); + } + } + } + } +} diff --git a/crates/raw/src/demosaic/xtrans.rs b/crates/raw/src/demosaic/xtrans.rs index c26f768b..2b50b66c 100644 --- a/crates/raw/src/demosaic/xtrans.rs +++ b/crates/raw/src/demosaic/xtrans.rs @@ -13,24 +13,115 @@ use lightcraft_raster::par_rows; const EPS: f32 = 1e-3; -fn green(m: &Mosaic) -> Vec { +/// Same-colour neighbour offsets for every pattern phase, in scan order (so sums run in the same +/// order as a plain 2-D loop and interior pixels match border pixels bit for bit). +struct Offsets { + pw: usize, + ph: usize, + /// Per phase: green offsets at radius 1, then those at radius 2 (ring order as the loop below). + green: Vec<(Vec<(isize, isize)>, Vec<(isize, isize)>)>, + /// Per phase and colour (0 = red, 1 = blue): 5×5 offsets of that colour. + rb: Vec<[Vec<(isize, isize)>; 2]>, +} + +impl Offsets { + fn new(m: &Mosaic) -> Offsets { + let (pw, ph) = (m.cfa.width.max(1), m.cfa.height.max(1)); + // phases are evaluated far from the borders (pattern coordinates wrap) + let col = |x: isize, y: isize| m.cfa.color_at(x.rem_euclid(pw as isize) as usize, y.rem_euclid(ph as isize) as usize); + let mut green = Vec::new(); + let mut rb = Vec::new(); + for py in 0..ph as isize { + for px in 0..pw as isize { + let ring = |r: isize| { + let mut v = Vec::new(); + for dy in -r..=r { + for dx in -r..=r { + if (dx != 0 || dy != 0) && col(px + dx, py + dy) == 1 { + v.push((dx, dy)); + } + } + } + v + }; + green.push((ring(1), ring(2))); + let of = |c: u8| { + let mut v = Vec::new(); + for dy in -2isize..=2 { + for dx in -2isize..=2 { + if col(px + dx, py + dy) == c { + v.push((dx, dy)); + } + } + } + v + }; + rb.push([of(0), of(2)]); + } + } + Offsets { pw, ph, green, rb } + } + #[inline] + fn phase(&self, x: usize, y: usize) -> usize { + (y % self.ph) * self.pw + x % self.pw + } +} + +/// Robust weighted mean of `vals` around their median. +#[inline] +fn robust_mean(vals: &[f32]) -> f32 { + let mut sorted = [0f32; 24]; + let sorted = &mut sorted[..vals.len()]; + sorted.copy_from_slice(vals); + sorted.sort_unstable_by(|a, b| a.total_cmp(b)); + let med = sorted[sorted.len() / 2]; + let (mut s, mut ws) = (0.0f32, 0.0f32); + for &v in vals { + let wt = 1.0 / (EPS + (v - med).abs()); + s += wt * v; + ws += wt; + } + s / ws +} + +fn green(m: &Mosaic, off: &Offsets) -> Vec { let (w, h) = (m.w, m.h); let mut g = vec![0f32; w * h]; par_rows(&mut g, w, |y, row| { - let y = y as isize; + let interior_y = y >= 2 && y + 2 < h; + let yi = y as isize; let mut vals: Vec = Vec::with_capacity(24); for (x, o) in row.iter_mut().enumerate() { + if interior_y && x >= 2 && x + 2 < w { + // fast path: precomputed offsets, no reflection + let ph = off.phase(x, y); + let i = y * w + x; + if m.cfa.color_at(x, y) == 1 { + *o = m.data[i]; + continue; + } + let (r1, r2) = &off.green[ph]; + let ring = if r1.is_empty() { r2 } else { r1 }; + if ring.is_empty() { + *o = m.data[i]; + continue; + } + vals.clear(); + vals.extend(ring.iter().map(|&(dx, dy)| m.data[(i as isize + dy * w as isize + dx) as usize])); + *o = robust_mean(&vals); + continue; + } let x = x as isize; - if m.color(x, y) == 1 { - *o = m.get(x, y); + if m.color(x, yi) == 1 { + *o = m.get(x, yi); continue; } vals.clear(); for r in [1isize, 2] { for dy in -r..=r { for dx in -r..=r { - if (dx != 0 || dy != 0) && m.color(x + dx, y + dy) == 1 { - vals.push(m.get(x + dx, y + dy)); + if (dx != 0 || dy != 0) && m.color(x + dx, yi + dy) == 1 { + vals.push(m.get(x + dx, yi + dy)); } } } @@ -39,19 +130,10 @@ fn green(m: &Mosaic) -> Vec { } } if vals.is_empty() { - *o = m.get(x, y); + *o = m.get(x, yi); continue; } - let mut sorted = vals.clone(); - sorted.sort_by(|a, b| a.total_cmp(b)); - let med = sorted[sorted.len() / 2]; - let (mut s, mut ws) = (0.0f32, 0.0f32); - for &v in &vals { - let wt = 1.0 / (EPS + (v - med).abs()); - s += wt * v; - ws += wt; - } - *o = s / ws; + *o = robust_mean(&vals); } }); g @@ -59,31 +141,57 @@ fn green(m: &Mosaic) -> Vec { pub(crate) fn directional(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); - let g = green(m); + let off = Offsets::new(m); + let g = green(m, &off); let gg = |x: isize, y: isize| g[super::reflect(y, h) * w + super::reflect(x, w)]; let mut out = Rgb32f::new(w, h); par_rows(&mut out.data, w, |y, row| { - let y = y as isize; + let interior_y = y >= 2 && y + 2 < h; + let yi = y as isize; for (x, px) in row.iter_mut().enumerate() { + if interior_y && x >= 2 && x + 2 < w { + let i = y * w + x; + let own = m.cfa.color_at(x, y) as usize; + let gp = g[i]; + px[1] = gp; + let lists = &off.rb[off.phase(x, y)]; + for (li, c) in [0usize, 2].into_iter().enumerate() { + if c == own { + px[c] = m.data[i]; + continue; + } + let (mut s, mut ws) = (0.0f32, 0.0f32); + for &(dx, dy) in &lists[li] { + let j = (i as isize + dy * w as isize + dx) as usize; + let gq = g[j]; + let d2 = (dx * dx + dy * dy) as f32; + let wt = 1.0 / (d2 * (EPS + (gq - gp).abs())); + s += wt * (m.data[j] - gq); + ws += wt; + } + px[c] = if ws > 0.0 { gp + s / ws } else { gp }; + } + continue; + } let x = x as isize; - let own = m.color(x, y) as usize; - let gp = gg(x, y); + let own = m.color(x, yi) as usize; + let gp = gg(x, yi); px[1] = gp; for c in [0usize, 2] { if c == own { - px[c] = m.get(x, y); + px[c] = m.get(x, yi); continue; } let (mut s, mut ws) = (0.0f32, 0.0f32); for dy in -2isize..=2 { for dx in -2isize..=2 { - if m.color(x + dx, y + dy) as usize != c { + if m.color(x + dx, yi + dy) as usize != c { continue; } - let gq = gg(x + dx, y + dy); + let gq = gg(x + dx, yi + dy); let d2 = (dx * dx + dy * dy) as f32; let wt = 1.0 / (d2 * (EPS + (gq - gp).abs())); - s += wt * (m.get(x + dx, y + dy) - gq); + s += wt * (m.get(x + dx, yi + dy) - gq); ws += wt; } } @@ -93,3 +201,81 @@ pub(crate) fn directional(m: &Mosaic) -> Rgb32f { }); out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Cfa; + + /// The straightforward per-pixel formulation (reflection at every access): the precomputed + /// interior path must match it bit for bit. + fn reference(m: &Mosaic) -> Rgb32f { + let (w, h) = (m.w, m.h); + let mut g = vec![0f32; w * h]; + for y in 0..h as isize { + for x in 0..w as isize { + let o = &mut g[y as usize * w + x as usize]; + if m.color(x, y) == 1 { + *o = m.get(x, y); + continue; + } + let mut vals = Vec::new(); + for r in [1isize, 2] { + for dy in -r..=r { + for dx in -r..=r { + if (dx != 0 || dy != 0) && m.color(x + dx, y + dy) == 1 { + vals.push(m.get(x + dx, y + dy)); + } + } + } + if !vals.is_empty() { + break; + } + } + *o = if vals.is_empty() { m.get(x, y) } else { robust_mean(&vals) }; + } + } + let gg = |x: isize, y: isize| g[super::super::reflect(y, h) * w + super::super::reflect(x, w)]; + Rgb32f::from_fn(w, h, |x, y| { + let (x, y) = (x as isize, y as isize); + let own = m.color(x, y) as usize; + let gp = gg(x, y); + let mut px = [0f32, gp, 0.0]; + for c in [0usize, 2] { + if c == own { + px[c] = m.get(x, y); + continue; + } + let (mut s, mut ws) = (0.0f32, 0.0f32); + for dy in -2isize..=2 { + for dx in -2isize..=2 { + if m.color(x + dx, y + dy) as usize == c { + let gq = gg(x + dx, y + dy); + let wt = 1.0 / ((dx * dx + dy * dy) as f32 * (EPS + (gq - gp).abs())); + s += wt * (m.get(x + dx, y + dy) - gq); + ws += wt; + } + } + } + px[c] = if ws > 0.0 { gp + s / ws } else { gp }; + } + px + }) + } + + #[test] + fn fast_path_is_bit_exact() { + let cfa = Cfa::xtrans().shifted(1, 2); + let (w, h) = (37, 29); + let mut seed = 12345u32; + let data: Vec = (0..w * h) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) as f32 / (1u32 << 24) as f32 + }) + .collect(); + let m = Mosaic { w, h, data: &data, cfa: &cfa }; + let (a, b) = (directional(&m), reference(&m)); + assert!(a.data.iter().zip(&b.data).all(|(p, q)| p.map(f32::to_bits) == q.map(f32::to_bits))); + } +} diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index f25eb604..4ffb0666 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -14,7 +14,7 @@ pub(crate) fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { .into_iter() .filter(|i| i.u32(t::NEW_SUBFILE_TYPE).unwrap_or(0) == 0) .filter(|i| matches!(i.u16(t::PHOTOMETRIC), Some(photometric::CFA) | Some(photometric::LINEAR_RAW))) - .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0) * i.u64(t::IMAGE_LENGTH).unwrap_or(0)) + .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) } fn mat3(v: Option>) -> Option { diff --git a/crates/raw/src/highlight.rs b/crates/raw/src/highlight.rs index 388174ee..40d78751 100644 --- a/crates/raw/src/highlight.rs +++ b/crates/raw/src/highlight.rs @@ -7,6 +7,7 @@ //! normalised-convolution fill). Fully clipped pixels become neutral at the brightest plausible level. use lightcraft_raster::Rgb32f; +use rayon::prelude::*; /// Clip every channel of `wb ⊙ img` at `min_c(wb_c) · clip` — i.e. at the lowest channel's clip level after WB — /// then divide the multipliers back out. `clip` is the sensor clip in normalised units (≈ 1.0). @@ -28,25 +29,28 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let mut cv = vec![[0f32; 3]; cw * ch]; let mut cval = vec![false; cw * ch]; - for y in 0..ch { - for x in 0..cw { - let (mut s, mut n) = ([0f32; 3], 0); - for dy in 0..2 { - for dx in 0..2 { - let (sx, sy) = (2 * x + dx, 2 * y + dy); - if sx < w && sy < h && valid[sy * w + sx] { - for c in 0..3 { - s[c] += values[sy * w + sx][c]; + { + let (values, valid) = (&*values, &*valid); + cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| { + for x in 0..cw { + let (mut s, mut n) = ([0f32; 3], 0); + for dy in 0..2 { + for dx in 0..2 { + let (sx, sy) = (2 * x + dx, 2 * y + dy); + if sx < w && sy < h && valid[sy * w + sx] { + for c in 0..3 { + s[c] += values[sy * w + sx][c]; + } + n += 1; } - n += 1; } } + if n > 0 { + vrow[x] = s.map(|v| v / n as f32); + okrow[x] = true; + } } - if n > 0 { - cv[y * cw + x] = s.map(|v| v / n as f32); - cval[y * cw + x] = true; - } - } + }); } if cw * ch < w * h { fill_invalid(cw, ch, &mut cv, &mut cval); @@ -54,10 +58,9 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) // cannot shrink further (1×1): nothing valid anywhere handled above return; } - for y in 0..h { + values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| { for x in 0..w { - let i = y * w + x; - if !valid[i] { + if !okrow[x] { // bilinear upsample of the coarse level let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); @@ -71,11 +74,11 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; v[c] = top + (bot - top) * ty; } - values[i] = v; - valid[i] = true; + vrow[x] = v; + okrow[x] = true; } } - } + }); } /// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly @@ -84,52 +87,56 @@ fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { let (w, h) = (img.width, img.height); let n = w * h; - let clipped: Vec<[bool; 3]> = img.data.iter().map(|p| [p[0] >= clip, p[1] >= clip, p[2] >= clip]).collect(); - let count = clipped.iter().filter(|c| c.iter().any(|&b| b)).count(); + let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip; + let count = img.data.par_iter().filter(|p| is_clipped(p)).count(); if count == 0 { return 0; } // chromaticity (white-balanced ratios to the channel mean) of unclipped pixels let mut chroma = vec![[1f32 / 3.0; 3]; n]; let mut valid = vec![false; n]; - for i in 0..n { - if clipped[i].iter().any(|&b| b) { - continue; + chroma.par_iter_mut().zip(valid.par_iter_mut()).zip(img.data.par_iter()).for_each(|((r, v), p)| { + if is_clipped(p) { + return; } - let p = img.data[i]; let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; let s = q[0] + q[1] + q[2]; if s > 1e-4 { - chroma[i] = q.map(|v| v.max(0.0) / s); - valid[i] = true; + *r = q.map(|v| v.max(0.0) / s); + *v = true; } - } + }); fill_invalid(w, h, &mut chroma, &mut valid); let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip; - for i in 0..n { - let cl = clipped[i]; + img.data.par_iter_mut().zip(chroma.par_iter()).for_each(|(px, r)| { + let p = *px; + let cl = [p[0] >= clip, p[1] >= clip, p[2] >= clip]; if !cl.iter().any(|&b| b) { - continue; + return; } - let p = img.data[i]; let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let r = chroma[i]; // estimate the white-balanced channel sum from the unclipped channels - let ests: Vec = (0..3).filter(|&c| !cl[c] && r[c] > 1e-3).map(|c| q[c] / r[c]).collect(); + let (mut sum, mut k) = (0f32, 0usize); + for c in 0..3 { + if !cl[c] && r[c] > 1e-3 { + sum += q[c] / r[c]; + k += 1; + } + } let mut out = q; - if ests.is_empty() { + if k == 0 { let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); out = [v; 3]; } else { - let sum = ests.iter().sum::() / ests.len() as f32; + let sum = sum / k as f32; for c in 0..3 { if cl[c] { out[c] = q[c].max(sum * r[c]); } } } - img.data[i] = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; - } + *px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; + }); count } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 2803d4b5..453c7523 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -5,7 +5,8 @@ //! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG. //! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG //! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`]; -//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop. +//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`] +//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails). //! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance) //! and produces camera → linear Rec.2020 D65 matrices. //! @@ -18,6 +19,7 @@ //! colour matrix: [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. #![forbid(unsafe_code)] +mod binned; pub mod color; pub mod demosaic; mod dng; diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index eb7c8e68..89020038 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -91,7 +91,7 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() .filter(|i| i.u16(t::PHOTOMETRIC) == Some(photometric::CFA) || i.contains(TONE_CURVE)) - .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0) * i.u64(t::IMAGE_LENGTH).unwrap_or(0)) + .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) } pub(crate) fn decode(bytes: &[u8]) -> Result { diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index bcd8dd1a..11700bd1 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -22,7 +22,7 @@ fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() .filter(|i| i.u16(t::PHOTOMETRIC) == Some(photometric::CFA)) - .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0) * i.u64(t::IMAGE_LENGTH).unwrap_or(0)) + .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) } /// Width without the optically masked columns some bodies append on the right: trailing columns (at most 64) diff --git a/crates/raw/tests/robust.proptest-regressions b/crates/raw/tests/robust.proptest-regressions new file mode 100644 index 00000000..21260e7d --- /dev/null +++ b/crates/raw/tests/robust.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 96fbbf63f3e7e0d186dc8aee69f5452640ec49e763149965b40707abbe37065c # shrinks to flips = [(16380191992317618766, 16), (5757657205609716137, 1)], cut = 11088808457807325322, lj = true From 5fd5384bfbde00d72eb587d5581db67a8ef470c4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:18:50 -0400 Subject: [PATCH 2/3] =?UTF-8?q?M5.4a:=20instant=20first=20view=20=E2=80=94?= =?UTF-8?q?=20cached=20view=20render=20/=20embedded=20raw=20preview,=20the?= =?UTF-8?q?n=20the=20full=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine: QuickJob — what to show the moment a photo opens: the loupe's last render for the current settings (kept by Session::loupe_job under a size-independent "view" key in the preview cache, memory + disk with a library; the disk JPEG is written off the render thread via PreviewCache::put_deferred), else the embedded camera JPEG of a raw that still has its import look (import::has_import_look), else a thumbnail-level render. - Grid / filmstrip: an unedited raw without a thumbnail shows its cached thumbnail, else its embedded preview (Slot::ThumbQuick) at once; the real rendered thumbnail follows at background priority (same cache keys, so it still ends up matching the develop render; edited photos render directly). - Loupe (detail, compare, filmstrip selection): Slot::Preview holds the stand-in until Slot::Main has the photo; the full render then replaces it in place (no blank frame). - files::embedded_preview_srgb + PreviewLoader hook (desktop/CLI/MCP and web). - ui.inspect → loupe {photo, source: render|cached|embedded|small|thumb|none, pending}. Real app (24 MP raws, loaded machine): first image 15–90 ms (thumbnail, then embedded preview), full render 0.2–0.5 s (was 1.0–2.4 s with nothing shown before it). Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/bench.rs | 2 +- apps/lightcraft-web/src/store.rs | 3 + crates/engine/src/files.rs | 57 ++++++++++- crates/engine/src/import.rs | 27 +++-- crates/engine/src/media.rs | 148 +++++++++++++++++++++++++++- crates/preview/src/lib.rs | 22 +++++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panels/detail.rs | 59 +++++++---- crates/ui-egui/src/panels/grid.rs | 26 ++++- crates/ui-egui/src/render.rs | 74 ++++++++++++-- 11 files changed, 380 insertions(+), 43 deletions(-) diff --git a/apps/lightcraft-web/src/bench.rs b/apps/lightcraft-web/src/bench.rs index 7ede4a7a..efbb71e9 100644 --- a/apps/lightcraft-web/src/bench.rs +++ b/apps/lightcraft-web/src/bench.rs @@ -68,7 +68,7 @@ impl Bench { let idle = app.renderer.queued() == 0; match self.phase { Phase::Idle => { - let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count(); + let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count(); if idle && thumbs > 0 && self.frames > 3 { self.thumbs_done_ms = Some(now - self.start_ms); let first = app.session.visible().first().copied(); diff --git a/apps/lightcraft-web/src/store.rs b/apps/lightcraft-web/src/store.rs index acac328e..01b08bc5 100644 --- a/apps/lightcraft-web/src/store.rs +++ b/apps/lightcraft-web/src/store.rs @@ -74,6 +74,9 @@ impl MemStore { let (l, p) = self.hooks(); session.media.file_loader = Some(l); session.media.file_probe = Some(p); + let s = self.clone(); + session.media.preview_loader = + Some(Arc::new(move |path: &str, max_edge: usize| lightcraft_engine::files::embedded_preview_srgb(&s.get(path)?, max_edge))); } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index cd1cc4b5..6f0a5f7c 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use crate::media::{FileLoader, FileProbe, ProbeInfo}; +use crate::media::{FileLoader, FileProbe, PreviewLoader, ProbeInfo}; fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); @@ -287,6 +287,24 @@ pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, S Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default())) } +/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the +/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). +pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option { + let jpeg = lightcraft_raw::embedded_preview(bytes)?; + let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?; + if d.image.width.max(d.image.height) > max_edge { + d.image = fit(&d.image, max_edge, max_edge, Filter::Box); + d.alpha = None; + } + let o = preview_orientation(bytes, d.orientation); + Some(d.to_srgb8().oriented(o)) +} + +/// Filesystem-backed embedded-preview hook (native). +pub fn fs_preview_loader() -> PreviewLoader { + Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)) +} + /// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead. pub fn fs_hooks() -> (FileLoader, FileProbe) { let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| { @@ -306,6 +324,7 @@ impl crate::Session { let (l, p) = fs_hooks(); self.media.file_loader = Some(l); self.media.file_probe = Some(p); + self.media.preview_loader = Some(fs_preview_loader()); self } } @@ -341,4 +360,40 @@ mod tests { // no preview at all: a clear error, not a panic assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err()); } + + #[test] + fn quick_jobs_show_embedded_previews_then_cached_renders() { + use crate::media::QuickSource; + let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("a.cr3"); + std::fs::write(&path, cr3_with_preview(96, 64)).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + // loupe: an unedited raw opens on its embedded preview… + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Embedded)); + let img = q.rendered.unwrap().image; + assert_eq!((img.width, img.height), (96, 64)); + // …and once the loupe has rendered it, on that render (any size) + let full = s.loupe_job(id, 48, 32, true).unwrap().run(); + assert!(full.rendered.is_ok() && full.quick.is_none()); + let q = s.quick_view_job(id, 1600, true).unwrap().run(); + assert_eq!(q.quick, Some(QuickSource::Cached)); + assert_eq!(q.rendered.unwrap().image.width, 48); + // grid: embedded first, then the real thumbnail (final, with the thumbnail job's key) + let job = s.thumb_job(id, 128).unwrap(); + let q = s.quick_thumb_job(&job).unwrap().run(); + assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key)); + drop(job.clone().run()); + assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached)); + // an edited raw: no embedded stand-in (it wouldn't show the edit) + s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap(); + let job = s.thumb_job(id, 128).unwrap(); + assert!(s.quick_thumb_job(&job).is_none()); + assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 3aac0203..abbadfd7 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -57,6 +57,25 @@ pub struct ImportReport { pub sidecars: usize, } +/// The develop settings a photo gets on import: raws start from their as-shot white balance with +/// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the +/// camera intended). +pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings { + let mut d = match p.as_shot_wb { + Some((t, tint)) => lightcraft_develop::DevelopSettings::for_raw(t, tint), + None => lightcraft_develop::DevelopSettings::default(), + }; + if p.embedded_lens.is_some() { + d.optics.lens_profile = true; + } + d +} + +/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)? +pub fn has_import_look(p: &Photo) -> bool { + *p.develop == import_defaults(p) +} + pub fn is_supported(path: &Path) -> bool { path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) } @@ -220,14 +239,8 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res p.meta = info.meta; p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; - if let Some((t, tint)) = info.as_shot_wb { - p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint)); - } p.embedded_lens = info.embedded_lens; - if p.embedded_lens.is_some() { - // built-in (file-embedded) lens corrections are on by default, like the camera intended - std::sync::Arc::make_mut(&mut p.develop).optics.lens_profile = true; - } + p.develop = std::sync::Arc::new(import_defaults(&p)); let raw = p.kind == lightcraft_catalog::MediaKind::Raw; let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) .and_then(|f| std::fs::read_to_string(f).ok()) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index f7643de1..e506c384 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -10,6 +10,11 @@ //! disk cache in the library's `thumbs/` folder), keyed by the photo's content hash, the develop //! settings hash, the size and [`RENDER_CACHE_VERSION`] — so reopening a library shows its grid //! without decoding a single original, and an edit simply produces a new key. +//! +//! Opening a photo shows something at once ([`QuickJob`]): the loupe's last render for the current +//! settings (kept in the same cache under a size-independent "view" key), else the camera's +//! embedded JPEG for an unedited raw, else a thumbnail-level render — while the real render is +//! prepared. Grid thumbnails of unedited raws likewise start from the embedded preview. use std::sync::Arc; @@ -17,7 +22,7 @@ use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; -use lightcraft_raster::{Histogram, Rgb32f}; +use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. @@ -62,6 +67,10 @@ impl SourceLevel { /// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; +/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than +/// `max_edge` (set by the app). `None`: no usable preview. +pub type PreviewLoader = Arc Option + Send + Sync>; + #[derive(Clone)] pub enum SourceRef { Loaded(Arc), @@ -92,6 +101,7 @@ pub struct MediaCache { pub preview_capacity: usize, pub file_loader: Option, pub file_probe: Option, + pub preview_loader: Option, scenes: Vec, /// Rendered thumbnails (memory, plus disk once a library is attached). pub rendered: Arc, @@ -106,6 +116,7 @@ impl Default for MediaCache { preview_capacity: 0, file_loader: None, file_probe: None, + preview_loader: None, scenes: Vec::new(), rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)), } @@ -198,6 +209,8 @@ pub struct RenderJob { /// Intermediate results of this view's previous renders (set by the frontend for the loupe): /// slider drags then only redo the stages the changed setting feeds. pub stages: Option>, + /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). + pub view_cache: Option<(Arc, Hash128)>, } pub struct RenderResult { @@ -207,6 +220,8 @@ pub struct RenderResult { pub rendered: Result, /// A source that was loaded by this job (to be inserted into the cache). pub loaded: Option>, + /// Set for a [`QuickJob`]'s stand-in: where the image came from. + pub quick: Option, } impl RenderJob { @@ -231,7 +246,14 @@ impl RenderJob { { let image = Arc::unwrap_or_clone(img); let histogram = Histogram::of_srgb8(&image); - return RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram }), loaded: None }; + return RenderResult { + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(Rendered { image, histogram }), + loaded: None, + quick: None, + }; } let was_loaded = matches!(self.source, SourceRef::Loaded(_)); match self.source.load() { @@ -243,9 +265,21 @@ impl RenderJob { if let Some((cache, key)) = &self.cache { cache.put(*key, Arc::new(rendered.image.clone())); } - RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(rendered), loaded: (!was_loaded).then_some(src) } + if let Some((cache, key)) = &self.view_cache + && self.request.quality == Quality::Full + { + cache.put_deferred(*key, Arc::new(rendered.image.clone())); + } + RenderResult { + photo: self.photo, + level: self.level, + key: self.key, + rendered: Ok(rendered), + loaded: (!was_loaded).then_some(src), + quick: None, + } } - Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None }, + Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None, quick: None }, } } } @@ -263,6 +297,58 @@ pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: & } } +/// Where a [`QuickJob`]'s stand-in image came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum QuickSource { + /// The photo's own develop render for its current settings (cached view or thumbnail render). + Cached, + /// The camera's embedded JPEG (unedited raws). + Embedded, + /// A thumbnail-level develop render made for the occasion. + Small, +} + +/// Something to show *now* for a photo while its real render is prepared. Tried in order: cached +/// renders, the embedded preview of an unedited raw, a thumbnail-level render. +#[derive(Clone)] +pub struct QuickJob { + pub photo: PhotoId, + pub key: u64, + /// Cached renders to try first, in order: (cache, key). + pub cached: Vec<(Arc, Hash128)>, + /// Embedded preview of an unedited raw: (path, loader, max edge). + pub embedded: Option<(String, PreviewLoader, usize)>, + /// Last resort: run this thumbnail job. + pub small: Option>, +} + +impl QuickJob { + pub fn run(self) -> RenderResult { + let (photo, key) = (self.photo, self.key); + let done = |rendered: Result, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick }; + let rendered = |image: Rgba8| { + let histogram = Histogram::of_srgb8(&image); + Ok(Rendered { image, histogram }) + }; + for (cache, k) in &self.cached { + if let Some(img) = cache.get(*k) { + return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached)); + } + } + if let Some((path, loader, edge)) = &self.embedded + && let Some(image) = loader(path, *edge) + { + return done(rendered(image), Some(QuickSource::Embedded)); + } + if let Some(job) = self.small { + let r = job.run(); + return RenderResult { key, quick: Some(QuickSource::Small), ..r }; + } + done(Err("no quick preview".into()), None) + } +} + /// What identifies a photo's pixels for caching: its content hash, else its source. pub fn content_key(p: &Photo) -> String { match (&p.content_hash, &p.source) { @@ -316,7 +402,59 @@ impl crate::Session { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); (self.media.rendered.clone(), k) }); - Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache, stages: None }) + Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache, stages: None, view_cache: None }) + } + + /// Size-independent cache key of a photo's view render (loupe) for its current settings. + fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { + Hasher128::new().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish() + } + + /// The loupe's render job: like [`Self::render_job`], and a full-quality result is kept as the + /// photo's view preview (memory, and disk with a library) for [`Self::quick_view_job`]. + pub fn loupe_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, apply_crop: bool) -> Option { + let mut job = self.render_job(id, max_w, max_h, false, apply_crop)?; + let p = self.catalog.photo(id)?; + job.view_cache = Some((self.media.rendered.clone(), Self::view_key(p, apply_crop))); + Some(job) + } + + /// The embedded preview of an unedited raw (path, loader), when the app installed a loader. + fn embedded_of(&self, p: &Photo) -> Option<(String, PreviewLoader)> { + match (&p.source, &self.media.preview_loader) { + (Source::File { path }, Some(l)) if p.kind == MediaKind::Raw && crate::import::has_import_look(p) => Some((path.clone(), l.clone())), + _ => None, + } + } + + /// Something to show in the loupe right away for `id` (see [`QuickJob`]): its cached view + /// render, else the embedded preview of an unedited raw, else a cached or fresh thumbnail. + pub fn quick_view_job(&mut self, id: PhotoId, max_edge: usize, apply_crop: bool) -> Option { + let p = self.catalog.photo(id)?.clone(); + let small = self.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])?; + // (the thumbnail job itself starts with the cached thumbnail, after the sharper embedded preview) + let cached = vec![(self.media.rendered.clone(), Self::view_key(&p, apply_crop))]; + let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish(); + let key = h.0 as u64; + let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge()))); + Some(QuickJob { photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) + } + + /// A first grid thumbnail for an unedited raw: the cached render for `job` (the real + /// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries + /// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for + /// other photos (render them directly). + pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option { + let p = self.catalog.photo(job.photo)?.clone(); + let (path, l) = self.embedded_of(&p)?; + let edge = job.request.max_w.max(job.request.max_h); + Some(QuickJob { + photo: job.photo, + key: job.key, + cached: job.cache.clone().into_iter().collect(), + embedded: Some((path, l, edge)), + small: None, + }) } /// Accept a finished job's loaded source into the cache. diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 478c784d..a8afb955 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -63,6 +63,28 @@ impl PreviewCache { self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img, c); } + /// Like [`Self::put`], but the disk write (a JPEG encode) happens on a background thread, so + /// the caller (a render about to hand its result to the screen) isn't held up. + pub fn put_deferred(self: &Arc, key: Hash128, img: Arc) { + let c = cost(&img); + self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), c); + if self.disk.is_none() { + return; + } + let me = self.clone(); + let write = move || { + if let Some(d) = &me.disk { + d.put(key, &img); + } + }; + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new().name("lc-preview-write".into()).spawn(write).is_err() { + log::warn!("preview cache: could not start a writer thread"); + } + #[cfg(target_arch = "wasm32")] + write(); + } + /// Drop everything (memory and disk). pub fn clear(&self) { self.mem.lock().unwrap_or_else(|e| e.into_inner()).clear(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8d6cbfae..5ee69e57 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -78,6 +78,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()}, + "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), "status": app.ui.status, }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05013ce1..e7718c16 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,9 @@ pub struct LightcraftApp { pub widgets: Vec<(String, egui::Rect)>, /// In-progress on-canvas gesture (brush stroke points, gradient drag…). pub gesture: Option, + /// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small", + /// "thumb", "none"). + pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>, } impl LightcraftApp { @@ -105,6 +108,7 @@ impl LightcraftApp { image_rect: None, widgets: vec![], gesture: None, + loupe_shown: None, } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 32cc8ea1..f3a819ca 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -142,10 +142,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let scale = if interacting { 0.6 } else { 1.0 }; let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; - if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, !crop_tool) { + if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; app.renderer.request(Slot::Main, job, 100); } + // until the loupe has this photo: show its cached render / embedded preview / a thumbnail + if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) + && let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool) + { + app.renderer.request_quick(Slot::Preview, q, 110); + } let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash)); if (split || show_before || app.ui.before_after == BeforeAfter::Split) && let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool) @@ -153,29 +159,39 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.renderer.request(Slot::Before, job, 90); } let p = ui.painter_at(canvas); - let draw = |slot: Slot, r: Rect| { - if let Some(tex) = app.renderer.textures.get(&slot).filter(|t| t.photo == id) { - p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - true - } else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) { - p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - true + // what a view slot shows: its own render of this photo, else the stand-ins (no blank frame + // between photos, and the full render replaces them in place) + let draw = |slot: Slot, r: Rect| -> &'static str { + let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); + let (tex, what) = if let Some(t) = mine(slot) { + (t, "render") + } else if let Some(t) = mine(Slot::Preview) { + (t, t.quick.map(quick_name).unwrap_or("preview")) + } else if let Some(t) = app.renderer.thumb(id) { + (t, "thumb") } else { - false - } + return "none"; + }; + p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + what }; + let shown; if split { let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); draw(Slot::Before, br); - draw(Slot::Main, img_rect); + shown = draw(Slot::Main, img_rect); p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim); p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim); } else if show_before { - draw(Slot::Before, img_rect); + shown = draw(Slot::Before, img_rect); p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text); - } else if !draw(Slot::Main, img_rect) { - p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim); + } else { + shown = draw(Slot::Main, img_rect); + if shown == "none" { + p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim); + } } + app.loupe_shown = Some((id, shown)); if app.ui.before_after == BeforeAfter::Split { let mid = img_rect.center().x; if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { @@ -199,6 +215,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); } +fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { + use lightcraft_engine::media::QuickSource; + match q { + QuickSource::Cached => "cached", + QuickSource::Embedded => "embedded", + QuickSource::Small => "small", + } +} + fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) { // Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint. if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) { @@ -691,10 +716,8 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim); } let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); - if let Some(job) = app.session.thumb_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2) { - app.renderer.request(Slot::Thumb(*id), job, 8); - } - if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(*id)) { + super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8); + if let Some(tex) = app.renderer.thumb(*id) { let [tw, th] = tex.size; let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32); let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s)); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 6312c0cb..434248da 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -128,6 +128,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE); } +/// Request a grid/filmstrip thumbnail at `priority`. An unedited raw without a thumbnail texture +/// first gets a stand-in (its cached thumbnail, else its embedded camera preview), and its real +/// render then follows in the background. +pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) { + let Some(job) = app.session.thumb_job(id, size) else { return }; + let quick = if app.renderer.textures.contains_key(&Slot::Thumb(id)) { None } else { app.session.quick_thumb_job(&job) }; + match quick { + Some(q) => { + app.renderer.request_quick(Slot::ThumbQuick(id), q, priority + 1); + if !app.renderer.is_pending(Slot::ThumbQuick(id)) { + app.renderer.request(Slot::Thumb(id), job, BACKGROUND_THUMB_PRIORITY); + } + } + None => app.renderer.request(Slot::Thumb(id), job, priority), + } +} + +/// Rendered thumbnails replacing embedded previews: after everything on screen. +pub const BACKGROUND_THUMB_PRIORITY: u32 = 3; + fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square: bool, onscreen: bool, ppp: f32) { let t = Tokens::get(ui.ctx()); let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; @@ -144,10 +164,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square }; // thumbnail let size = thumb_px(img_rect.width().max(img_rect.height()), ppp); - if let Some(job) = app.session.thumb_job(id, size) { - app.renderer.request(Slot::Thumb(id), job, if onscreen { 10 } else { 5 }); - } - if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) { + request_thumb(app, id, size, if onscreen { 10 } else { 5 }); + if let Some(tex) = app.renderer.thumb(id) { let [tw, th] = tex.size; let fit = if square { let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 46c57afb..2bb94653 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -5,12 +5,18 @@ //! prioritised (loupe first, then on-screen thumbnails, then prefetch); thumbnails scrolled far //! out of view are dropped from the queue. Thumbnail jobs hit the engine's preview cache (memory + //! disk) before rendering. On wasm the jobs run inline, one per frame. +//! +//! Stand-ins ([`QuickJob`]s, once per photo and settings): [`Slot::Preview`] holds what the loupe +//! shows until [`Slot::Main`] has the photo's render (cached view render, embedded camera JPEG or +//! a thumbnail render); [`Slot::ThumbQuick`] holds a raw's embedded preview in the grid until its +//! rendered thumbnail arrives (a cached thumbnail found by the quick job becomes the +//! [`Slot::Thumb`] texture directly). use std::collections::HashMap; use lightcraft_catalog::PhotoId; use lightcraft_engine::Session; -use lightcraft_engine::media::{RenderJob, RenderResult}; +use lightcraft_engine::media::{QuickJob, QuickSource, RenderJob, RenderResult}; use lightcraft_engine::pipeline::StageCache; use lightcraft_preview::JobPool; use lightcraft_raster::Histogram; @@ -19,7 +25,11 @@ use std::sync::Arc; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Slot { Thumb(PhotoId), + /// A thumbnail's stand-in (embedded preview of an unedited raw). + ThumbQuick(PhotoId), Main, + /// The loupe's stand-in until `Main` has the photo. + Preview, Before, Compare(u8), } @@ -31,6 +41,8 @@ pub struct Tex { pub size: [usize; 2], pub histogram: Option, pub ms: f64, + /// Set for stand-ins: where the image came from. + pub quick: Option, /// CPU copy of the pixels (only with [`Renderer::keep_pixels`]; for headless screenshots). pub pixels: Option>, } @@ -48,6 +60,8 @@ pub struct Renderer { pub keep_pixels: bool, /// Per-view intermediate results (loupe and "before"), so slider drags redo only what changed. stages: HashMap>, + /// Slot → key of the last quick job requested for it (each is tried once). + quick_tried: HashMap, } impl Default for Renderer { @@ -61,6 +75,7 @@ impl Default for Renderer { completed: 0, keep_pixels: false, stages: HashMap::new(), + quick_tried: HashMap::new(), } } } @@ -88,6 +103,27 @@ impl Renderer { self.pool.submit(slot, key, priority, Box::new(move || job.run())); } + /// Request a stand-in for `slot` (once per job key). + pub fn request_quick(&mut self, slot: Slot, job: QuickJob, priority: u32) { + if self.quick_tried.get(&slot) == Some(&job.key) { + return; + } + self.quick_tried.insert(slot, job.key); + self.pending.insert(slot, (job.key, priority)); + let key = job.key; + self.pool.submit(slot, key, priority, Box::new(move || job.run())); + } + + /// Is a request for `slot` queued or running? + pub fn is_pending(&self, slot: Slot) -> bool { + self.pending.contains_key(&slot) + } + + /// The texture to show for a grid/filmstrip thumbnail: the rendered one, else its stand-in. + pub fn thumb(&self, id: PhotoId) -> Option<&Tex> { + self.textures.get(&Slot::Thumb(id)).or_else(|| self.textures.get(&Slot::ThumbQuick(id))) + } + pub fn queued(&self) -> usize { self.pool.queued() } @@ -99,7 +135,7 @@ impl Renderer { /// Thumbnail textures currently loaded. pub fn thumb_textures(&self) -> usize { - self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count() + self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_) | Slot::ThumbQuick(_))).count() } /// Collect finished jobs into textures. Returns true if anything changed. @@ -115,7 +151,7 @@ impl Renderer { let inline_ms = 0.0; let mut changed = false; while let Some(done) = self.pool.try_recv() { - let (slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms }); + let (mut slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms }); session.accept(&r); self.completed += 1; if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { @@ -124,6 +160,18 @@ impl Renderer { let Ok(rendered) = r.rendered else { continue; }; + if let Slot::ThumbQuick(id) = slot { + if self.textures.contains_key(&Slot::Thumb(id)) { + continue; // the real thumbnail won the race + } + if r.quick == Some(QuickSource::Cached) { + // the photo's own cached thumbnail: final + slot = Slot::Thumb(id); + } + } + if let Slot::Thumb(id) = slot { + self.textures.remove(&Slot::ThumbQuick(id)); + } let img = &rendered.image; let color = std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); let pixels = self.keep_pixels.then(|| color.clone()); @@ -137,12 +185,22 @@ impl Renderer { t.size = [img.width, img.height]; t.histogram = Some(rendered.histogram); t.ms = ms; + t.quick = r.quick; } None => { let tex = ctx.load_texture(name, color, egui::TextureOptions::LINEAR); self.textures.insert( slot, - Tex { key: r.key, photo: r.photo, tex, size: [img.width, img.height], histogram: Some(rendered.histogram), ms, pixels }, + Tex { + key: r.key, + photo: r.photo, + tex, + size: [img.width, img.height], + histogram: Some(rendered.histogram), + ms, + quick: r.quick, + pixels, + }, ); } } @@ -166,16 +224,18 @@ impl Renderer { /// thumbnail jobs for photos that scrolled out of `keep`. pub fn evict_thumbs(&mut self, keep: &std::collections::HashSet, max: usize) { let dropped = self.pool.reprioritize(|s, p| match s { - Slot::Thumb(id) if !keep.contains(id) && p <= 10 => None, + Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id) && p <= 11 => None, _ => Some(p), }); for s in dropped { self.pending.remove(&s); + self.quick_tried.remove(&s); } - let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count(); + let thumbs = self.thumb_textures(); if thumbs <= max { return; } - self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) if !keep.contains(id))); + self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) | Slot::ThumbQuick(id) if !keep.contains(id))); + self.quick_tried.retain(|s, _| !matches!(s, Slot::ThumbQuick(id) if !keep.contains(id))); } } From 570d03e2023fb9ca317afe751d2e3ca7ea8fe5d9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:23:36 -0400 Subject: [PATCH 3/3] M5.4c: prefetch filmstrip neighbours; GPU device created at startup off the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Once the current photo's loupe render is done, the next and previous photos in filmstrip order get a background loupe job (Slot::Prefetch, priority below on-screen thumbnails, once per settings): the decoded 2560 px source is kept in the engine's bounded preview LRU and the view render is cached, so stepping to a neighbour shows its developed image in ~50 ms and then re-renders only on the GPU. - lightcraft_gpu::warm_up (called when the app is created) builds the device and kernels on a background thread; gpu::ready() answers without blocking and ui.inspect uses it, so the UI thread never waits for device creation. - docs/gpu-pipeline.md: opening-a-photo section. Stepping through 24 MP raws in the loupe (real app): full render 220–580 ms → 45–62 ms. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/lib.rs | 27 ++++++++++++++++++++++++++- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/detail.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/render.rs | 21 +++++++++++++++++++++ docs/gpu-pipeline.md | 14 +++++++++++++- 6 files changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index bdfd5b3e..ee88430b 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -55,15 +55,40 @@ fn env_disabled() -> bool { *OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"))) } +#[cfg(not(target_arch = "wasm32"))] +static GPU: std::sync::OnceLock> = std::sync::OnceLock::new(); + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn device() -> Option<&'static ctx::Gpu> { - static GPU: std::sync::OnceLock> = std::sync::OnceLock::new(); if env_disabled() { return None; } GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref() } +/// Create the device and compile the kernels on a background thread now (app start), so the +/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. +pub fn warm_up() { + #[cfg(not(target_arch = "wasm32"))] + if !env_disabled() && GPU.get().is_none() { + let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { + let _ = device(); + }); + } +} + +/// Has device creation finished (successfully or not)? Never blocks — for status displays. +pub fn ready() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + env_disabled() || GPU.get().is_some() + } + #[cfg(target_arch = "wasm32")] + { + true + } +} + /// Whether a usable GPU adapter exists and GPU rendering is enabled (creates the device on first /// use). pub fn available() -> bool { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5ee69e57..c9220302 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()}, + "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), "status": app.ui.status, }) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7718c16..757301ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,8 @@ pub struct LightcraftApp { impl LightcraftApp { pub fn new(session: Session, services: Services) -> Self { + // GPU device + kernels off the UI thread, before the first photo is opened + lightcraft_engine::gpu::warm_up(); Self { session, ui: UiState::default(), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index f3a819ca..5abcca6a 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -146,6 +146,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = if interacting { job.draft() } else { job }; app.renderer.request(Slot::Main, job, 100); } + // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and + // kept, view render cached) so stepping to them is instant + if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { + let ids = app.session.visible_cloned(); + if let Some(i) = ids.iter().position(|p| *p == id) { + let next = ids.get(i + 1).copied(); + let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); + for (n, nid) in [next, prev].into_iter().enumerate() { + let Some(nid) = nid else { continue }; + let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; + let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); + let na = nf.aspect() as f32; + let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); + let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize; + let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; + if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { + app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); + } + } + } + } // until the loupe has this photo: show its cached render / embedded preview / a thumbnail if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) && let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool) @@ -215,6 +236,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); } +/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. +const PREFETCH_PRIORITY: u32 = 4; + fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { use lightcraft_engine::media::QuickSource; match q { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 2bb94653..e7f052f5 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -32,6 +32,9 @@ pub enum Slot { Preview, Before, Compare(u8), + /// Background preparation of a neighbouring photo (no texture: its decoded source and view + /// render are cached by the engine). 0 = next, 1 = previous. + Prefetch(u8), } pub struct Tex { @@ -62,6 +65,8 @@ pub struct Renderer { stages: HashMap>, /// Slot → key of the last quick job requested for it (each is tried once). quick_tried: HashMap, + /// Prefetch slot → key of the last job submitted (each runs once). + prefetched: HashMap, } impl Default for Renderer { @@ -76,6 +81,7 @@ impl Default for Renderer { keep_pixels: false, stages: HashMap::new(), quick_tried: HashMap::new(), + prefetched: HashMap::new(), } } } @@ -114,6 +120,18 @@ impl Renderer { self.pool.submit(slot, key, priority, Box::new(move || job.run())); } + /// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one + /// replaces it while queued). Memory stays bounded by the engine's source caches. + pub fn prefetch(&mut self, slot: Slot, job: RenderJob, priority: u32) { + if self.prefetched.get(&slot) == Some(&job.key) { + return; + } + self.prefetched.insert(slot, job.key); + self.pending.insert(slot, (job.key, priority)); + let key = job.key; + self.pool.submit(slot, key, priority, Box::new(move || job.run())); + } + /// Is a request for `slot` queued or running? pub fn is_pending(&self, slot: Slot) -> bool { self.pending.contains_key(&slot) @@ -160,6 +178,9 @@ impl Renderer { let Ok(rendered) = r.rendered else { continue; }; + if matches!(slot, Slot::Prefetch(_)) { + continue; + } if let Slot::ThumbQuick(id) = slot { if self.textures.contains_key(&Slot::Thumb(id)) { continue; // the real thumbnail won the race diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 3dc9299d..88f19924 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -74,7 +74,19 @@ so CPU numbers are pessimistic; the GPU numbers are less affected): | export render 6000×4000 | 1037–1414 ms | 291–330 ms | GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel -compilation: ~0.4 s once per process (lazily, on the first GPU render). +compilation: ~0.4 s once per process — the desktop app starts it on a background thread at launch +(`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create +the device on their first GPU render. `lightcraft_gpu::ready()` asks without blocking. + +## Opening a photo (M5.4) +- The loupe shows a stand-in at once (`media::QuickJob`): the photo's cached view render for its + current settings, else (raws with their import look) the embedded camera JPEG, else a + thumbnail-level render; the full render replaces it in place. +- Raw sources for previews (2560 px) and thumbnails are binned straight from the mosaic + (`RawImage::develop_binned`, 2× for a 24 MP preview); only exports / 1:1 demosaic at full size. +- The next and previous photos in filmstrip order are prepared in the background once the current + one is rendered (decoded source kept, view render cached): stepping through photos shows the + developed image in ~50 ms. ## Open items - Keep the loupe texture on the GPU (render into an `egui-wgpu` texture on eframe's device instead of