diff --git a/AGENTS.md b/AGENTS.md index 24e9f1444..97e761616 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). 2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Behaviour/visual reference: `plan/lightroom/` (incl. `10-observed-ui.md` + screenshots). -3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. +3. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (Lightroom parity tracker: one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary. +4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's. `plan/` is gitignored (local-only). @@ -59,6 +60,8 @@ and often so a crash loses minutes, not hours. `apps/lightcraft-cli/tests/`). New features need at least one test that would fail without them. - **Run the app after every user-visible change**: launch with `--control 7980`, drive it with a JSON-lines script (`docs/showcase/run.py file.jsonl`), take `ui.screenshot`, and look at it. Check `ui.inspect` → `perf`. +- **Benchmarks:** `cargo xtask bench` (24 MP raw from corpus; CPU and GPU columns) appends to `target/bench/history.jsonl` + and flags CPU-time regressions > 20 % vs the previous run (`--strict` to fail). Run it before and after perf work. - **Measure, don't guess**: `LIGHTCRAFT_PROFILE=1` prints per-stage pipeline timings to stderr; time CLI renders with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. @@ -67,4 +70,4 @@ and often so a crash loses minutes, not hours. ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `wasm`, `corpus`, `stats`). +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). diff --git a/CLAUDE.md b/CLAUDE.md index 40f492c7e..f46b6a188 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,10 @@ and often so a crash loses minutes, not hours. ## Running and looking at the app - `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`). - For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/lightroom/screenshots/`. +- **Unattended (display asleep/locked, CI, nightly runs): prefer headless snapshots** — no window needed: + `lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000` (control-protocol requests, + one per line; see `docs/control-protocol.md` → Headless rendering). In a running app use + `ui.screenshot {"path": …, "headless": true}`; windowed screenshots fall back to headless after 2 s. - MCP: `lightcraft-cli mcp` (headless, `--demo` for the procedural library) or `lightcraft-cli mcp --connect` (drives the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`. - Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in @@ -55,6 +59,8 @@ and often so a crash loses minutes, not hours. `apps/lightcraft-cli/tests/`). New features need at least one test that would fail without them. - **Run the app after every user-visible change**: launch with `--control 7980`, drive it with a JSON-lines script (`docs/showcase/run.py file.jsonl`), take `ui.screenshot`, and look at it. Check `ui.inspect` → `perf`. +- **Benchmarks:** `cargo xtask bench` (24 MP raw from corpus; CPU and GPU columns) appends to `target/bench/history.jsonl` + and flags CPU-time regressions > 20 % vs the previous run (`--strict` to fail). Run it before and after perf work. - **Measure, don't guess**: `LIGHTCRAFT_PROFILE=1` prints per-stage pipeline timings to stderr; time CLI renders with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. diff --git a/Cargo.lock b/Cargo.lock index f22c48fa9..45d8dde24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,6 +2124,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "log", + "rayon", "serde", "serde_json", "thiserror 2.0.21", diff --git a/README.md b/README.md index c9ca6adbd..19493f0b6 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,9 @@ lightcraft --control 7980 ~/Pictures/trip checked against the CPU pipeline to within 1/255. On a 24 MP raw (Apple M4 Pro): a slider update re-renders in ~4 ms, a cold 2.5 MP loupe in ~30 ms, and a full-size export in ~0.3 s including a parallel JPEG encode. Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. +- **Instant culling.** Opening a raw shows its embedded camera preview or cached render within ~0.1 s while the + full render follows (~0.2–0.5 s for 24 MP); the next and previous photos are prepared in the background, so stepping + through a shoot takes ~50 ms per photo. - **Background rendering.** A worker pool renders the loupe, before/after and every visible thumbnail off the UI thread; drafts during drags, full quality on release. - **Local-first.** No account, no cloud, no telemetry, no subscription. Your catalog is an append-only log of @@ -215,7 +218,9 @@ lightcraft --control 7980 ~/Pictures/trip ## Feature status -LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estimates. +LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estimates. The detailed, row-by-row +Lightroom parity tracker — every feature, menu item and shortcut with its status, evidence and the top gaps — is +[docs/parity.md](docs/parity.md). | Area | Status | |---|---| diff --git a/apps/lightcraft-web/src/bench.rs b/apps/lightcraft-web/src/bench.rs index 90b85af84..b1b976bfa 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 && app.renderer.in_flight() == 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 4b395c57b..9d3e21997 100644 --- a/apps/lightcraft-web/src/store.rs +++ b/apps/lightcraft-web/src/store.rs @@ -161,6 +161,9 @@ impl Originals { 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/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index f669ec268..00ef74ad9 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -158,6 +158,7 @@ fn services(originals: Originals, backend: Option, ctx: egui::Context) png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), + reveal: None, } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 27539f23d..9f282c164 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -248,7 +248,7 @@ impl Workers { w.dead = true; w.worker.terminate(); if let Some(b) = w.busy.take() { - let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None }; + let r = RenderResult { photo: b.photo, level: b.level, key: b.key, rendered: Err(msg), loaded: None, quick: None }; g.done.push((b.slot, r, 0.0)); } } @@ -308,7 +308,7 @@ impl Workers { } g.affinity.insert((b.photo, b.level), i); g.remote_done += 1; - g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None }, ms)); + g.done.push((b.slot, RenderResult { photo: b.photo, level: b.level, key: b.key, rendered, loaded: None, quick: None }, ms)); } /// The thumbnail index as JSON if it changed since the last call (the host saves it). @@ -340,7 +340,14 @@ impl RenderOffload for Workers { { let image = Arc::unwrap_or_clone(img); let histogram = Histogram::of_srgb8(&image); - let r = RenderResult { photo: job.photo, level: job.level, key: job.key, rendered: Ok(Rendered { image, histogram }), loaded: None }; + let r = RenderResult { + photo: job.photo, + level: job.level, + key: job.key, + rendered: Ok(Rendered { image, histogram }), + loaded: None, + quick: None, + }; g.done.push((slot, r, 0.0)); return None; } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 7200568ee..b2ad72362 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -82,6 +82,19 @@ fn save_prefs(app: &LightcraftApp) { fn services() -> Services { Services { + reveal: Some(Box::new(|path: &str| { + let status = if cfg!(target_os = "macos") { + std::process::Command::new("open").args(["-R", path]).status() + } else if cfg!(target_os = "windows") { + std::process::Command::new("explorer").arg(format!("/select,{path}")).status() + } else { + let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_else(|| ".".into()); + std::process::Command::new("xdg-open").arg(dir).status() + }; + status + .map_err(|e| e.to_string()) + .and_then(|s| if s.success() || cfg!(target_os = "windows") { Ok(()) } else { Err(format!("reveal failed: {s}")) }) + })), pick_files: Some(Box::new(|| { rfd::FileDialog::new() .add_filter( diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index cfb1bd4e0..abba999f7 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -19,6 +19,9 @@ use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu use serde_json::Value; const QUIT: &str = "app.quit"; +/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo +/// or the active mask): left to the egui handler, which knows the context. +const CONTEXTUAL: &[&str] = &["X", "Delete"]; /// Shortcuts text fields need while they have focus. const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"]; @@ -238,7 +241,7 @@ impl NativeMenu { if self.items.contains_key(&key) { continue; // muda ids must be unique } - let accel = shortcut.as_deref().and_then(accelerator); + let accel = shortcut.as_deref().filter(|s| !CONTEXTUAL.contains(s)).and_then(accelerator); let handle = match checked { Some(c) => { let it = CheckMenuItem::with_id(key.clone(), label_text(label), *enabled, *c, accel); @@ -382,8 +385,8 @@ mod tests { for (_, v) in menu_bar(&app) { all(&v, &mut scs); } - for sc in scs { - assert!(accelerator(&sc).is_some(), "{sc}"); + for sc in scs.iter().filter(|s| !CONTEXTUAL.contains(&s.as_str())) { + assert!(accelerator(sc).is_some(), "{sc}"); } } } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 99d352943..5d4f46d9c 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -213,8 +213,21 @@ impl Photo { copy_name: None, } } + /// The develop settings import gives this photo (raws: as-shot white balance and the raw + /// defaults; embedded lens corrections on when the file has them). + pub fn import_defaults(&self) -> DevelopSettings { + let mut d = match self.as_shot_wb { + Some((t, tint)) => DevelopSettings::for_raw(t, tint), + None => DevelopSettings::default(), + }; + if self.embedded_lens.is_some() { + d.optics.lens_profile = true; + } + d + } + /// Edited by the user: settings differ from what import gave the photo. pub fn is_edited(&self) -> bool { - !self.develop.is_unedited() + *self.develop != self.import_defaults() && !self.develop.is_unedited() } /// Capture time if known, else import time (sort key). pub fn date(&self) -> &str { @@ -251,3 +264,22 @@ impl Album { self.smart.is_some() } } + +#[cfg(test)] +mod edited_tests { + use super::*; + + #[test] + fn raw_with_import_settings_is_not_edited() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.arw", "ARW", 10, 10, "2026-10-01T00:00:00"); + p.as_shot_wb = Some((5200.0, 4.0)); + p.develop = Arc::new(p.import_defaults()); + assert!(!p.is_edited(), "import look counts as unedited"); + let mut d = (*p.develop).clone(); + d.light.exposure = 0.5; + p.develop = Arc::new(d); + assert!(p.is_edited()); + p.develop = Arc::new(DevelopSettings::default()); + assert!(!p.is_edited(), "a full reset is unedited too"); + } +} diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index 7f1e1d20a..4483b0608 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -268,7 +268,8 @@ pub fn encode( let n = width * height; #[cfg(feature = "parallel")] use rayon::prelude::*; - let ncomp = if gray { 1 } else { 3 }; + // 4:4:4: Y, Cb, Cr at full resolution in one pass; 4:2:0: Y here, chroma below straight from 2×2 RGB means + let ncomp = if gray || hs == 2 { 1 } else { 3 }; let mut full: Vec> = (0..ncomp).map(|_| vec![0f32; n]).collect(); { let rows: Vec<(usize, Vec<&mut [f32]>)> = { @@ -283,6 +284,12 @@ pub fn encode( } return; } + if out.len() == 1 { + for (o, p) in out[0].iter_mut().zip(src.chunks_exact(channels)) { + *o = 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32 - 128.0; + } + return; + } for (x, p) in src.chunks_exact(channels).enumerate() { let (r, g, b) = (p[0] as f32, p[1] as f32, p[2] as f32); out[0][x] = 0.299 * r + 0.587 * g + 0.114 * b - 128.0; @@ -297,25 +304,28 @@ pub fn encode( } let mut it = full.into_iter(); let mut planes = vec![Plane { w: width, h: height, data: it.next().unwrap_or_default() }]; - for c in it { - if hs == 1 { - planes.push(Plane { w: width, h: height, data: c }); - continue; - } + planes.extend(it.map(|c| Plane { w: width, h: height, data: c })); + if !gray && hs == 2 { + // conversion is linear: average RGB over 2×2 first, then convert once let (cw, ch) = (width.div_ceil(2), height.div_ceil(2)); - let mut d = vec![0f32; cw * ch]; - let down = |(cy, out): (usize, &mut [f32])| { + let (mut cb, mut cr) = (vec![0f32; cw * ch], vec![0f32; cw * ch]); + let down = |(cy, (ob, or)): (usize, (&mut [f32], &mut [f32]))| { let (y0, y1) = (2 * cy, (2 * cy + 1).min(height - 1)); - for (cx, o) in out.iter_mut().enumerate() { - let (x0, x1) = (2 * cx, (2 * cx + 1).min(width - 1)); - *o = 0.25 * (c[y0 * width + x0] + c[y0 * width + x1] + c[y1 * width + x0] + c[y1 * width + x1]); + let (r0, r1) = (&data[y0 * width * channels..], &data[y1 * width * channels..]); + for cx in 0..cw { + let (x0, x1) = (2 * cx * channels, (2 * cx + 1).min(width - 1) * channels); + let s = |k: usize| (r0[x0 + k] as f32 + r0[x1 + k] as f32 + r1[x0 + k] as f32 + r1[x1 + k] as f32) * 0.25; + let (r, g, b) = (s(0), s(1), s(2)); + ob[cx] = -0.168_736 * r - 0.331_264 * g + 0.5 * b; + or[cx] = 0.5 * r - 0.418_688 * g - 0.081_312 * b; } }; #[cfg(feature = "parallel")] - d.par_chunks_mut(cw).enumerate().for_each(down); + cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw)).enumerate().for_each(down); #[cfg(not(feature = "parallel"))] - d.chunks_mut(cw).enumerate().for_each(down); - planes.push(Plane { w: cw, h: ch, data: d }); + cb.chunks_mut(cw).zip(cr.chunks_mut(cw)).enumerate().for_each(down); + planes.push(Plane { w: cw, h: ch, data: cb }); + planes.push(Plane { w: cw, h: ch, data: cr }); } let ql = scaled_quant(&Q_LUMA, quality); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 59922a08e..dcaab9690 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 8e0027f8c..c1cb9040f 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/cmd/library.rs b/crates/engine/src/cmd/library.rs index b2d0d4e5a..4cbfc22d3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -292,30 +292,22 @@ pub fn specs() -> Vec { } ), // ---- albums - cmd!( - "album.create", - "New Album", - ["File"], - Some("Cmd+Shift+N"), - "{name, parent?: folderId, folder?: bool, addSelected?: bool}", - always, - |s, p| { - let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); - if name.is_empty() { - return Err(bad("album.create", "empty name")); - } - let folder = bool_or(p, "folder", false); - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] }; - let id = s.catalog.alloc_album_id(); - let cover = photos.first().copied(); - s.commit( - if folder { "New Folder" } else { "New Album" }, - Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } }, - )?; - Ok(json!({"id": id.0})) + cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| { + let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.create", "empty name")); } - ), + let folder = bool_or(p, "folder", false); + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] }; + let id = s.catalog.alloc_album_id(); + let cover = photos.first().copied(); + s.commit( + if folder { "New Folder" } else { "New Album" }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None } }, + )?; + Ok(json!({"id": id.0})) + }), cmd!( "album.createSmart", "New Smart Album…", diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index f9d085227..884235016 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(); @@ -167,8 +167,33 @@ 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). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the +/// cost of Bayer's, so 3× is accepted down to 75 % (a 24 MP X-Trans preview is then ~2000 px +/// instead of a ~1.2 s full demosaic; zooming in still uses the full-size source). +/// `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).max(1); + let need3 = (max_edge.saturating_mul(3) / 4).max(1); + [8usize, 6, 4, 3, 2].into_iter().find(|&k| raw.can_bin(k) && (long / k >= need || (k == 3 && !raw.can_bin(2) && long / k >= need3))) +} + /// 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 +206,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 +237,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 })); } @@ -235,6 +291,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| { @@ -254,6 +328,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 } } @@ -289,4 +364,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 e255b0e00..e07199d68 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -57,6 +57,18 @@ 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 { + p.import_defaults() +} + +/// 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())) } @@ -223,14 +235,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/lib.rs b/crates/engine/src/lib.rs index 71f2fe37a..25efe8757 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -94,6 +94,8 @@ pub struct Session { pub library: Option, /// XMP sidecar preferences (persisted with the library). pub xmp: sidecar::XmpPrefs, + /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. + pub last_export: Option, } impl Default for Session { @@ -126,6 +128,7 @@ impl Session { active_mask: None, library: None, xmp: sidecar::XmpPrefs::default(), + last_export: None, } } diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index d0fd0ac3b..5225a00a5 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -99,6 +99,8 @@ impl Library { #[serde(default)] struct PrefsFile { xmp: crate::sidecar::XmpPrefs, + /// Parameters of the last export (for Export with Previous). + last_export: Option, } fn presets_json(presets: &[Preset]) -> String { @@ -151,7 +153,9 @@ impl Session { } } // preferences - self.xmp = read_json::(files.as_mut(), "prefs.json").map(|p| p.xmp).unwrap_or_default(); + let prefs = read_json::(files.as_mut(), "prefs.json").unwrap_or_default(); + self.xmp = prefs.xmp; + self.last_export = prefs.last_export; // view state if let Some(v) = read_json::(files.as_mut(), "view.json") { self.source = v.source; @@ -248,7 +252,7 @@ impl Session { /// Save the library preferences (no-op for in-memory sessions). pub fn save_prefs(&mut self) -> Result<()> { - let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp }).unwrap_or_default(); + let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone() }).unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}"))) } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 869357ea3..bb97fc8e0 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)), } @@ -135,7 +146,7 @@ impl MediaCache { SourceLevel::Preview => { self.previews.retain(|(p, _)| *p != id); self.previews.push((id, img)); - let cap = if self.preview_capacity == 0 { 10 } else { self.preview_capacity }; + let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; while self.previews.len() > cap { self.previews.remove(0); } @@ -207,6 +218,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 { @@ -216,6 +229,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 { @@ -240,7 +255,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() { @@ -252,9 +274,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 }, } } } @@ -272,6 +306,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) { @@ -332,7 +418,71 @@ 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, origin: p.source.clone(), info: source_info(&p), settings, request, key, cache, stages: None }) + Some(RenderJob { + photo: id, + level, + source, + origin: p.source.clone(), + 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/gpu/src/lib.rs b/crates/gpu/src/lib.rs index bdfd5b3ea..ee88430b4 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/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 3ccee51ba..4ba0d886f 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/preview/src/lib.rs b/crates/preview/src/lib.rs index 5fdcfc5e4..5490194e4 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/raw/src/binned.rs b/crates/raw/src/binned.rs new file mode 100644 index 000000000..fde6c2c0b --- /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 c26f768b1..2b50b66c3 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 f25eb6044..4ffb0666c 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 388174ee2..40d787514 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 2803d4b5c..453c75231 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 eb7c8e685..890200384 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 bcd8dd1ac..11700bd1b 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 000000000..21260e7d8 --- /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 diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 27451dafa..d84ad0d00 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,7 +77,8 @@ 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, }) } @@ -332,6 +333,14 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result Vec>; pub type SaveFile = Box Option>; pub type WriteFn = Box Result<(), String>>; pub type PngEncode = Box Vec>; +/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux). +pub type RevealFn = Box Result<(), String>>; /// Platform services injected by the host app (desktop or web). #[derive(Default)] @@ -44,6 +46,8 @@ pub struct Services { pub write: Option, /// PNG encoder (the host links an image encoder; the UI crate stays codec-free). pub png: Option, + /// Show a file in the system file manager (desktop only). + pub reveal: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -84,10 +88,15 @@ 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 { 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(), @@ -110,6 +119,7 @@ impl LightcraftApp { image_rect: None, widgets: vec![], gesture: None, + loupe_shown: None, } } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 0e74e2da9..02b63d61e 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -62,6 +62,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "file.exportPresets", "---", "dialog.export", + "app.exportPrevious", "---", "library.toggleAutoWriteXmp", "---", @@ -133,6 +134,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "photo.saveMetadataToFile", "photo.readMetadataFromFile", + "app.showInFinder", "---", "photo.delete", ], diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a43c98858..e2e3f6f21 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -23,6 +23,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), + ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), + ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), ("view.showOriginal", "Show Original", Some("\\"), "View"), ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), ("view.zoom100", "Zoom 100%", Some("Cmd+1"), "View"), @@ -57,7 +59,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), ("tool.none", "No Tool", None, ""), ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), - ("dialog.newFolder", "New Folder…", None, "File"), + ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), @@ -69,6 +71,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.about", "About LightCraft", None, "Help"), ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), ("app.export", "Export Now", None, ""), + ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), ]; fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { @@ -144,6 +148,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.before_after = if app.ui.before_after == BeforeAfter::TopBottom { BeforeAfter::Off } else { BeforeAfter::TopBottom }; + Ok(Value::Null) + } + "view.beforeAfterSplitTopBottom" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::SplitTopBottom { BeforeAfter::Off } else { BeforeAfter::SplitTopBottom }; + Ok(Value::Null) + } "view.showOriginal" => { app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original }; Ok(Value::Null) @@ -284,6 +296,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true }); + Ok(Value::Null) + } "dialog.newAlbum" => { app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false }); Ok(Value::Null) @@ -296,10 +312,6 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true }); - Ok(Value::Null) - } "dialog.createPreset" => { app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); Ok(Value::Null) @@ -311,8 +323,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = - Some(Dialog::Export { opts: Default::default(), long_edge: 2048, limit_kb: 0, dir: crate::control::default_export_dir() }); + let prev = app.session.last_export.clone().unwrap_or_default(); + let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); + let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); + app.ui.dialog = Some(Dialog::Export { + opts: lightcraft_engine::export::ExportOptions::from_json(&prev), + long_edge: u("longEdge", 2048) as u32, + limit_kb: u("limitKb", 0) as u32, + dir, + }); Ok(Value::Null) } "app.about" => { @@ -387,6 +406,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::control::export_active(app, p), + "app.showInFinder" => show_in_finder(app), + "app.exportPrevious" => match app.session.last_export.clone() { + Some(prev) => crate::control::export_active(app, &prev), + None => Err("nothing exported yet — use Export…".into()), + }, _ => return None, }; Some(r) @@ -396,6 +420,15 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { match id { s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.copySettings" => app.session.active().is_some(), + "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), + "app.showInFinder" => { + app.services.reveal.is_some() + && app + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + } "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), "view.compare" => app.session.catalog.len() > 1, "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, @@ -438,3 +471,15 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { } v } + +/// Reveal the active photo's original in the system file manager. +fn show_in_finder(app: &mut LightcraftApp) -> Result { + let id = app.session.active().ok_or("no photo selected")?; + let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) { + Some(lightcraft_engine::catalog::Source::File { path }) => path, + _ => return Err("this photo has no file (demo scene)".into()), + }; + let reveal = app.services.reveal.as_mut().ok_or("not available here")?; + reveal(&path)?; + Ok(json!({"path": path})) +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 818dca642..165a7df8d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -124,15 +124,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let ppp = ui.ctx().pixels_per_point(); let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 }); let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; - let split = app.ui.before_after == BeforeAfter::SideBySide; - let areas: Vec = if split { - let half = area.width() / 2.0 - 6.0; - vec![ - Rect::from_min_size(area.min, vec2(half, area.height())), - Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), - ] - } else { - vec![area] + // two views (before, after): side by side or stacked + let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); + let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom); + let areas: Vec = match app.ui.before_after { + BeforeAfter::SideBySide => { + let half = area.width() / 2.0 - 6.0; + vec![ + Rect::from_min_size(area.min, vec2(half, area.height())), + Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())), + ] + } + BeforeAfter::TopBottom => { + let half = area.height() / 2.0 - 14.0; + vec![ + Rect::from_min_size(area.min, vec2(area.width(), half)), + Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)), + ] + } + _ => vec![area], }; let main_area = *areas.last().unwrap_or(&area); let img_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); @@ -142,40 +152,77 @@ 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); } + // 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) + { + 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) + if (split || show_before || split_view) && let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool) { 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) { @@ -184,6 +231,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); } + if app.ui.before_after == BeforeAfter::SplitTopBottom { + let mid = img_rect.center().y; + if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { + let top = Rect::from_min_max(img_rect.min, pos2(img_rect.right(), mid)); + p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); + } + p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); + } if app.ui.show_clipping && !show_before { clipping_overlay(app, &p, img_rect); } @@ -199,6 +254,18 @@ 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 { + 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 +758,8 @@ pub(crate) 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/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index bf0536cbd..43ed2983a 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -211,6 +211,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.end_row(); } } + for (sc, id, _) in crate::shortcuts::ALIASES { + let label = crate::menus::UI_COMMANDS + .iter() + .find(|c| c.0 == *id) + .map(|c| c.1) + .or_else(|| lightcraft_engine::find_command(id).map(|c| c.label)) + .unwrap_or(id); + ui.label(label); + ui.label(*sc); + ui.label(egui::RichText::new(*id).color(t.text_dim)); + ui.end_row(); + } }); }); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 309254fe9..03534a2cf 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -132,6 +132,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 }; @@ -148,10 +168,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 99815866d..3703dcb82 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -7,12 +7,18 @@ //! disk) before rendering. On wasm the jobs run inline, one per frame, unless the host installs a //! [`RenderOffload`] (the browser build's Web Workers): then queued jobs are handed to it as it has //! room, and its results are collected every 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; @@ -21,9 +27,16 @@ 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), + /// 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 { @@ -33,6 +46,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>, } @@ -71,6 +86,10 @@ 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, + /// Prefetch slot → key of the last job submitted (each runs once). + prefetched: HashMap, } impl Default for Renderer { @@ -87,6 +106,8 @@ impl Default for Renderer { completed: 0, keep_pixels: false, stages: HashMap::new(), + quick_tried: HashMap::new(), + prefetched: HashMap::new(), } } } @@ -131,6 +152,39 @@ 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())); + } + + /// 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) + } + + /// 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() + self.queue.len() } @@ -154,7 +208,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. @@ -177,7 +231,7 @@ impl Renderer { } self.dispatch(); let mut changed = false; - for (slot, r, ms) in finished { + for (mut slot, r, ms) in finished { session.accept(&r); self.completed += 1; if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { @@ -186,6 +240,21 @@ 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 + } + 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()); @@ -199,12 +268,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, + }, ); } } @@ -228,11 +307,12 @@ 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 pending = &mut self.pending; self.queue.retain(|q| match q.slot { @@ -242,10 +322,11 @@ impl Renderer { } _ => true, }); - 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))); } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cce9defde..d48c3917a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -5,6 +5,18 @@ use serde_json::json; use crate::LightcraftApp; +/// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`. +/// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our +/// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts. +pub const ALIASES: &[(&str, &str, &str)] = &[ + ("Cmd+D", "library.selectNone", "{}"), + ("Shift+E", "dialog.export", "{}"), + ("Space", "view.zoomToggle", "{}"), + ("Shift+M", "version.create", "{}"), + ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), + ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), +]; + pub fn parse(s: &str) -> Option<(Modifiers, Key)> { let mut m = Modifiers::NONE; let mut key = None; @@ -14,13 +26,14 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { "Shift" => m.shift = true, "Alt" => m.alt = true, "Ctrl" => m.ctrl = true, + // "Delete" means the key labelled ⌫ (egui's Backspace); forward-delete also matches, see `matches`. + "Delete" => key = Some(Key::Backspace), k => { key = Key::from_name(k).or(match k { "Right" => Some(Key::ArrowRight), "Left" => Some(Key::ArrowLeft), "Up" => Some(Key::ArrowUp), "Down" => Some(Key::ArrowDown), - "Delete" => Some(Key::Backspace), "\\" => Some(Key::Backslash), "/" => Some(Key::Slash), "=" => Some(Key::Equals), @@ -39,7 +52,10 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool { i.events.iter().any(|e| match e { egui::Event::Key { key, pressed: true, modifiers, .. } => { - *key == k && modifiers.command == m.command && modifiers.shift == m.shift && modifiers.alt == m.alt + (*key == k || (k == Key::Backspace && *key == Key::Delete)) + && modifiers.command == m.command + && modifiers.shift == m.shift + && modifiers.alt == m.alt } _ => false, }) @@ -53,21 +69,34 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let mut fire: Vec = Vec::new(); // shortcuts the native menu bar handles (it consumes those key presses itself) let native = |sc: &str| app.native_shortcuts.contains(sc); + let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); ctx.input(|i| { + let mut ui_keys = Vec::new(); for (id, _, sc, _) in crate::menus::UI_COMMANDS { - if let Some((m, k)) = sc.filter(|s| !native(s)).and_then(parse) - && matches(i, m, k) - { - fire.push(id.to_string()); + if let Some((m, k)) = sc.and_then(parse) { + ui_keys.push((m, k)); + if !native(sc.unwrap_or_default()) && matches(i, m, k) { + fire.push(id.to_string()); + } } } for c in lightcraft_engine::command_specs() { + // A UI command bound to the same key wraps the engine command (e.g. `W` opens the + // White Balance Selector tool rather than sampling without a point): the UI one wins. if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse) + && !ui_keys.contains(&(m, k)) && matches(i, m, k) { fire.push(c.id.to_string()); } } + for (sc, id, params) in ALIASES { + if let Some((m, k)) = parse(sc).filter(|_| !native(sc)) + && matches(i, m, k) + { + aliased.push((id, serde_json::from_str(params).unwrap_or_default())); + } + } // rating 0-5, colour labels 6-9 (with Shift: and advance) for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) { @@ -82,12 +111,6 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { fire.push(format!("label:{label}")); } } - // Shift+P/X/U: flag and advance - for (id, key) in [("photo.pick", Key::P), ("photo.reject", Key::X), ("photo.unflag", Key::U)] { - if matches(i, Modifiers::SHIFT, key) { - fire.push(format!("flagadv:{id}")); - } - } }); use crate::panels::compare; // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or @@ -99,6 +122,20 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { compare::advance(app); } }; + for (id, params) in aliased { + // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, + // `advance` moves to the next candidate there + if matches!(id, "photo.flag" | "photo.rate" | "photo.label") { + let mut params = params; + let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false); + if let Some(o) = params.as_object_mut() { + o.remove("advance"); + } + cull(app, id, params, advance); + } else { + let _ = app.run(id, params); + } + } for f in fire { if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); @@ -107,8 +144,6 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { cull(app, "photo.label", json!({"label": l}), false); - } else if let Some(id) = f.strip_prefix("flagadv:") { - cull(app, id, json!({}), true); } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { let d = if f == "library.next" { 1 } else { -1 }; let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; @@ -120,6 +155,21 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { _ => app.toast(ctx, "Unflagged"), } } else { + // Delete acts on what's being edited: the active mask in the Masking panel; never the + // photo while retouching (spots are removed from their own panel). + if f == "photo.delete" { + use crate::state::RightPanel as R; + match app.ui.right { + R::Masking => { + if app.session.active_mask.is_some() { + let _ = app.run("mask.delete", json!({})); + } + continue; + } + R::Remove | R::RedEye => continue, + _ => {} + } + } // X is both reject (library) and swap crop aspect (crop tool) if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { let _ = app.run("crop.rotateAspect", json!({})); @@ -143,6 +193,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests { + + #[test] + fn delete_means_the_backspace_key() { + assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace))); + assert_eq!(parse("Cmd+Delete"), Some((Modifiers::COMMAND, Key::Backspace))); + } use super::*; #[test] @@ -164,4 +220,52 @@ mod tests { } } } + + #[test] + fn aliases_parse_and_target_existing_commands() { + let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect(); + for (sc, id, params) in ALIASES { + assert!(parse(sc).is_some(), "{id}: {sc}"); + assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); + assert!(serde_json::from_str::(params).is_ok(), "alias {sc}: bad params"); + } + } + + /// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. + const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; + + /// No key fires two different actions (a UI command may shadow the engine command it wraps). + #[test] + fn no_conflicting_bindings() { + let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); + for (id, _, sc, _) in crate::menus::UI_COMMANDS { + if let Some(k) = sc.and_then(parse) { + ui.push((k, id.to_string())); + } + } + for (sc, id, _) in ALIASES { + ui.push((parse(sc).unwrap(), format!("alias {id}"))); + } + let mut engine: Vec<((Modifiers, Key), &str)> = Vec::new(); + for c in lightcraft_engine::command_specs() { + if let Some(k) = c.shortcut.and_then(parse) { + engine.push((k, c.id)); + } + } + for (sc, id, _) in ALIASES { + let k = parse(sc).unwrap(); + assert!(!engine.iter().any(|(k2, _)| *k2 == k), "alias {sc} ({id}) shadows an engine shortcut"); + } + for (i, (k, a)) in ui.iter().enumerate() { + for (k2, b) in &ui[i + 1..] { + assert!(k != k2, "{a} and {b} share a key"); + } + } + for (i, (k, a)) in engine.iter().enumerate() { + for (k2, b) in &engine[i + 1..] { + let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)); + assert!(k != k2 || contextual, "{a} and {b} share a key"); + } + } + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 52367fa4e..d2614f470 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -58,6 +58,10 @@ pub enum BeforeAfter { Original, SideBySide, Split, + /// Before above after. + TopBottom, + /// One image split horizontally: before above the line. + SplitTopBottom, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 3dc9299d2..88f19924f 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 diff --git a/docs/parity.md b/docs/parity.md new file mode 100644 index 000000000..edeead7f8 --- /dev/null +++ b/docs/parity.md @@ -0,0 +1,813 @@ +# Lightroom parity tracker + +A living checklist of every Lightroom feature LightCraft aims to match, what we have, and what is missing. Agents and +humans pick work from **[Top gaps](#top-gaps)**; whoever lands a feature updates its row in the same commit. + +- **Ids** come from the (local, gitignored) reference in `plan/lightroom/`: `LR-…` = feature catalog (03), + `MENU-…` = menu items (04), `KEY-…` / `KEYC-…` = keyboard shortcuts (06, desktop / Classic), `LRC-…` = Classic + extras (08). Ids are stable; never renumber. +- **Tier** as in the catalog: **P0** core, **P1** important parity, **P2** later / AI / niche, **OOS** out of scope. +- **Status**: ✅ done (usable end to end; polish may be noted) · 🟡 partial (note says what is missing, or the status + is unverified) · ⬜ missing · 🚫 out of scope / not applicable. +- **Evidence**: `cmd:` = command id (engine `command_specs()` or UI `UI_COMMANDS`), `ctl:` = develop control + id (`lightcraft-cli controls`), plus source files. A trailing `*` matches a prefix (`ctl:mixer.*`). +- Feature names and notes are our own words. No Adobe text, screenshots or assets belong here. + +`cargo xtask parity` checks that every `cmd:`/`ctl:` id and file path below still exists (part of `cargo xtask ci`) +and prints the summary; `cargo xtask parity --write` refreshes the summary table below. + +## Summary + + +| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | +|---|---:|---:|---:|---:|---:|---:| +| A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) | +| B. Library management (LIB) | 9 | 4 | 12 | 2 | 7/9 (78%) | 1/9 (11%) | +| C. Views & navigation (VIEW) | 7 | 2 | 8 | 0 | 7/9 (78%) | 0/4 (0%) | +| D. Search & filter (FILT) | 2 | 7 | 4 | 0 | 2/4 (50%) | 0/4 (0%) | +| E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | +| F. Edit panel — global adjustments (EDIT) | 35 | 3 | 10 | 1 | 27/28 (96%) | 8/14 (57%) | +| G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | +| H. Crop & rotate (CROP) | 5 | 4 | 0 | 1 | 5/6 (83%) | 0/3 (0%) | +| I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | +| J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | +| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) | +| L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) | +| M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | +| N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) | +| O. Merge (MERGE) | 0 | 0 | 4 | 0 | — | — | +| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | +| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — | +| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | +| S. Export (EXP) | 4 | 6 | 8 | 0 | 2/7 (29%) | 2/7 (29%) | +| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | +| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | +| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) | +| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | +| X. Cross-cutting behaviours (BEHAV) | 10 | 2 | 5 | 1 | 7/8 (88%) | 3/5 (60%) | +| Y. Menus | 42 | 19 | 24 | 8 | 37/47 (79%) | 3/23 (13%) | +| Z. Keyboard shortcuts (desktop) | 45 | 16 | 19 | 1 | 39/52 (75%) | 6/23 (26%) | +| Lightroom Classic extras | 1 | 17 | 61 | 9 | — | 1/21 (5%) | +| **Total** | 184 | 110 | 206 | 37 | 148/198 (75%) | 33/144 (23%) | + + +## Top gaps + +Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on. + +1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / + Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. +2. **LR-FILT-RATING / LR-FILT-FLAG / LR-FILT-LABEL / LR-FILT-TYPE** (P0/P1) — the engine filters already exist + (`cmd:library.filter`); there is no filter-bar UI (stars ≥/=/≤, flags, labels, kind, edited). High value, low effort. +3. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") + and offer built-in export presets (small JPEG, full JPEG, TIFF). Medium value, low effort. +4. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. +5. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is + rendered but has no UI. Low effort. +6. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. +7. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask + kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. +8. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local + Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. +9. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, + feather/opacity sliders, `[`/`]` size keys. Medium effort. +10. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +11. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. +12. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; + live preview while hovering presets/profiles/versions. Medium effort. +13. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). +14. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +15. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), + XMP prefs, cache, GPU. Medium effort. +16. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination + album; browse folders before adding. Medium effort. +17. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in + `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. +18. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no + permissive pure-Rust decoder yet). High value, high effort (clean sources needed). +19. **MENU-BAR** (P1) — render the existing menu model (`ui.menu.list`) as a native macOS menu / in-window menu bar. + High value, medium effort. +20. **LR-LIB-SMARTALBUM** (P1) — smart albums from the existing filter + fielded search tokens. High value, medium effort. +21. **LR-LIB-STACK + LR-LIB-VIRTUALCOPY** (P1) — stacks (⌘G) and virtual copies. Medium effort. +22. **LR-VIEW-FULLSCREEN + LR-VIEW-COMPARE + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +23. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +24. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). +25. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — + missing develop controls (calibration matters for XMP interop). +26. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +27. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). + +## Shortcuts: conflicts and missing bindings + +Compared `plan/lightroom/06-shortcuts.md` (desktop part) with our bindings: command specs in `crates/engine/src/cmd/`, +`UI_COMMANDS` in `crates/ui-egui/src/menus.rs` and secondary bindings (`ALIASES`) in `crates/ui-egui/src/shortcuts.rs`. +`no_conflicting_bindings` (same file) fails when one key fires two actions. + +**Fixed (M16.1):** added Lightroom-desktop keys as secondary bindings for existing commands — ⌘D Select None, ⇧E +Export dialog, Space Toggle zoom, ⇧M Create Version, ⇧X Reject + advance, ⇧U Unflag + advance. `⇧Y` fired both +Before/After Split and the History panel; History lost the binding. `W` and `⇧⌘I` also fired the engine command +under the UI command that wraps it (a no-op error); the UI command now wins. + +**Deliberate differences (our key → Lightroom desktop key)** — each is a conflict with another binding we have: + +| Action | Ours | Lightroom desktop | Why | +|---|---|---|---| +| Pick flag | P | Z | Z = toggle zoom (Classic convention); P is the Classic pick key | +| Photos panel (left) | ⌘⇧L | P | P = pick | +| Expand/collapse edit sections | ⌘⌥1–5 | ⌘1–6 | ⌘0 = Zoom to Fit, ⌘1 = Zoom 100 %; Geometry lives in the Crop panel | +| Histogram | ⌘⇧H | ⌘0 | ⌘0 = Zoom to Fit | +| Square Grid | ⇧G | (none; ⇧G = Guided Upright) | Guided Upright is a button in the Crop panel | +| Export dialog | ⌘⇧E (+ ⇧E) | ⇧E | ⌘⇧E is "Edit in Photoshop" there; no external-editor command yet | +| Crop overlay cycle | ⇧O | O | O = mask overlay; ⇧O (mask colour / overlay orientation) unused otherwise | +| Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | +| Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | + +**Still missing / broken:** +- No command yet: F full-screen preview, ⇧⌘F window full screen, ⇧⌘V paste selected, + ⌘, settings, ⌘F focus search, ⌘G / ⇧⌘G stacks, A visualize spots, `[` `]` / ⇧`[` ⇧`]` + brush size/feather, ⌃H / ⌃M merges, F1 help, ⇧6–9 label + advance. +- `⌫` in the Masking panel deletes the active mask; spots have no pin selection yet (⌫ does nothing in Remove). +- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — no pin toggle yet. +- ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). + + + +## A. Import (IMP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | 🟡 | `cmd:file.addPhotos`, `cmd:library.import` (`mode`, `album`) | file picker + import report; no review grid with per-photo checkboxes / destination album UI | +| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | +| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | +| LR-IMP-DEVICE | Import from camera/card | P1 | ⬜ | | no device detection | +| LR-IMP-AUTO | Watched-folder auto import | P2 | ⬜ | | | +| LR-IMP-PRESET | Preset on import | P2 | ⬜ | | | +| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ⬜ | `crates/engine/src/import.rs` | raws get embedded lens corrections on import; no user raw-default setting | +| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | +| LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported | +| LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; no browse-a-folder-without-adding view | +| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG, CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only), HEIC/AVIF decode | +| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | ⬜ | | | +| LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | DNG writer exists, not wired to import | + +## B. Library management (LIB) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | +| LR-LIB-RECENT-ADDED | Recently added | P1 | 🟡 | `cmd:library.source` (`recentlyAdded`) | not grouped by import session | +| LR-LIB-BYDATE | Browse by date | P1 | 🟡 | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` | years only; no month/day tree | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ⬜ | `crates/catalog/src/query.rs` | building blocks exist (filter + fielded search tokens) | +| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | +| LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | +| LR-LIB-TARGET | Target album | P2 | ⬜ | | | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | +| LR-LIB-LABEL | Colour labels | P1 | 🟡 | `cmd:photo.label`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | no label menu/buttons, no purple key, label names not editable | +| LR-LIB-KEYWORD | Keywords | P0 | 🟡 | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`) | no library-wide rename/delete, no keyword browser in the left panel | +| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | +| LR-LIB-STACK | Stacks | P1 | ⬜ | | | +| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | +| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ⬜ | | | +| LR-LIB-RENAME | Batch rename | P1 | ⬜ | | | +| LR-LIB-CAPTURETIME | Edit capture time | P1 | ⬜ | | `photo.setMeta` has no capture-time field | +| LR-LIB-SHOWFINDER | Reveal original in file manager | P0 | ⬜ | | | +| LR-LIB-COVER | Album cover | P2 | ✅ | `cmd:album.setCover` | | +| LR-LIB-CULL | Assisted culling | P2 | ⬜ | | | +| LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | +| LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ⬜ | | | +| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ⬜ | | | + +## C. Views & navigation (VIEW) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | 🟡 | `cmd:view.photoGrid`, `crates/ui-egui/src/panels/grid.rs` | no grouping by date | +| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | | +| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | +| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | +| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ⬜ | | | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar | +| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | all four layouts; "before" is always the original (no before-state from history) | +| LR-VIEW-COMPARE | Compare two photos | P1 | ⬜ | | | +| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ⬜ | | | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | | +| LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | | +| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | | +| LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram | +| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | + +## D. Search & filter (FILT) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); no suggestions dropdown | +| LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | | +| LR-FILT-RATING | Rating filter | P0 | 🟡 | `cmd:library.filter` (`rating`, `ratingOp`) | engine + search token only; no filter-bar UI | +| LR-FILT-FLAG | Flag filter | P0 | 🟡 | `cmd:library.filter` (`flag`), `cmd:library.source` (`picks`) | no filter-bar UI (Picks source only) | +| LR-FILT-LABEL | Colour-label filter | P1 | 🟡 | `cmd:library.filter` (`label`) | no UI | +| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`) | no UI; no HDR/panorama/depth kinds | +| LR-FILT-KEYWORD | Keyword filter | P1 | 🟡 | `cmd:library.filter` (`keyword`) | no UI | +| LR-FILT-CAMERA | Camera / lens filter | P1 | 🟡 | `cmd:library.filter` (`camera`) | no UI; lens via text search only | +| LR-FILT-LOCATION | Location filter | P2 | 🟡 | `cmd:library.filter` (`text`) | free-text match on the location field only | +| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | +| LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | | +| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | +| LR-FILT-SAVED | Filter presets [Classic] | P2 | ⬜ | | | + +## E. Metadata (META) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` | no flash, creator field, map snippet or people | +| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ⬜ | | | +| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`) | text field + GPS read; no map, no geocoding | +| LR-META-COPYPASTE | Copy / paste metadata | P2 | ⬜ | | | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | | +| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | 🟡 | `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; panel shows a subset; no metadata presets | + +## F. Edit panel — global adjustments (EDIT) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-EDIT-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto`, `crates/pipeline/src/auto.rs` | | +| LR-EDIT-BW | Black & white | P0 | ✅ | `cmd:develop.treatment` | | +| LR-EDIT-HDR-MODE | HDR editing | P2 | ⬜ | | | +| LR-EDIT-LIGHT-EXPOSURE | Exposure | P0 | ✅ | `ctl:light.exposure` | | +| LR-EDIT-LIGHT-CONTRAST | Contrast | P0 | ✅ | `ctl:light.contrast` | | +| LR-EDIT-LIGHT-HIGHLIGHTS | Highlights | P0 | ✅ | `ctl:light.highlights` | | +| LR-EDIT-LIGHT-SHADOWS | Shadows | P0 | ✅ | `ctl:light.shadows` | | +| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | | +| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | | +| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | | +| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `crates/ui-egui/src/panels/edit.rs` | no curve presets (linear / medium / strong) | +| LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | +| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ⬜ | | | +| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ⬜ | | | +| LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | +| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp` | relative scale for non-raw in the UI | +| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint` | | +| LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | +| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | +| LR-EDIT-COLOR-MIXER-HSL | 8-band colour mixer | P0 | ✅ | `ctl:mixer.*` | no targeted (drag-on-image) mode | +| LR-EDIT-COLOR-MIXER-BW | B&W mix | P1 | 🟡 | `ctl:bw.*` | no auto mix | +| LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ⬜ | | | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*` | | +| LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | +| LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | +| LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | +| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | 🟡 | `ctl:vignette.*`, `crates/pipeline/src/finish.rs` | style (highlight / colour priority / paint) rendered but not selectable in the UI | +| LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | +| LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | | +| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | | +| LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | ⬜ | | settings field reserved, not rendered | +| LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | +| LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | +| LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | +| LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs` | uses corrections embedded in DNG/raw files; no lens-profile database | +| LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | +| LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | +| LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | +| LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | +| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ⬜ | | | +| LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | +| LR-EDIT-CALIB | Calibration [Classic] | P1 | ⬜ | | needed for XMP interop | +| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled` | | +| LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | +| LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | + +## G. Profiles (PROF) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-PROF-DROPDOWN | Profile menu | P0 | 🟡 | `cmd:develop.profile`, `cmd:profiles.list`, `crates/ui-egui/src/panels/edit.rs` | no favourites / recent / browse entry | +| LR-PROF-BROWSER | Profile browser | P1 | ⬜ | | | +| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | +| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | +| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | +| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ⬜ | | | +| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | +| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | +| LR-PROF-AMOUNT | Profile amount | P1 | ✅ | `ctl:profile.amount` | | +| LR-PROF-IMPORT | Import profiles | P1 | ⬜ | | | + +## H. Crop & rotate (CROP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `crates/ui-egui/src/panels/detail.rs` | | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect` | no "As Shot"; custom ratio via command params only | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | 🟡 | `cmd:crop.straighten` | rotate by dragging outside the crop; no draw-along-horizon tool / double-click auto | +| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:geometry.upright` (`level`) | via Upright Level; no Auto button in the crop panel | +| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle` | | +| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | +| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | +| LR-CROP-OVERLAY | Crop overlays | P1 | 🟡 | `cmd:view.cropOverlay` | thirds, grid, golden ratio, diagonal; no triangle / spiral / aspect overlays or orientation cycle | +| LR-CROP-ZOOM | Zoom while cropping | P1 | 🟡 | | unverified | +| LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | | + +## I. Remove / healing (REM) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-REM-CONTENTAWARE | Content-aware remove | P1 | 🟡 | `cmd:spot.add` (`mode: remove`), `crates/pipeline/src/spots.rs` | heal with automatic source; no patch synthesis (M8.4) | +| LR-REM-HEAL | Heal | P0 | ✅ | `cmd:spot.add` (`mode: heal`) | | +| LR-REM-CLONE | Clone | P0 | ✅ | `cmd:spot.add` (`mode: clone`) | | +| LR-REM-GEN | Generative remove | OOS | 🚫 | | | +| LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | | +| LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | 🟡 | `cmd:spot.add` (`size`, `feather`, `opacity`), `crates/ui-egui/src/panels/right.rs` | UI has size only; no `[` `]` keys | +| LR-REM-SPOT-EDIT | Edit existing spots | P0 | 🟡 | `cmd:spot.delete` | no pin selection, no moving target/source, ⌫ deletes the photo instead | +| LR-REM-VISUALIZE | Visualize spots | P1 | ⬜ | | | +| LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | +| LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | +| LR-REM-DUST | Dust detection | P2 | ⬜ | | | +| LR-REM-SYNC | Sync spots | P1 | ✅ | `cmd:develop.copy` (`groups`), `crates/develop/src/presets.rs` | | + +## J. Red eye (EYE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-EYE-RED | Red-eye correction | P1 | ⬜ | `cmd:panel.redeye` | placeholder panel; settings field not rendered | +| LR-EYE-PET | Pet eye | P2 | ⬜ | | | + +## K. Masking (MASK) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-MASK-PANEL | Masks panel | P0 | 🟡 | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate`, `cmd:mask.visible`, `cmd:mask.delete` | no reorder, no one-step duplicate-and-invert | +| LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model | +| LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`) | heuristic | +| LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject heuristic | +| LR-MASK-OBJECTS | Object selection | P2 | ⬜ | | shape exists (falls back to the subject heuristic); no UI | +| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | | +| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` | size/feather/flow/density/erase; Auto Mask flag not applied; no A/B brushes, no pressure | +| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | +| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | +| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified | +| LR-MASK-LUMRANGE | Luminance range | P1 | 🟡 | `cmd:mask.add` (`luminanceRange`) | no luminance-map display | +| LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | +| LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent` | | +| LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | +| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | +| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ⬜ | | | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets | +| LR-MASK-OVERLAY | Mask overlay | P0 | 🟡 | `cmd:view.maskOverlay`, `crates/ui-egui/src/panels/detail.rs` | brush dabs + shape outlines only; no rendered alpha, colour or mode options | +| LR-MASK-PINS | Pins | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | handles drawn; no show/hide-pins option | +| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | +| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | +| LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | + +## L. Presets (PRE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-PRE-PANEL | Presets panel | P0 | 🟡 | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; no hover preview | +| LR-PRE-CREATE | Create preset | P0 | 🟡 | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | dialog has no per-group checkboxes (fixed subset) | +| LR-PRE-MANAGE | Manage presets | P1 | 🟡 | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export` | no rename, update-with-current, move group, hide groups | +| LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | +| LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | +| LR-PRE-PREMIUM | Built-in presets (own) | P2 | 🟡 | `crates/engine/src/presets.rs` | 18 own-authored presets | +| LR-PRE-RECOMMENDED | Community recommendations | OOS | 🚫 | | | +| LR-PRE-ONIMPORT | Apply during import | P2 | ⬜ | | | + +## M. Versions & history (VER) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | +| LR-VER-PANEL | Versions panel | P1 | 🟡 | `cmd:panel.versions`, `cmd:version.restore`, `cmd:version.delete` | no rename, update, hover preview, named/auto tabs | +| LR-VER-AUTO | Automatic versions | P2 | ⬜ | | `auto` flag reserved in the model | +| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | +| LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | + +## N. Copy / paste / sync (SYNC) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-SYNC-COPY | Copy edit settings | P0 | ✅ | `cmd:develop.copy` | | +| LR-SYNC-CHOOSE | Choose settings to copy | P0 | ✅ | `cmd:dialog.copySettings` | groups are coarser than per-slider | +| LR-SYNC-PASTE | Paste to selection | P0 | ✅ | `cmd:develop.paste` | no separate "paste selected" (choose at copy time instead) | +| LR-SYNC-SYNCBTN | Sync active → selected | P1 | ✅ | `cmd:develop.sync` | | +| LR-SYNC-PREVIOUS | Paste from previous / auto sync [Classic] | P2 | ⬜ | | | + +## O. Merge (MERGE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-MERGE-HDR | HDR merge | P2 | ⬜ | | | +| LR-MERGE-PANO | Panorama | P2 | ⬜ | | | +| LR-MERGE-HDRPANO | HDR panorama | P2 | ⬜ | | | +| LR-MERGE-HEADLESS | Merge with last settings | P2 | ⬜ | | | + +## P. Enhance (ENH) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-ENH-DIALOG | Enhance dialog | P2 | ⬜ | | | +| LR-ENH-INPLACE | In-place enhance | P2 | ⬜ | | | + +## Q. HDR (HDR) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-HDR-EDIT | HDR editing | P2 | ⬜ | | | +| LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | ⬜ | | | +| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ⬜ | | | +| LR-HDR-LIMIT | HDR headroom limit | P2 | ⬜ | | | +| LR-HDR-EXPORT | HDR export | P2 | ⬜ | | | + +## R. Video (VID) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-VID-PLAY | Video playback | P1 | ⬜ | `crates/catalog/src/model.rs` | videos can be catalogued (kind, duration); no player | +| LR-VID-TRIM | Trim | P1 | ⬜ | | | +| LR-VID-EDIT | Edits on video | P2 | ⬜ | | | +| LR-VID-COVER | Cover frame | P2 | ⬜ | | | +| LR-VID-EXPORT | Video export | P2 | ⬜ | | | +| LR-VID-PHOTO2VIDEO | Photo to video (generative) | OOS | 🚫 | | | + +## S. Export (EXP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-EXP-DIALOG | Export dialog | P0 | 🟡 | `cmd:dialog.export`, `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export works; no built-in or saved export presets | +| LR-EXP-TYPE | File types | P0 | 🟡 | `crates/engine/src/export.rs` (`ExportFormat`) | JPEG, PNG, TIFF, WebP, AVIF; no DNG, JXL or original (+XMP) | +| LR-EXP-DIM | Output size | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (long edge / full size) | no short edge / width / height / megapixels, ppi, don't-enlarge | +| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | +| LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only | +| LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate | +| LR-EXP-COLORSPACE | Output colour space | P0 | ⬜ | `crates/engine/src/export.rs` | sRGB only | +| LR-EXP-HDR | HDR output | P2 | ⬜ | | | +| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | +| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | +| LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `crates/engine/src/export.rs` (`Watermark`) | text only; no graphic watermark | +| LR-EXP-NAMING | File naming | P1 | 🟡 | `cmd:app.export` (`naming`: `{name}`, `{seq}`) | no date tokens, custom start number | +| LR-EXP-LOCATION | Destination folder | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (folder field) | no folder picker, subfolder or name-conflict policy | +| LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | +| LR-EXP-DNGOPT | DNG options | P2 | ⬜ | | | +| LR-EXP-ORIGINAL | Original + XMP | P1 | ⬜ | | | +| LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | +| LR-EXP-PSD | Round trip to an external editor | P2 | ⬜ | | | + +## T. Share (SHARE) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-SHARE-LINK | Web share link | OOS | 🚫 | | | +| LR-SHARE-INVITE | Invite collaborators | OOS | 🚫 | | | +| LR-SHARE-WEBGALLERY | Web galleries | OOS | 🚫 | | | +| LR-SHARE-COMMUNITY | Community edits | OOS | 🚫 | | | + +## U. Map & location (MAP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-MAP-INFO | Location in the info panel | P1 | 🟡 | `cmd:photo.setMeta` (`location`), `crates/ui-egui/src/panels/right.rs` | shown as text; not editable in the panel; no map | +| LR-MAP-MODULE | Map module [Classic] | P2 | ⬜ | | | + +## V. Preferences (PREF) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-PREF-GENERAL | General settings | P0 | ⬜ | `cmd:library.xmpPreferences` | no settings dialog; only XMP preferences (by command) | +| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | 🟡 | `cmd:library.clearPreviews`, `cmd:library.compact` | bounded thumbnail cache; no UI for size/location | +| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | +| LR-PREF-INTERFACE | Interface options | P1 | ⬜ | | | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | 🟡 | `cmd:app.gpu` | by command only | +| LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | +| LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference | +| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | +| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | | +| LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | | +| LR-PREF-DEVICE | Device settings | P2 | ⬜ | | | + +## W. Cloud & AI infrastructure (CLOUD / AI) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-CLOUD-SYNC | Cloud sync | OOS | 🚫 | | | +| LR-CLOUD-SMARTPREVIEW | Editable proxies | P2 | 🟡 | `crates/engine/src/media.rs` | preview-size proxies drive the loupe; editing needs the original | +| LR-AI-UPDATE-INDICATOR | AI-settings update indicator | P2 | ⬜ | | | +| LR-AI-CREDITS | Generative credits | OOS | 🚫 | | | + +## X. Cross-cutting behaviours (BEHAV) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | | +| LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits | +| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | | +| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | +| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ⬜ | | presets, profiles, versions | +| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | 🟡 | `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; no progress popover for import/export | +| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback | +| LR-BEHAV-DRAGDROP | Drag and drop | P1 | 🟡 | `crates/ui-egui/src/lib.rs` | files → app only; no photos → albums | +| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | +| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | +| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | +| LR-BEHAV-ACCESS | Accessibility | P2 | ⬜ | | unverified (screen-reader labels not audited) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | ⬜ | | | +| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | +| LR-BEHAV-WHATSNEW | What's new | P2 | ⬜ | | | +| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | + +## Y. Menus + +From `04-menu-tree.md`. ✅ = a command exists and is reachable from the UI (button, panel or shortcut). There is no +visible menu bar yet: the menu model is only exposed through the control channel (`ui.menu.list`). + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| MENU-BAR | Menu bar rendering | P1 | 🟡 | `crates/ui-egui/src/menus.rs` (`menu_entries`) | model only; no native macOS menu or in-window menu bar | +| MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | | +| MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL | +| MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | | +| MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | | +| MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | | +| MENU-APP-HIDE | Hide / hide others / show all | P1 | 🟡 | | platform window defaults (unverified) | +| MENU-APP-QUIT | Quit | P0 | 🟡 | | platform window defaults (unverified) | +| MENU-FILE-ADDPHOTOS | Add Photos… | P0 | ✅ | `cmd:file.addPhotos` | | +| MENU-FILE-ADDFOLDER | Add Folder… | P0 | 🟡 | `cmd:library.import` (folders, recursive) | no folder picker entry | +| MENU-FILE-MIGRATE | Migrate photos | OOS | 🚫 | | | +| MENU-FILE-NEWALBUM | New Album… | P0 | ✅ | `cmd:dialog.newAlbum` | | +| MENU-FILE-NEWFOLDER | New Folder… | P0 | ✅ | `cmd:dialog.newFolder` | ⇧⌘N | +| MENU-FILE-NEWSMART | New Smart Album… | P1 | ⬜ | | | +| MENU-FILE-IMPORTPROFILES | Import Profiles & Presets… | P1 | 🟡 | `cmd:file.importPresets` | presets only | +| MENU-FILE-EXPORT | Export… | P0 | ✅ | `cmd:dialog.export` | | +| MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | +| MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ⬜ | | | +| MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | +| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ⬜ | | | +| MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web | +| MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first | +| MENU-FILE-CLOSE | Close Window | P1 | 🟡 | | platform window defaults (unverified) | +| MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step | +| MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | | +| MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | +| MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | | +| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ⬜ | | | +| MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | | +| MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | +| MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ⬜ | | | +| MENU-EDIT-FIND | Find… | P0 | 🟡 | `crates/ui-egui/src/panels/topbar.rs` | search field; no command to focus it | +| MENU-VIEW-PHOTOGRID | Photo Grid | P0 | ✅ | `cmd:view.photoGrid` | | +| MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | | +| MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | | +| MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | | +| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ⬜ | | | +| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ⬜ | | | +| MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | `cmd:view.leftPanel` | | +| MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | +| MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | `cmd:panel.info` | | +| MENU-VIEW-KEYWORDS | Show/Hide keywords | P0 | ✅ | `cmd:panel.keywords` | | +| MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | | our History panel is `panel.activity` | +| MENU-VIEW-VERSIONS | Show/Hide versions | P1 | ✅ | `cmd:panel.versions` | | +| MENU-VIEW-HISTOGRAM | Show/Hide histogram | P0 | ✅ | `cmd:view.histogram` | | +| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ⬜ | | | +| MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | `cmd:view.showOriginal` | | +| MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | | +| MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | | +| MENU-VIEW-CLIPPING | Show Clipping | P0 | ✅ | `cmd:view.clipping` | | +| MENU-VIEW-MASKOVERLAY | Mask overlay / cycle colour | P0 | 🟡 | `cmd:view.maskOverlay` | no colour cycle | +| MENU-VIEW-INCLUDESUBFOLDERS | Include subfolders | P1 | 🟡 | `cmd:library.import` | folder import is always recursive | +| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort` | no colour-label key | +| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ⬜ | | | +| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | 🟡 | `crates/ui-egui/src/panels/left.rs` | counts always shown; no toggle | +| MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | +| MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | | +| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| MENU-PHOTO-RATE | Rate submenu | P0 | ✅ | `cmd:photo.rate` | | +| MENU-PHOTO-FLAG | Flag submenu | P0 | ✅ | `cmd:photo.flag` | | +| MENU-PHOTO-LABEL | Colour label submenu | P1 | 🟡 | `cmd:photo.label` | keys only; no menu, no label editing | +| MENU-PHOTO-ROTATE | Rotate left / right | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | +| MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | +| MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt | +| MENU-PHOTO-STACK | Stack submenu | P1 | ⬜ | | | +| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ⬜ | | | +| MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | | +| MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | | +| MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | `cmd:develop.treatment` | | +| MENU-PHOTO-RESET | Reset edits / crop | P0 | ✅ | `cmd:develop.reset`, `cmd:crop.reset` | | +| MENU-PHOTO-UPDATEAI | Update AI settings | P2 | ⬜ | | | +| MENU-PHOTO-RENAME | Rename N photos… | P1 | ⬜ | | | +| MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ⬜ | | | +| MENU-PHOTO-COVER | Set as album cover | P2 | ✅ | `cmd:album.setCover` | | +| MENU-PHOTO-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | no confirmation; static label | +| MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | | | +| MENU-WINDOW-MINIMIZE | Minimize / zoom | P1 | 🟡 | | platform window defaults (unverified) | +| MENU-WINDOW-PANELS | Panel switches | P0 | ✅ | `cmd:panel.edit`, `cmd:panel.crop`, `cmd:panel.remove`, `cmd:panel.masking`, `cmd:panel.presets`, `cmd:panel.versions` | | +| MENU-WINDOW-BRINGFRONT | Bring all to front | P2 | 🟡 | | platform window defaults (unverified) | +| MENU-HELP-HELP | Help | P2 | ⬜ | | | +| MENU-HELP-TUTORIALS | Tutorials | OOS | 🚫 | | | +| MENU-HELP-WHATSNEW | What's new | P2 | ⬜ | | | +| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | +| MENU-HELP-FEEDBACK | Send feedback | P2 | ⬜ | | | +| MENU-HELP-SYSINFO | System info | P2 | ⬜ | `cmd:library.info` | library info only | +| MENU-CTX-GRID | Photo context menu | P0 | 🟡 | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, add to album, copy/paste, reset, rotate, delete; no label, remove from album, export, reveal, version, cover | +| MENU-CTX-DETAIL | Loupe context menu | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | same as grid; no zoom submenu | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | 🟡 | `crates/ui-egui/src/panels/left.rs` | add selected, rename, delete; no move-to, export album | +| MENU-CTX-MASK | Mask / component menu | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` | add/subtract component; rename/duplicate by command only | +| MENU-CTX-PRESET | Preset menu | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs` | favourite, delete, export group; no rename/update/move | +| MENU-CTX-PROFILE | Profile favourites | P2 | ⬜ | | | +| MENU-CTX-VERSION | Version menu | P1 | ⬜ | | delete button only | +| MENU-CTX-KEYWORD | Keyword chip menu | P1 | 🟡 | `crates/ui-egui/src/panels/right.rs` | click removes; no rename/delete keyword | + +## Z. Keyboard shortcuts (desktop) + +From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained in +[Shortcuts: conflicts and missing bindings](#shortcuts-conflicts-and-missing-bindings). + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| KEY-CROP | Crop & rotate — C | P0 | ✅ | `cmd:panel.crop` | | +| KEY-DETAIL | Detail — D | P0 | ✅ | `cmd:view.detail` | | +| KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | | +| KEY-FULLSCREEN | Full-screen preview — F | P1 | ⬜ | | no command | +| KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.photoGrid` | | +| KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | | +| KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | | +| KEY-CLIPBOARD | Copy / paste edit settings — ⌘C / ⌘V | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | ⌘X has nothing to cut outside text fields | +| KEY-UNDOREDO | Undo / redo — ⌘Z / ⇧⌘Z | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | +| KEY-MINIMIZE | Minimize — ⌘M | P1 | 🟡 | | platform default (unverified) | +| KEY-AUTO | Auto — ⇧A | P0 | ✅ | `cmd:develop.auto` | | +| KEY-PHOTOSHOP | External editor — ⇧⌘E | P2 | ⬜ | | key used by our export dialog | +| KEY-ROTATE | Rotate — ⌘[ / ⌘] | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | +| KEY-ZOOM | Zoom in / out — ⌘= / ⌘− | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut` | | +| KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | | +| KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) | +| KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ⬜ | | no command | +| KEY-PREFS | Settings — ⌘, | P0 | ⬜ | | no command | +| KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command | +| KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | | +| KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | 🟡 | `cmd:view.maskOverlay`, `cmd:view.cropOverlay` | O toggles the mask overlay; crop overlays cycle on ⇧O | +| KEY-PHOTOSPANEL | Photos panel — P | P0 | 🟡 | `cmd:view.leftPanel` | bound to ⌘⇧L; P = pick | +| KEY-LINEAR | Linear gradient — L | P0 | ✅ | `cmd:tool.linear` | | +| KEY-RADIAL | Radial gradient — R | P0 | ✅ | `cmd:tool.radial` | | +| KEY-CLIPPING | Clipping — J | P0 | ✅ | `cmd:view.clipping` | | +| KEY-WB | White-balance selector — W | P0 | ✅ | `cmd:tool.wbPicker` | | +| KEY-FILMSTRIP | Filmstrip — / | P0 | ✅ | `cmd:view.filmstrip` | | +| KEY-SHOWORIGINAL | Show original — \ | P0 | ✅ | `cmd:view.showOriginal` | | +| KEY-TOGGLEZOOM | Toggle zoom — Space | P0 | ✅ | `cmd:view.zoomToggle` | Space is a secondary binding (primary Z) | +| KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ⬜ | | ⇧O cycles crop overlays | +| KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound | +| KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) | +| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | | +| KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ⬜ | | | +| KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid | +| KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) | +| KEY-QUIT | Quit — ⌘Q | P0 | 🟡 | | platform default (unverified) | +| KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | `cmd:version.create` | secondary binding (primary ⌘⇧S) | +| KEY-CLOSEWINDOW | Close window — ⌘W | P1 | 🟡 | | platform default (unverified) | +| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | | +| KEY-ADDPHOTOS | Add photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | +| KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | +| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | 🟡 | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | bound to ⌘⌥1–5 | +| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | | +| KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit | +| KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ⬜ | | brush size is UI state, no command | +| KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ⬜ | | | +| KEY-BRUSH | Brush — B | P0 | ✅ | `cmd:tool.brush` | | +| KEY-HEAL | Remove / heal — H | P0 | ✅ | `cmd:panel.remove` | | +| KEY-MERGE | HDR / panorama merges — ⌃H ⇧⌃H ⌃M ⇧⌃M | P2 | ⬜ | | | +| KEY-PICK | Pick — Z | P0 | 🟡 | `cmd:photo.pick` | bound to P; Z toggles zoom | +| KEY-UNFLAG | Unflag — U | P0 | ✅ | `cmd:photo.unflag` | | +| KEY-REJECT | Reject — X | P0 | ✅ | `cmd:photo.reject` | swaps crop aspect while cropping | +| KEY-RATING | Ratings — 0…5 | P0 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` | | +| KEY-LABELS | Labels — 6…9 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.label` | | +| KEY-MASKING | Masking — M | P0 | ✅ | `cmd:panel.masking` | | +| KEY-ERASE | Erase while held — ⌥ | P0 | 🟡 | `crates/ui-egui/src/panels/masking.rs` | Add/Erase toggle buttons; hold-to-erase unverified | +| KEY-RATEADVANCE | Rate and advance — ⇧0…5 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` (`advance`) | | +| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | 🟡 | `cmd:photo.flag` (`advance`) | ⇧X and ⇧U bound; pick-and-advance has no key (⇧P = presets) | +| KEY-NEXTPREV | Next / previous — → / ← | P0 | ✅ | `cmd:library.next`, `cmd:library.previous` | | +| KEY-BA-CYCLE | Before/after — Y | P0 | ✅ | `cmd:view.beforeAfter` | toggles side by side (no cycling) | +| KEY-BA-TOPBOTTOM | Before/after top/bottom — ⌥Y | P1 | ✅ | `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | ⌥Y, ⇧⌥Y | +| KEY-BA-SPLIT | Split before/after — ⇧Y | P0 | ✅ | `cmd:view.beforeAfterSplit` | | +| KEY-CHOOSECOPY | Choose settings to copy — ⇧⌘C | P0 | ✅ | `cmd:dialog.copySettings` | | +| KEY-RESETALL | Reset all — ⇧⌘R | P0 | ✅ | `cmd:develop.reset` | | +| KEY-GENAI | Generative toggles / variations — ⌥⇧G, ⌥←/→ | OOS | 🚫 | | | +| KEY-DETECT | Detect objects — ⌥⇧O | P2 | ⬜ | | | +| KEY-CROP-CONSTRAIN | Lock crop aspect — A | P1 | ⬜ | | | +| KEY-CROP-SWAP | Swap crop orientation — X | P0 | ✅ | `cmd:crop.rotateAspect` | | +| KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ⬜ | | | +| KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | +| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ⬜ | | | +| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | +| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | 🟡 | `cmd:develop.adjust` | command exists; keyboard nudging unverified | +| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | +| KEY-HELP | Help — F1 | P2 | ⬜ | | | +| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | +| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back` | | +| KEY-COMMIT | Commit tool — Return | P1 | 🟡 | | edits apply live; no explicit commit step | +| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | 🟡 | `cmd:mask.delete` | ⌫ deletes the active mask in the Masking panel and never the photo while retouching; no spot pin selection yet | +| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel | + +## Lightroom Classic extras + +From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-shortcuts.md` (Classic keys, grouped). + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LRC-LIB-IMPORT | Full import dialog | P1 | 🟡 | `cmd:library.import` (`mode: copy`) | copy/add + duplicate skip; no file renaming, apply-during-import, destination organising, import presets | +| LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ⬜ | | | +| LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | +| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.detail` | no compare, survey, people; no grid cell styles beyond filenames | +| LRC-LIB-COMPARE | Compare view | P1 | ⬜ | | | +| LRC-LIB-SURVEY | Survey view | P2 | ⬜ | | | +| LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | +| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | 🟡 | `cmd:library.source` | all, recently added, picks, recently deleted; no missing/problem sets | +| LRC-LIB-FOLDERS | Disk folder tree | P1 | ⬜ | | | +| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ⬜ | `crates/catalog/src/query.rs` | fielded search tokens are a start | +| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | +| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:library.filter` | engine filter + text tokens; no filter bar, presets, lock | +| LRC-LIB-STACKS | Stacks (full) | P1 | ⬜ | | | +| LRC-LIB-VC | Virtual copies | P1 | ⬜ | | | +| LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ⬜ | | flat keywords only (LR-LIB-KEYWORD) | +| LRC-LIB-METADATA | Metadata panel & presets | P1 | 🟡 | `cmd:photo.setMeta`, `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile` | no metadata presets, capture-time edit, copyright status | +| LRC-LIB-QUICKDEV | Quick develop | P2 | ⬜ | | | +| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | +| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | +| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ⬜ | | | +| LRC-LIB-MISSING | Missing files & relink | P1 | ⬜ | | | +| LRC-LIB-CONVERT | Convert to DNG | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | writer exists, not exposed | +| LRC-LIB-PREVIEWS | Build / discard previews | P1 | 🟡 | `cmd:library.clearPreviews`, `crates/preview/src/lib.rs` | disk thumbnail cache; no build-1:1 / smart previews | +| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ⬜ | | | +| LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | +| LRC-DEV-HISTORY | Full history panel | P1 | 🟡 | `cmd:history.list`, `cmd:history.restore` | no clear, no snapshot-from-step | +| LRC-DEV-SOFTPROOF | Soft proofing | P2 | ⬜ | | | +| LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | 🟡 | `cmd:develop.sync` | no auto sync or paste-from-previous | +| LRC-DEV-MATCHEXP | Match total exposures | P2 | ⬜ | | | +| LRC-DEV-CALIB | Calibration panel | P1 | ⬜ | | | +| LRC-DEV-TAT | Targeted adjustment tools | P1 | ⬜ | | | +| LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ⬜ | | | +| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | +| LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | +| LRC-MAP-VIEW | Map view | P2 | ⬜ | | | +| LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | +| LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | +| LRC-MAP-TRACKLOG | GPS track logs | P2 | ⬜ | | | +| LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | | +| LRC-MAP-REVGEO | Reverse geocoding | OOS | 🚫 | | | +| LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | | +| LRC-BOOK-AUTOLAYOUT | Book auto layout | P2 | ⬜ | | | +| LRC-BOOK-PAGE | Book pages & templates | P2 | ⬜ | | | +| LRC-BOOK-GUIDES | Book guides | P2 | ⬜ | | | +| LRC-BOOK-CELL | Book cell padding | P2 | ⬜ | | | +| LRC-BOOK-TEXT | Book photo/page text | P2 | ⬜ | | | +| LRC-BOOK-TYPE | Book typography | P2 | ⬜ | | | +| LRC-BOOK-BG | Book backgrounds | P2 | ⬜ | | | +| LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | | +| LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | | +| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | +| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | | | +| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | | | +| LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | | +| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | | +| LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | +| LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | +| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ⬜ | | | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | +| LRC-PRINT-LAYOUT | Print layout | P2 | ⬜ | | | +| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | | | +| LRC-PRINT-PAGE | Print page options | P2 | ⬜ | | | +| LRC-PRINT-JOB | Print job & colour management | P2 | ⬜ | | | +| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | +| LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | +| LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | +| LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | +| LRC-WEB-APPEARANCE | Web gallery appearance | OOS | 🚫 | | | +| LRC-WEB-IMAGEINFO | Web gallery image info | OOS | 🚫 | | | +| LRC-WEB-OUTPUT | Web gallery output | OOS | 🚫 | | | +| LRC-WEB-UPLOAD | Web gallery upload | OOS | 🚫 | | | +| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | | +| KEYC-MODULES | Classic module switching (⌘⌥1–7) | P2 | 🚫 | | no modules in LightCraft | +| KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | +| KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | +| KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | 🟡 | `cmd:library.import`, `cmd:photo.delete`, `cmd:dialog.export` | import, delete, export work; no virtual copy, reveal, rename | +| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | most work; no ⇧P / ⇧6–9 advance, rating `[` `]`, flag cycle, filter-bar keys | +| KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | +| KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | +| KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | +| KEYC-MODULE-OUTPUT | Book / slideshow / print / map / web keys | P2 | ⬜ | | modules not implemented | +| KEYC-HELP | Classic help keys (⌘/, F1) | P2 | 🟡 | `cmd:app.shortcuts` | ⌘/ only | diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs new file mode 100644 index 000000000..7f94f37fb --- /dev/null +++ b/xtask/src/bench.rs @@ -0,0 +1,160 @@ +//! `cargo xtask bench [FILE] [--strict] [--threshold PCT]`: run the end-to-end render benchmark, record +//! the results in `target/bench/history.jsonl` (one JSON object per run, with git revision and load +//! average) and compare with the previous run. +//! +//! Comparisons use **process CPU time**, which reflects the work done even on a busy shared machine; +//! wall-clock is recorded for reference. `--strict` exits non-zero when a CPU metric regressed by more +//! than the threshold (default 20 %). + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Command; + +/// Parse `render_bench` output lines such as +/// `loupe draft 1152×768 cold: cpu: 379.5 ms wall, 139.5 ms cpu | gpu: 19.9 ms wall, 17.2 ms cpu` +/// or `export JPEG encode: 63.9 ms wall, 278.0 ms cpu` into `name/backend/{wall,cpu}` → ms. +pub fn parse(output: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + for line in output.lines() { + let Some((name, rest)) = line.split_once(':') else { continue }; + if !rest.contains(" ms ") { + continue; + } + let name = name.trim(); + for seg in rest.split('|') { + let seg = seg.trim(); + let (backend, body) = match seg.split_once(':') { + Some((b, body)) if matches!(b.trim(), "cpu" | "gpu") => (b.trim(), body), + _ => ("cpu", seg), + }; + for part in body.split(',') { + let toks: Vec<&str> = part.split_whitespace().collect(); + if let [v, "ms", kind] = toks[..] + && let Ok(v) = v.parse::() + && matches!(kind, "wall" | "cpu") + { + out.insert(format!("{name}/{backend}/{kind}"), v); + } + } + } + } + out +} + +/// CPU-time metrics that got slower than `threshold` (fraction) vs `prev`: (key, prev, now). +pub fn regressions(prev: &BTreeMap, now: &BTreeMap, threshold: f64) -> Vec<(String, f64, f64)> { + now.iter() + .filter(|(k, _)| k.ends_with("/cpu")) + .filter_map(|(k, &v)| prev.get(k).map(|&p| (k.clone(), p, v))) + .filter(|(_, p, v)| *p > 1.0 && *v > p * (1.0 + threshold)) + .collect() +} + +fn load_avg() -> Option { + let out = Command::new("uptime").output().ok()?; + let s = String::from_utf8_lossy(&out.stdout); + let tail = s.rsplit_once("load average")?.1; + tail.trim_start_matches(['s', ':', ' ']).split([',', ' ']).find(|t| !t.is_empty())?.parse().ok() +} + +fn git_rev(root: &Path) -> String { + Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .current_dir(root) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + +pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { + let strict = args.contains(&"--strict"); + let threshold = + args.iter().position(|a| *a == "--threshold").and_then(|i| args.get(i + 1)).and_then(|v| v.parse::().ok()).unwrap_or(20.0) / 100.0; + let file = args.iter().find(|a| !a.starts_with("--") && a.parse::().is_err()).map(|s| s.to_string()); + let file = file.or_else(|| { + let default = root.join("corpus/raw/arw-sony-a7m3-compressed.arw"); + default.exists().then(|| default.to_string_lossy().to_string()) + }); + let mut cmd = crate::cargo(); + cmd.args(["run", "--release", "-q", "-p", "lightcraft-engine", "--example", "render_bench", "--"]); + if let Some(f) = &file { + cmd.arg(f); + } + eprintln!("$ cargo run --release -p lightcraft-engine --example render_bench -- {}", file.as_deref().unwrap_or("(procedural 24 MP source)")); + let out = cmd.output().map_err(|e| format!("render_bench: {e}"))?; + let text = String::from_utf8_lossy(&out.stdout).to_string(); + print!("{text}"); + if !out.status.success() { + return Err(format!("render_bench failed:\n{}", String::from_utf8_lossy(&out.stderr))); + } + let metrics = parse(&text); + if metrics.is_empty() { + return Err("no metrics parsed from render_bench output".into()); + } + let dir = root.join("target/bench"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let hist = dir.join("history.jsonl"); + let prev: Option> = std::fs::read_to_string(&hist) + .ok() + .and_then(|s| s.lines().rev().find(|l| !l.trim().is_empty()).map(str::to_string)) + .and_then(|l| serde_json::from_str::(&l).ok()) + .and_then(|v| serde_json::from_value(v["metrics"].clone()).ok()); + let rec = serde_json::json!({ + "rev": git_rev(root), + "time": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0), + "load": load_avg(), + "input": file, + "metrics": metrics, + }); + let mut line = rec.to_string(); + line.push('\n'); + use std::io::Write; + std::fs::OpenOptions::new().create(true).append(true).open(&hist).and_then(|mut f| f.write_all(line.as_bytes())).map_err(|e| e.to_string())?; + eprintln!("bench: {} metrics recorded in {}", metrics.len(), hist.display()); + let Some(prev) = prev else { + eprintln!("bench: first run — nothing to compare"); + return Ok(()); + }; + let reg = regressions(&prev, &metrics, threshold); + if reg.is_empty() { + eprintln!("bench: no CPU-time regressions > {:.0}% vs previous run", threshold * 100.0); + return Ok(()); + } + for (k, p, v) in ® { + eprintln!("bench: REGRESSION {k}: {p:.1} → {v:.1} ms (+{:.0}%)", (v / p - 1.0) * 100.0); + } + if strict { Err(format!("{} CPU-time regression(s)", reg.len())) } else { Ok(()) } +} + +#[cfg(test)] +mod tests { + use super::*; + + const OUT: &str = "gpu: Apple M4 Pro (Metal) (device + kernels: 322 ms) +gpu vs cpu, loupe 1920×1280 typical: max 1 LSB, mean 0.0001 LSB +loupe draft 1152×768 cold: cpu: 379.5 ms wall, 139.5 ms cpu | gpu: 19.9 ms wall, 17.2 ms cpu +export JPEG encode: 63.9 ms wall, 278.0 ms cpu +"; + + #[test] + fn parses_cpu_gpu_and_plain_lines() { + let m = parse(OUT); + assert_eq!(m["loupe draft 1152×768 cold/cpu/wall"], 379.5); + assert_eq!(m["loupe draft 1152×768 cold/gpu/cpu"], 17.2); + assert_eq!(m["export JPEG encode/cpu/cpu"], 278.0); + assert_eq!(m.len(), 6, "{m:?}"); + } + + #[test] + fn flags_only_cpu_time_regressions_over_threshold() { + let prev = parse(OUT); + let mut now = prev.clone(); + *now.get_mut("export JPEG encode/cpu/cpu").unwrap() = 400.0; // +44 % + *now.get_mut("export JPEG encode/cpu/wall").unwrap() = 400.0; // wall ignored + *now.get_mut("loupe draft 1152×768 cold/gpu/cpu").unwrap() = 18.0; // +5 % + let r = regressions(&prev, &now, 0.2); + assert_eq!(r.len(), 1); + assert_eq!(r[0].0, "export JPEG encode/cpu/cpu"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c44460cfe..268ba72ee 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,7 +4,9 @@ //! invoked through `std::process::Command`. mod assets; +mod bench; mod layers; +mod parity; mod stats; mod web; @@ -16,12 +18,18 @@ usage: cargo xtask commands: assets every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets + bench [FILE] [--strict] [--threshold PCT] + run the render benchmark, append to target/bench/history.jsonl, compare CPU time with + the previous run (default input: corpus/raw/arw-sony-a7m3-compressed.arw) layers enforce the crate dependency layering (plan/architecture.md §3) + parity [--write] + check docs/parity.md (every cmd:/ctl: id and path it cites exists) and print the + Lightroom parity summary; --write refreshes the summary table in the document wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app) web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into /web/; --serve serves it on http://127.0.0.1: (default 8080) - ci fmt --check, clippy -D warnings, test, layers, assets, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, parity refs, layers, assets, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -33,6 +41,8 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), + Some("bench") => bench::run(&root(), &rest), + Some("parity") => parity::run(&root(), rest.contains(&"--write")), Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), Some("ci") => cmd_ci(), @@ -172,6 +182,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test --workspace") }), ), + ("parity", Box::new(|| parity::run(&root(), false))), ("layers", Box::new(cmd_layers)), ("assets", Box::new(|| assets::run(&root()))), ("wasm", Box::new(cmd_wasm)), diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs new file mode 100644 index 000000000..651f3372e --- /dev/null +++ b/xtask/src/parity.rs @@ -0,0 +1,411 @@ +//! `cargo xtask parity`: the Lightroom parity tracker (`docs/parity.md`). +//! +//! - **Reference check** (in `ci`): every `` `cmd:` `` must be a registered command (engine command specs, as +//! listed by `lightcraft-cli commands --json`, or a UI command from `UI_COMMANDS`), every `` `ctl:` `` a develop +//! control (`lightcraft-cli controls --json`), and every repository path in backticks must exist (with `:line` +//! inside the file). A trailing `*` matches an id prefix. Row ids must be unique and statuses valid. +//! - **Summary**: per section done / partial / missing / out of scope and the share of P0 and P1 rows done; +//! `--write` refreshes the table between the `parity:summary` markers in the document. + +use std::collections::{BTreeSet, HashSet}; +use std::path::Path; + +pub const DOC: &str = "docs/parity.md"; +const BEGIN: &str = ""; +const END: &str = ""; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Done, + Partial, + Missing, + OutOfScope, +} + +impl Status { + fn parse(cell: &str) -> Option { + let c = cell.trim_start(); + [("✅", Status::Done), ("🟡", Status::Partial), ("⬜", Status::Missing), ("🚫", Status::OutOfScope)] + .into_iter() + .find(|(e, _)| c.starts_with(e)) + .map(|(_, s)| s) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Ref { + Cmd(String), + Ctl(String), + /// Repository-relative path, optional 1-based line. + Path(String, Option), +} + +#[derive(Clone, Debug)] +pub struct Row { + pub id: String, + pub tier: String, + pub status: Status, + pub section: String, +} + +#[derive(Debug, Default)] +pub struct Doc { + pub rows: Vec, + /// (line, reference) for every reference anywhere in the document. + pub refs: Vec<(usize, Ref)>, + pub errors: Vec, +} + +fn is_row_id(s: &str) -> bool { + let Some((prefix, rest)) = s.split_once('-') else { return false }; + matches!(prefix, "LR" | "LRC" | "MENU" | "KEY" | "KEYC") + && !rest.is_empty() + && rest.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') +} + +/// Directories whose paths are checked (others, e.g. the local-only `plan/`, are ignored). +const CHECKED_DIRS: &[&str] = &["crates/", "apps/", "docs/", "xtask/", "assets/"]; + +/// A concrete id (not a placeholder such as `cmd:` in prose). +fn concrete(id: &str) -> bool { + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '*')) +} + +fn classify(span: &str) -> Option { + if let Some(id) = span.strip_prefix("cmd:").filter(|id| concrete(id)) { + return Some(Ref::Cmd(id.to_string())); + } + if let Some(id) = span.strip_prefix("ctl:").filter(|id| concrete(id)) { + return Some(Ref::Ctl(id.to_string())); + } + if CHECKED_DIRS.iter().any(|d| span.starts_with(d)) && !span.contains(' ') { + return Some(match span.rsplit_once(':') { + Some((p, l)) if l.parse::().is_ok() => Ref::Path(p.to_string(), l.parse().ok()), + _ => Ref::Path(span.to_string(), None), + }); + } + None +} + +/// Inline-code spans of one line (single backticks). +fn code_spans(line: &str) -> Vec<&str> { + line.split('`').skip(1).step_by(2).collect() +} + +pub fn parse(md: &str) -> Doc { + let mut doc = Doc::default(); + let mut section = String::new(); + let mut seen = HashSet::new(); + let mut in_comment = false; + for (i, line) in md.lines().enumerate() { + let n = i + 1; + let t = line.trim(); + if t.starts_with("") { + in_comment = true; + } + if in_comment { + in_comment = !t.contains("-->"); + continue; + } + if let Some(h) = t.strip_prefix("## ") { + section = h.trim().to_string(); + continue; + } + for span in code_spans(line) { + if let Some(r) = classify(span) { + doc.refs.push((n, r)); + } + } + if !t.starts_with('|') { + continue; + } + let cells: Vec<&str> = t.trim_matches('|').split('|').map(str::trim).collect(); + let Some(id) = cells.first().copied().filter(|c| is_row_id(c)) else { continue }; + if cells.len() < 6 { + doc.errors.push(format!("{DOC}:{n}: {id}: expected 6 columns (Id | Feature | Tier | Status | Evidence | Notes)")); + continue; + } + let Some(status) = Status::parse(cells[3]) else { + doc.errors.push(format!("{DOC}:{n}: {id}: status `{}` is not one of ✅ 🟡 ⬜ 🚫", cells[3])); + continue; + }; + if !seen.insert(id.to_string()) { + doc.errors.push(format!("{DOC}:{n}: duplicate id {id}")); + } + doc.rows.push(Row { id: id.to_string(), tier: cells[2].to_string(), status, section: section.clone() }); + } + doc +} + +/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines). +pub fn ui_command_ids(menus_rs: &str) -> Vec { + let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() }; + let body = &menus_rs[start..]; + let body = &body[..body.find("];").unwrap_or(body.len())]; + body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect() +} + +fn known(set: &BTreeSet, id: &str) -> bool { + match id.strip_suffix('*') { + Some(prefix) => set.iter().any(|s| s.starts_with(prefix)), + None => set.contains(id), + } +} + +/// Reference problems (empty = OK). `file_lines` returns a repository file's line count (None = missing). +pub fn check(doc: &Doc, commands: &BTreeSet, controls: &BTreeSet, file_lines: impl Fn(&str) -> Option) -> Vec { + let mut out = doc.errors.clone(); + for (n, r) in &doc.refs { + match r { + Ref::Cmd(id) if !known(commands, id) => out.push(format!("{DOC}:{n}: unknown command `cmd:{id}`")), + Ref::Ctl(id) if !known(controls, id) => out.push(format!("{DOC}:{n}: unknown develop control `ctl:{id}`")), + Ref::Path(p, line) => match (file_lines(p), line) { + (None, _) => out.push(format!("{DOC}:{n}: missing path `{p}`")), + (Some(len), Some(l)) if *l == 0 || *l > len => out.push(format!("{DOC}:{n}: `{p}:{l}` is past the end ({len} lines)")), + _ => {} + }, + _ => {} + } + } + out +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Counts { + pub done: usize, + pub partial: usize, + pub missing: usize, + pub oos: usize, + pub p0: (usize, usize), + pub p1: (usize, usize), +} + +impl Counts { + fn add(&mut self, r: &Row) { + match r.status { + Status::Done => self.done += 1, + Status::Partial => self.partial += 1, + Status::Missing => self.missing += 1, + Status::OutOfScope => self.oos += 1, + } + let done = usize::from(r.status == Status::Done); + match r.tier.as_str() { + "P0" => self.p0 = (self.p0.0 + done, self.p0.1 + 1), + "P1" => self.p1 = (self.p1.0 + done, self.p1.1 + 1), + _ => {} + } + } +} + +/// Per-section counts in document order, plus the total. +pub fn summarize(doc: &Doc) -> (Vec<(String, Counts)>, Counts) { + let mut sections: Vec<(String, Counts)> = Vec::new(); + let mut total = Counts::default(); + for r in &doc.rows { + if sections.last().is_none_or(|(s, _)| *s != r.section) { + sections.push((r.section.clone(), Counts::default())); + } + sections.last_mut().expect("pushed").1.add(r); + total.add(r); + } + (sections, total) +} + +fn pct((done, n): (usize, usize)) -> String { + match (done * 100 + n / 2).checked_div(n) { + Some(p) => format!("{done}/{n} ({p}%)"), + None => "—".into(), + } +} + +pub fn summary_table(doc: &Doc) -> String { + let (sections, total) = summarize(doc); + let mut s = String::from("| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |\n|---|---:|---:|---:|---:|---:|---:|\n"); + let row = + |name: &str, c: &Counts| format!("| {name} | {} | {} | {} | {} | {} | {} |\n", c.done, c.partial, c.missing, c.oos, pct(c.p0), pct(c.p1)); + for (name, c) in §ions { + s += &row(name, c); + } + s += &row("**Total**", &total); + s +} + +/// Replace the summary between the markers. +pub fn with_summary(md: &str, table: &str) -> Option { + let b = md.find(BEGIN)? + BEGIN.len(); + let e = md[b..].find(END)? + b; + Some(format!("{}\n{}{}", &md[..b], table, &md[e..])) +} + +fn registry(what: &str) -> Result, String> { + let out = crate::cargo() + .args(["run", "-q", "-p", "lightcraft-cli", "--", what, "--json"]) + .output() + .map_err(|e| format!("lightcraft-cli {what}: {e}"))?; + if !out.status.success() { + return Err(format!("lightcraft-cli {what} --json failed:\n{}", String::from_utf8_lossy(&out.stderr))); + } + let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("lightcraft-cli {what}: bad JSON: {e}"))?; + Ok(v.as_array().into_iter().flatten().filter_map(|c| c["id"].as_str().map(str::to_string)).collect()) +} + +pub fn run(root: &Path, write: bool) -> Result<(), String> { + let path = root.join(DOC); + let md = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let doc = parse(&md); + let menus = std::fs::read_to_string(root.join("crates/ui-egui/src/menus.rs")).map_err(|e| format!("menus.rs: {e}"))?; + let mut commands = registry("commands")?; + commands.extend(ui_command_ids(&menus)); + let controls = registry("controls")?; + let problems = check(&doc, &commands, &controls, |p| { + let full = root.join(p); + if full.is_dir() { Some(0) } else { std::fs::read_to_string(full).ok().map(|s| s.lines().count()) } + }); + + let table = summary_table(&doc); + println!("Lightroom parity ({DOC}): {} rows, {} references\n", doc.rows.len(), doc.refs.len()); + print!("{table}"); + if write { + let new = with_summary(&md, &table).ok_or_else(|| format!("{DOC}: summary markers `{BEGIN}` … `{END}` not found"))?; + if new != md { + std::fs::write(&path, new).map_err(|e| format!("{}: {e}", path.display()))?; + println!("\nupdated the summary in {DOC}"); + } + } else if with_summary(&md, &table).is_some_and(|new| new != md) { + println!("\nnote: the summary in {DOC} is stale — run `cargo xtask parity --write`"); + } + // Ids from the local Lightroom reference that have no row yet (plan/ is local-only, so only a warning). + let catalog = root.join("plan/lightroom/03-feature-catalog.md"); + if let Ok(cat) = std::fs::read_to_string(&catalog) { + let have: HashSet<&str> = doc.rows.iter().map(|r| r.id.as_str()).collect(); + let missing: Vec<&str> = cat + .lines() + .filter_map(|l| l.trim().strip_prefix("| ")?.split(['|', ' ']).next()) + .filter(|id| id.starts_with("LR-") && !have.contains(id)) + .collect(); + if !missing.is_empty() { + println!("\nwarning: catalog ids without a row: {}", missing.join(", ")); + } + } + if problems.is_empty() { + println!("\nOK: every command, control and path referenced in {DOC} exists."); + Ok(()) + } else { + println!(); + for p in &problems { + println!(" - {p}"); + } + Err(format!("{} stale reference(s) in {DOC}", problems.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# Tracker + +Prose mentioning `cmd:edit.undo`, placeholders `cmd:` / `ctl:` and `plan/lightroom/03.md` (not checked) and `rating:3`. + + +old + + + + +## A. Import (IMP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-IMP-ONE | One | P0 | ✅ | `cmd:library.import`, `crates/engine/src/import.rs:12` | | +| LR-IMP-TWO | Two | P1 | 🟡 | `ctl:light.*` | partial | +| LR-IMP-THREE | Three | OOS | 🚫 | | | + +| Action | Ours | Theirs | +|---|---|---| +| Pick flag | P | Z | + +## B. Library (LIB) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-LIB-ONE | One | P0 | ⬜ | `cmd:album.nope` | | +| LR-LIB-ONE | Dup | P1 | done | | | +| KEY-SHORT | too | few | +"; + + fn set(ids: &[&str]) -> BTreeSet { + ids.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn parses_rows_sections_and_statuses() { + let d = parse(SAMPLE); + let ids: Vec<&str> = d.rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, ["LR-IMP-ONE", "LR-IMP-TWO", "LR-IMP-THREE", "LR-LIB-ONE"]); + assert_eq!(d.rows[0].section, "A. Import (IMP)"); + assert_eq!(d.rows[3].section, "B. Library (LIB)"); + assert_eq!(d.rows[1].status, Status::Partial); + assert_eq!(d.rows[2].status, Status::OutOfScope); + assert_eq!(d.rows[1].tier, "P1"); + // the bad-status duplicate and the short row are errors; the comment row is ignored + assert_eq!(d.errors.len(), 2, "{:?}", d.errors); + assert!(d.errors.iter().any(|e| e.contains("status `done`"))); + assert!(d.errors.iter().any(|e| e.contains("KEY-SHORT"))); + } + + #[test] + fn collects_references_everywhere() { + let d = parse(SAMPLE); + let refs: Vec<&Ref> = d.refs.iter().map(|(_, r)| r).collect(); + assert!(refs.contains(&&Ref::Cmd("edit.undo".into()))); + assert!(refs.contains(&&Ref::Path("crates/engine/src/import.rs".into(), Some(12)))); + assert!(refs.contains(&&Ref::Ctl("light.*".into()))); + assert!(!refs.iter().any(|r| matches!(r, Ref::Cmd(id) if id == "nope")), "comment rows are skipped"); + assert!(!refs.iter().any(|r| matches!(r, Ref::Path(p, _) if p.starts_with("plan/")))); + assert_eq!(refs.len(), 5); + } + + #[test] + fn check_reports_unknown_ids_and_paths() { + let d = parse(SAMPLE); + let cmds = set(&["edit.undo", "library.import"]); + let ctls = set(&["light.exposure"]); + let problems = check(&d, &cmds, &ctls, |p| (p == "crates/engine/src/import.rs").then_some(100)); + let unknown: Vec<&String> = problems.iter().filter(|p| p.contains("unknown")).collect(); + assert_eq!(unknown.len(), 1, "{problems:?}"); + assert!(unknown[0].contains("cmd:album.nope")); + // a line past the end of the file is stale too + let short = check(&d, &cmds, &ctls, |_| Some(5)); + assert!(short.iter().any(|p| p.contains("import.rs:12"))); + // missing files and unknown controls + let none = check(&d, &cmds, &set(&[]), |_| None); + assert!(none.iter().any(|p| p.contains("missing path"))); + assert!(none.iter().any(|p| p.contains("ctl:light.*"))); + } + + #[test] + fn summary_counts_and_rewrites_between_markers() { + let d = parse(SAMPLE); + let (sections, total) = summarize(&d); + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].1, Counts { done: 1, partial: 1, missing: 0, oos: 1, p0: (1, 1), p1: (0, 1) }); + assert_eq!(total.p0, (1, 2)); + let table = summary_table(&d); + assert!(table.contains("| A. Import (IMP) | 1 | 1 | 0 | 1 | 1/1 (100%) | 0/1 (0%) |"), "{table}"); + assert!(table.contains("| **Total** | 1 | 1 | 1 | 1 | 1/2 (50%) | 0/1 (0%) |"), "{table}"); + let new = with_summary(SAMPLE, &table).unwrap(); + assert!(!new.contains("\nold\n")); + assert!(new.contains(&format!("{BEGIN}\n{table}{END}"))); + assert_eq!(with_summary(&new, &table).unwrap(), new, "idempotent"); + } + + #[test] + fn reads_ui_command_ids() { + let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }"; + assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]); + } +}