diff --git a/AGENTS.md b/AGENTS.md index f46b6a188..062db7b60 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). @@ -41,6 +42,9 @@ 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`). +- **Never send OS-level synthetic input** (osascript/System Events keystrokes or clicks, `cliclick`, accessibility + automation): it goes to whatever window is frontmost — the user's terminal or other apps. Drive LightCraft only + through its control channel (`ui.key`, `ui.pointer`, `ui.clickWidget`, `ui.menu.invoke`) or headless snapshots. - 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, @@ -69,4 +73,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 f46b6a188..062db7b60 100644 --- a/CLAUDE.md +++ b/CLAUDE.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). @@ -41,6 +42,9 @@ 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`). +- **Never send OS-level synthetic input** (osascript/System Events keystrokes or clicks, `cliclick`, accessibility + automation): it goes to whatever window is frontmost — the user's terminal or other apps. Drive LightCraft only + through its control channel (`ui.key`, `ui.pointer`, `ui.clickWidget`, `ui.menu.invoke`) or headless snapshots. - 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, @@ -69,4 +73,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/Cargo.lock b/Cargo.lock index fa371fe28..40697205b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,6 +877,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1973,6 +1982,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2063,6 +2082,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-ui-egui", "log", + "muda", "rfd", "serde_json", ] @@ -2088,6 +2108,7 @@ dependencies = [ "lightcraft-codecs", "lightcraft-engine", "lightcraft-mcp", + "lightcraft-merge", "lightcraft-raster", "lightcraft-ui-egui", "serde_json", @@ -2150,6 +2171,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-geom", "lightcraft-gpu", + "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", @@ -2198,6 +2220,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lightcraft-merge" +version = "0.1.0" +dependencies = [ + "lightcraft-codecs", + "lightcraft-color", + "lightcraft-geom", + "lightcraft-meta", + "lightcraft-raster", + "lightcraft-raw", + "lightcraft-scenes", + "rayon", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "lightcraft-meta" version = "0.1.0" @@ -2452,6 +2490,25 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" diff --git a/Cargo.toml b/Cargo.toml index e7287c0b7..bdadda7b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ lightcraft-pipeline = { path = "crates/pipeline" } lightcraft-gpu = { path = "crates/gpu" } lightcraft-catalog = { path = "crates/catalog" } lightcraft-preview = { path = "crates/preview" } +lightcraft-merge = { path = "crates/merge" } lightcraft-engine = { path = "crates/engine" } lightcraft-ui-egui = { path = "crates/ui-egui" } lightcraft-mcp = { path = "crates/mcp" } @@ -69,6 +70,7 @@ egui_extras = { version = "0.36", default-features = false } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" +muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) web-time = "1" # web (apps/lightcraft-web) diff --git a/README.md b/README.md index 252b777e6..3a48e4787 100644 --- a/README.md +++ b/README.md @@ -218,24 +218,28 @@ 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 | |---|---| -| Library: albums, folders, ratings, flags, labels, search, sort, grids, filmstrip | ✅ | -| Light, Color, Effects, Tone Curve, Color Mixer, Color Grading, B&W | ✅ | +| Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ | +| Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ | +| Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ | | Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | -| Crop, straighten, flip, rotate, aspect ratios, overlays | ✅ | +| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ | | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome — our own looks), presets, versions, history, copy/paste/sync settings | ✅ | -| Control channel + every widget addressable | ✅ | +| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | | RAW: DNG, CR2, ARW, NEF (uncompressed), Fujifilm RAF (Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF; embedded previews for every format incl. CR3 | ✅ · compressed NEF/RAF/ORF, CR3 decode 🚧 | | Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | -| Remove / Heal / Clone spots (auto source) | ✅ · content-aware fill (PatchMatch), Red Eye 🚧 | +| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | | Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch, metadata policy, text watermark | ✅ · DNG export, image watermark ⬜ | | Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ | | MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ | | XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ | | Optics (distortion, vignetting, auto + manual CA, defringe, DNG-embedded lens corrections), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | +| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255) with CPU fallback | ✅ · WebGPU in the browser 🚧 | | Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 | diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml index 3770a91b2..36c37c32e 100644 --- a/apps/lightcraft-cli/Cargo.toml +++ b/apps/lightcraft-cli/Cargo.toml @@ -19,6 +19,7 @@ lightcraft-mcp = { workspace = true } lightcraft-ui-egui = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raster = { workspace = true } +lightcraft-merge = { workspace = true } serde_json = { workspace = true } dhat = { workspace = true, optional = true } diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 2988c3278..28c88911f 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -4,6 +4,8 @@ //! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] //! lightcraft-cli render -o [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] //! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] +//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… +//! lightcraft-cli synth-merge hdr|panorama -o DIR //! lightcraft-cli commands [--json] //! lightcraft-cli controls [--json] //! ``` @@ -48,6 +50,15 @@ USAGE: -o, --output OUT PNG path (a final screenshot is written here if the script took none) --size WxH window size in points (default 1600x1000) --scale S pixels per point (default 1) + lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… + Photo Merge: writes -HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and + prints the result as JSON. Options: + --deghost none|low|medium|high --no-align --bracket N (HDR panorama) + --projection auto|spherical|cylindrical|perspective --boundary-warp 0..100 + --auto-crop --fill-edges --no-auto-settings + --preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files) + lightcraft-cli synth-merge hdr|panorama -o DIR + Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views). lightcraft-cli commands [--json] list every command id with its parameters lightcraft-cli controls [--json] list every develop control id with its range lightcraft-cli --version | --help @@ -76,6 +87,8 @@ fn main() -> ExitCode { Some("render") => render(&args[1..]), Some("snapshot") => snapshot(&args[1..]), Some("commands") => commands(&args[1..]), + Some("merge") => merge(&args[1..]), + Some("synth-merge") => synth_merge(&args[1..]), Some("controls") => controls(&args[1..]), Some("--version" | "-V" | "version") => { println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION")); @@ -166,6 +179,112 @@ fn mcp(args: &[String]) -> Result<(), String> { server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string()) } +fn merge(args: &[String]) -> Result<(), String> { + let cmd = match args.first().map(String::as_str) { + Some("hdr") => "merge.hdr", + Some("panorama" | "pano") => "merge.panorama", + Some("hdr-panorama") => "merge.hdrPanorama", + _ => return Err("merge: expected hdr, panorama or hdr-panorama".into()), + }; + let mut p = serde_json::Map::new(); + let mut files = Vec::new(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--deghost" => { + p.insert("deghost".into(), json!(take_value(args, &mut i, "--deghost")?)); + } + "--no-align" => { + p.insert("align".into(), json!(false)); + } + "--bracket" => { + p.insert("bracket".into(), json!(take_value(args, &mut i, "--bracket")?.parse::().map_err(|e| e.to_string())?)); + } + "--projection" => { + p.insert("projection".into(), json!(take_value(args, &mut i, "--projection")?)); + } + "--boundary-warp" => { + p.insert("boundaryWarp".into(), json!(take_value(args, &mut i, "--boundary-warp")?.parse::().map_err(|e| e.to_string())?)); + } + "--auto-crop" => { + p.insert("autoCrop".into(), json!(true)); + } + "--fill-edges" => { + p.insert("fillEdges".into(), json!(true)); + } + "--no-auto-settings" => { + p.insert("autoSettings".into(), json!(false)); + } + "--preview" => { + p.insert("preview".into(), json!(true)); + p.insert("previewPath".into(), json!(take_value(args, &mut i, "--preview")?)); + p.insert("showOverlay".into(), json!(true)); + } + a if a.starts_with("--") => return Err(format!("unknown option `{a}`")), + f => files.push(f.to_string()), + } + i += 1; + } + let mut s = Session::new().with_fs(); + let paths = expand_paths(&files); + let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; + let mut ids: Vec = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + // keep the command line's order + ids.sort_by_key(|id| { + paths.iter().position(|f| { + s.catalog + .photo(lightcraft_engine::catalog::PhotoId(*id)) + .is_some_and(|ph| matches!(&ph.source, lightcraft_engine::catalog::Source::File { path } if path == f)) + }) + }); + p.insert("ids".into(), json!(ids)); + let t0 = std::time::Instant::now(); + let mut out = s.execute(cmd, &Value::Object(p)).map_err(|e| e.to_string())?; + out["seconds"] = json!(t0.elapsed().as_secs_f64()); + println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); + Ok(()) +} + +fn synth_merge(args: &[String]) -> Result<(), String> { + let kind = args.first().map(String::as_str).unwrap_or(""); + let mut dir = None; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-o" | "--output" => dir = Some(take_value(args, &mut i, "-o")?.to_string()), + a => return Err(format!("unknown option `{a}`")), + } + i += 1; + } + let dir = dir.ok_or("synth-merge needs -o DIR")?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let mut written = Vec::new(); + match kind { + "hdr" => { + for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).into_iter().enumerate() { + let p = Path::new(&dir).join(format!("bracket-{k}.dng")); + std::fs::write(&p, b).map_err(|e| e.to_string())?; + written.push(p); + } + } + "panorama" | "pano" => { + for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0]).into_iter().enumerate() { + let p = Path::new(&dir).join(format!("view-{k}.png")); + let img = v.to_srgb8(); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()) + .map_err(|e| e.to_string())?; + std::fs::write(&p, png).map_err(|e| e.to_string())?; + written.push(p); + } + } + _ => return Err("synth-merge: expected hdr or panorama".into()), + } + for p in written { + println!("{}", p.display()); + } + Ok(()) +} + fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> { *i += 1; args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value")) 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/wire.rs b/apps/lightcraft-web/src/wire.rs index 95e75ffd5..6dbe81b2c 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -45,6 +45,9 @@ pub struct WireJob { /// Reuse the worker's intermediate results for this view (the loupe), like the desktop's /// per-slot stage cache: slider drags then only redo what changed. pub stages: Option, + /// The request's diagnostic overlay (`Overlay::to_parts`). + #[serde(default)] + pub overlay: (u8, f32), } impl WireJob { @@ -70,6 +73,7 @@ impl WireJob { thumb: job.cache.as_ref().map(|(_, k)| k.to_string()), thumb_cached, stages: job.stages.is_some().then(|| view.to_string()), + overlay: job.request.overlay.to_parts(), } } @@ -83,6 +87,7 @@ impl WireJob { max_h: self.max_h, quality: if self.draft { Quality::Draft } else { Quality::Full }, apply_crop: self.apply_crop, + overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1), } } @@ -296,6 +301,7 @@ mod tests { thumb: None, thumb_cached: false, stages: Some("Main".into()), + overlay: (0, 0.0), }; let mut core = WorkerCore::default(); assert_eq!(core.needs_original(&job).as_deref(), Some(hash.as_str())); diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 96d7e8ab5..21de0243a 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -21,3 +21,6 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +muda = { workspace = true } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index f6ea375f6..6ac150ecd 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -14,14 +14,20 @@ mod alloc_release; mod control_server; +#[cfg(target_os = "macos")] +mod native_menu; use lightcraft_engine::Session; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; -struct App(LightcraftApp); +struct App(LightcraftApp, #[cfg(target_os = "macos")] Option); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + if let Some(m) = self.1.as_mut() { + m.update(&mut self.0, ctx); + } self.0.logic(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -77,6 +83,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( @@ -223,7 +242,15 @@ fn main() -> eframe::Result { let _ = app.run("library.import", serde_json::json!({"paths": files})); app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid; } - Ok(Box::new(App(app))) + // native menu bar generated from the command registry (macOS; elsewhere the menus are + // drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus. + #[cfg(target_os = "macos")] + let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx)); + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + menu, + ))) }), ) } diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs new file mode 100644 index 000000000..abba999f7 --- /dev/null +++ b/apps/lightcraft/src/native_menu.rs @@ -0,0 +1,392 @@ +//! The native macOS menu bar (via `muda`, pure Rust over AppKit), generated from the same model as +//! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts, +//! enabled and checked state, and live labels ("Undo Exposure"). +//! +//! Key handling: a native key equivalent consumes the key press before egui sees it, so +//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by +//! the egui shortcut handler (nothing fires twice); +//! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the +//! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come +//! back when the field loses focus. + +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::{Receiver, channel}; + +use lightcraft_ui_egui::LightcraftApp; +use lightcraft_ui_egui::menubar::{MenuNode, menu_bar}; +use muda::accelerator::{Accelerator, Code, Modifiers}; +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"]; + +enum Handle { + Plain(MenuItem), + Check(CheckMenuItem), +} + +struct Item { + handle: Handle, + cmd: String, + params: Value, + shortcut: Option, + accel: Option, + label: String, + enabled: bool, + checked: Option, +} + +pub struct NativeMenu { + menu: Menu, + items: HashMap, + rx: Receiver, + /// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it. + structure: String, + text_focus: bool, +} + +/// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape). +fn accelerator(sc: &str) -> Option { + let mut mods = Modifiers::empty(); + let mut key = None; + for part in sc.split('+') { + match part { + "Cmd" => mods |= Modifiers::META, + "Shift" => mods |= Modifiers::SHIFT, + "Alt" => mods |= Modifiers::ALT, + "Ctrl" => mods |= Modifiers::CONTROL, + k => key = Some(k), + } + } + let code = match key? { + k if k.len() == 1 && k.as_bytes()[0].is_ascii_alphabetic() => { + const LETTERS: [Code; 26] = [ + Code::KeyA, + Code::KeyB, + Code::KeyC, + Code::KeyD, + Code::KeyE, + Code::KeyF, + Code::KeyG, + Code::KeyH, + Code::KeyI, + Code::KeyJ, + Code::KeyK, + Code::KeyL, + Code::KeyM, + Code::KeyN, + Code::KeyO, + Code::KeyP, + Code::KeyQ, + Code::KeyR, + Code::KeyS, + Code::KeyT, + Code::KeyU, + Code::KeyV, + Code::KeyW, + Code::KeyX, + Code::KeyY, + Code::KeyZ, + ]; + LETTERS[(k.as_bytes()[0].to_ascii_uppercase() - b'A') as usize] + } + k if k.len() == 1 && k.as_bytes()[0].is_ascii_digit() => { + const DIGITS: [Code; 10] = [ + Code::Digit0, + Code::Digit1, + Code::Digit2, + Code::Digit3, + Code::Digit4, + Code::Digit5, + Code::Digit6, + Code::Digit7, + Code::Digit8, + Code::Digit9, + ]; + DIGITS[(k.as_bytes()[0] - b'0') as usize] + } + "[" => Code::BracketLeft, + "]" => Code::BracketRight, + "\\" => Code::Backslash, + "/" => Code::Slash, + "=" => Code::Equal, + "-" => Code::Minus, + "'" => Code::Quote, + "Delete" => Code::Backspace, + _ => return None, + }; + Some(Accelerator::new(mods, code)) +} + +/// Accelerators that must step aside while a text field has focus. +fn yields_to_text(sc: &str) -> bool { + !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) +} + +/// `&` marks a mnemonic in muda labels. +fn label_text(s: &str) -> String { + s.replace('&', "&&") +} + +fn structure_of(bar: &[(String, Vec)]) -> String { + fn walk(n: &[MenuNode], out: &mut String) { + for x in n { + match x { + MenuNode::Item { id, params, checked, shortcut, .. } => { + out.push_str(&MenuNode::key(id, params)); + out.push(if checked.is_some() { 'c' } else { 'i' }); + out.push_str(shortcut.as_deref().unwrap_or("")); + } + MenuNode::Separator => out.push('-'), + MenuNode::Submenu { label, children } => { + out.push('['); + out.push_str(label); + walk(children, out); + out.push(']'); + } + } + out.push(';'); + } + } + let mut s = String::new(); + for (t, items) in bar { + s.push_str(t); + walk(items, &mut s); + } + s +} + +impl NativeMenu { + /// Build the menu bar and install it as the application menu (main thread, after launch). + pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu { + let (tx, rx) = channel::(); + let repaint = ctx.clone(); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + let _ = tx.send(e.id.0); + repaint.request_repaint(); + })); + let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false }; + m.rebuild(app); + app.native_menu = true; + m + } + + fn rebuild(&mut self, app: &mut LightcraftApp) { + let bar = menu_bar(app); + self.structure = structure_of(&bar); + self.menu = Menu::new(); + self.items.clear(); + + // the application menu + let app_menu = Submenu::new("LightCraft", true); + let about = MenuItem::with_id("app.about", "About LightCraft", true, None); + let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); + let _ = app_menu.append_items(&[ + &about, + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::services(None), + &PredefinedMenuItem::separator(), + &PredefinedMenuItem::hide(Some("Hide LightCraft")), + &PredefinedMenuItem::hide_others(None), + &PredefinedMenuItem::show_all(None), + &PredefinedMenuItem::separator(), + &quit, + ]); + let _ = self.menu.append(&app_menu); + + for (title, nodes) in &bar { + let sub = Submenu::new(title, true); + if title == "Window" { + let _ = sub.append_items(&[ + &PredefinedMenuItem::minimize(None), + &PredefinedMenuItem::maximize(Some("Zoom")), + &PredefinedMenuItem::separator(), + ]); + } + // About lives in the app menu on macOS + let nodes: Vec = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect(); + self.append_nodes(&sub, &nodes); + if title == "Window" { + let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); + sub.set_as_windows_menu_for_nsapp(); + } + if title == "Help" { + sub.set_as_help_menu_for_nsapp(); + } + let _ = self.menu.append(&sub); + } + self.menu.init_for_nsapp(); + self.text_focus = false; + self.publish_shortcuts(app); + } + + fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode]) { + for n in nodes { + match n { + MenuNode::Separator => { + let _ = sub.append(&PredefinedMenuItem::separator()); + } + MenuNode::Submenu { label, children } => { + let s = Submenu::new(label_text(label), true); + self.append_nodes(&s, children); + let _ = sub.append(&s); + } + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { + let key = MenuNode::key(id, params); + if self.items.contains_key(&key) { + continue; // muda ids must be unique + } + 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); + let _ = sub.append(&it); + Handle::Check(it) + } + None => { + let it = MenuItem::with_id(key.clone(), label_text(label), *enabled, accel); + let _ = sub.append(&it); + Handle::Plain(it) + } + }; + self.items.insert( + key, + Item { + handle, + cmd: id.clone(), + params: params.clone(), + shortcut: shortcut.clone(), + accel, + label: label.clone(), + enabled: *enabled, + checked: *checked, + }, + ); + } + } + } + } + + /// Tell the egui shortcut handler which shortcuts the menu bar currently owns. + fn publish_shortcuts(&self, app: &mut LightcraftApp) { + app.native_shortcuts = self + .items + .values() + .filter(|i| i.accel.is_some()) + .filter_map(|i| i.shortcut.clone()) + .filter(|sc| !(self.text_focus && yields_to_text(sc))) + .collect::>(); + } + + /// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus + /// accelerators with the app state. + pub fn update(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { + while let Ok(key) = self.rx.try_recv() { + if key == QUIT { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + continue; + } + if key == "app.about" { + let _ = app.run("app.about", serde_json::json!({})); + continue; + } + if let Some(it) = self.items.get(&key) { + let (cmd, params) = (it.cmd.clone(), it.params.clone()); + let _ = lightcraft_ui_egui::menubar::run_item(app, &cmd, params); + } + } + let bar = menu_bar(app); + if structure_of(&bar) != self.structure { + self.rebuild(app); + return; + } + fn walk(nodes: &[MenuNode], items: &mut HashMap) { + for n in nodes { + match n { + MenuNode::Submenu { children, .. } => walk(children, items), + MenuNode::Item { id, params, label, enabled, checked, .. } => { + let Some(it) = items.get_mut(&MenuNode::key(id, params)) else { continue }; + if it.label != *label { + match &it.handle { + Handle::Plain(h) => h.set_text(label_text(label)), + Handle::Check(h) => h.set_text(label_text(label)), + } + it.label = label.clone(); + } + if it.enabled != *enabled { + match &it.handle { + Handle::Plain(h) => h.set_enabled(*enabled), + Handle::Check(h) => h.set_enabled(*enabled), + } + it.enabled = *enabled; + } + if let (Handle::Check(h), Some(c)) = (&it.handle, checked) { + // also resyncs after AppKit toggled it on click + if h.is_checked() != *c { + h.set_checked(*c); + } + it.checked = Some(*c); + } + } + MenuNode::Separator => {} + } + } + } + walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); + + let focus = ctx.egui_wants_keyboard_input(); + if focus != self.text_focus { + self.text_focus = focus; + for it in self.items.values() { + let Some(sc) = it.shortcut.as_deref() else { continue }; + if it.accel.is_none() || !yields_to_text(sc) { + continue; + } + let accel = if focus { None } else { it.accel }; + let _ = match &it.handle { + Handle::Plain(h) => h.set_accelerator(accel), + Handle::Check(h) => h.set_accelerator(accel), + }; + } + self.publish_shortcuts(app); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shortcuts_map_to_accelerators() { + assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ))); + assert_eq!(accelerator("G"), Some(Accelerator::new(Modifiers::empty(), Code::KeyG))); + assert_eq!(accelerator("Cmd+'"), Some(Accelerator::new(Modifiers::META, Code::Quote))); + assert_eq!(accelerator("Escape"), None); + assert!(yields_to_text("G") && yields_to_text("Delete") && yields_to_text("Cmd+V")); + assert!(!yields_to_text("Cmd+Shift+E")); + // every registry shortcut that a menu shows maps (or is deliberately left to egui) + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + fn all(n: &[MenuNode], out: &mut Vec) { + for x in n { + match x { + MenuNode::Item { shortcut: Some(s), .. } => out.push(s.clone()), + MenuNode::Submenu { children, .. } => all(children, out), + _ => {} + } + } + } + let mut scs = Vec::new(); + for (_, v) in menu_bar(&app) { + all(&v, &mut scs); + } + for sc in scs.iter().filter(|s| !CONTEXTUAL.contains(&s.as_str())) { + assert!(accelerator(sc).is_some(), "{sc}"); + } + } +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 4b03795c8..7a86fb5cf 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -10,6 +10,7 @@ pub mod journal; pub mod model; pub mod query; +pub mod stacks; pub mod store; use std::collections::BTreeMap; @@ -28,6 +29,8 @@ pub enum CatalogError { NoPhoto(PhotoId), #[error("no such album {0:?}")] NoAlbum(AlbumId), + #[error("no such stack {0:?}")] + NoStack(StackId), #[error("invalid: {0}")] Invalid(String), #[error("corrupt catalog data: {0}")] @@ -113,6 +116,23 @@ pub enum Op { id: AlbumId, cover: Option, }, + /// Replace a smart album's rules. + SetAlbumRules { + id: AlbumId, + rules: Box, + }, + AddStack { + stack: Stack, + }, + RemoveStack { + id: StackId, + }, + /// Replace a stack's members (first = top) and collapsed state. + SetStack { + id: StackId, + photos: Vec, + collapsed: bool, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -125,6 +145,10 @@ pub struct Catalog { albums: BTreeMap, next_photo: u64, next_album: u64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + stacks: BTreeMap, + #[serde(default)] + next_stack: u64, /// Increments on every applied op. #[serde(skip)] pub revision: u64, @@ -132,7 +156,7 @@ pub struct Catalog { impl Catalog { pub fn new() -> Catalog { - Catalog { next_photo: 1, next_album: 1, ..Default::default() } + Catalog { next_photo: 1, next_album: 1, next_stack: 1, ..Default::default() } } // ---- ids @@ -147,6 +171,11 @@ impl Catalog { self.next_album = id.0 + 1; id } + pub fn alloc_stack_id(&mut self) -> StackId { + let id = StackId(self.next_stack.max(1)); + self.next_stack = id.0 + 1; + id + } // ---- reads @@ -168,8 +197,51 @@ impl Catalog { pub fn albums(&self) -> impl Iterator { self.albums.values() } + /// Albums (regular and smart) that contain the photo. pub fn albums_of(&self, id: PhotoId) -> Vec { - self.albums.values().filter(|a| a.photos.contains(&id)).map(|a| a.id).collect() + let Some(p) = self.photos.get(&id) else { return Vec::new() }; + self.albums.values().filter(|a| self.album_contains(a.id, p)).map(|a| a.id).collect() + } + + /// Whether album `id` contains `p` (a smart album evaluates its rules; deleted photos are in + /// no smart album). + pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { + match self.albums.get(&id) { + Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self), + Some(a) => a.photos.contains(&p.id), + None => false, + } + } + + /// The photos of an album: the stored list, or a smart album's current matches (id order). + pub fn album_photos(&self, id: AlbumId) -> Vec { + match self.albums.get(&id) { + Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(), + Some(a) => a.photos.clone(), + None => Vec::new(), + } + } + + /// Number of photos shown for an album in the sources list (excludes deleted photos). + pub fn album_count(&self, id: AlbumId) -> usize { + match self.albums.get(&id) { + Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(), + Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(), + None => 0, + } + } + + /// Smart-album rules must not reference another smart album (no recursion) or deleted photos. + fn validate_rules(&self, rules: &Filter) -> Result<()> { + if rules.deleted { + return Err(CatalogError::Invalid("smart album rules can't select deleted photos".into())); + } + if let Some(a) = rules.album + && self.albums.get(&a).is_some_and(Album::is_smart) + { + return Err(CatalogError::Invalid("smart album rules can't reference another smart album".into())); + } + Ok(()) } // ---- writes @@ -261,6 +333,12 @@ impl Catalog { { return Err(CatalogError::Invalid("parent must be an existing folder".into())); } + if let Some(rules) = &album.smart { + if album.folder || !album.photos.is_empty() { + return Err(CatalogError::Invalid("a smart album holds rules, not photos".into())); + } + self.validate_rules(rules)?; + } let id = album.id; self.next_album = self.next_album.max(id.0 + 1); self.albums.insert(id, album); @@ -296,8 +374,10 @@ impl Catalog { } Op::SetAlbumPhotos { id, photos } => { let a = self.album_mut(id)?; - if a.folder && !photos.is_empty() { - return Err(CatalogError::Invalid("folders can't hold photos".into())); + if (a.folder || a.smart.is_some()) && !photos.is_empty() { + return Err(CatalogError::Invalid( + if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(), + )); } Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) } } @@ -305,6 +385,38 @@ impl Catalog { let a = self.album_mut(id)?; Op::SetAlbumCover { id, cover: std::mem::replace(&mut a.cover, cover) } } + Op::SetAlbumRules { id, rules } => { + self.validate_rules(&rules)?; + let a = self.album_mut(id)?; + let Some(old) = a.smart.as_mut() else { + return Err(CatalogError::Invalid("not a smart album".into())); + }; + Op::SetAlbumRules { id, rules: std::mem::replace(old, rules) } + } + Op::AddStack { stack } => { + if self.stacks.contains_key(&stack.id) { + return Err(CatalogError::Invalid(format!("stack {:?} exists", stack.id))); + } + self.validate_stack(stack.id, &stack.photos)?; + let id = stack.id; + self.next_stack = self.next_stack.max(id.0 + 1); + self.stacks.insert(id, stack); + Op::RemoveStack { id } + } + Op::RemoveStack { id } => { + let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?; + Op::AddStack { stack: s } + } + Op::SetStack { id, photos, collapsed } => { + if !self.stacks.contains_key(&id) { + return Err(CatalogError::NoStack(id)); + } + self.validate_stack(id, &photos)?; + let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?; + let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed }; + s.collapsed = collapsed; + old + } Op::Batch { ops } => { let mut inverses = Vec::with_capacity(ops.len()); for op in ops { @@ -325,7 +437,8 @@ impl Catalog { }) } - /// Ops that remove a photo permanently including album memberships (one undoable batch). + /// Ops that remove a photo permanently including album and stack memberships (one undoable + /// batch). pub fn delete_permanently_ops(&self, id: PhotoId) -> Op { let mut ops: Vec = self .albums @@ -333,6 +446,7 @@ impl Catalog { .filter(|a| a.photos.contains(&id)) .map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| *p != id).collect() }) .collect(); + ops.extend(self.remove_from_stacks_ops(&[id])); ops.push(Op::RemovePhoto { id }); Op::Batch { ops } } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 7f145b967..5d4f46d9c 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -13,6 +13,30 @@ pub struct PhotoId(pub u64); #[serde(transparent)] pub struct AlbumId(pub u64); +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StackId(pub u64); + +/// A stack: photos grouped under a top photo (bursts, brackets, merge results with their sources). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Stack { + pub id: StackId, + /// Members in stack order; `photos[0]` is the top of the stack. At least two. + pub photos: Vec, + /// Collapsed stacks show only their top photo in the grid. + #[serde(default)] + pub collapsed: bool, +} + +impl Stack { + pub fn top(&self) -> PhotoId { + self.photos[0] + } + pub fn position(&self, id: PhotoId) -> Option { + self.photos.iter().position(|p| *p == id) + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MediaKind { @@ -150,6 +174,13 @@ pub struct Photo { /// "Enable Profile Corrections" is on. #[serde(default, skip_serializing_if = "Option::is_none")] pub embedded_lens: Option, + /// A virtual copy: the photo it was copied from (it shares that photo's file but has its own + /// settings, metadata and history). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_of: Option, + /// Virtual copies' name ("Copy 1"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copy_name: Option, } impl Photo { @@ -178,6 +209,8 @@ impl Photo { as_shot_wb: None, content_hash: None, embedded_lens: None, + copy_of: None, + copy_name: None, } } /// The develop settings import gives this photo (raws: as-shot white balance and the raw @@ -216,6 +249,20 @@ pub struct Album { pub photos: Vec, #[serde(default)] pub cover: Option, + /// A smart album: its photos are every (non-deleted) photo matching these rules, evaluated + /// live; `photos` stays empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub smart: Option>, +} + +impl Album { + /// A regular (manual) album. + pub fn new(id: AlbumId, name: impl Into) -> Album { + Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None } + } + pub fn is_smart(&self) -> bool { + self.smart.is_some() + } } #[cfg(test)] diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index e7a406d28..389c01e3c 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -33,6 +33,12 @@ pub struct Filter { pub date: Option, pub keyword: Option, pub camera: Option, + /// Lens (case-insensitive substring). + pub lens: Option, + /// Capture date range, inclusive: `dateFrom` is compared as a lower bound (`2026-04-01`), + /// `dateTo` as a prefix upper bound (`2026-04` includes all of April). + pub date_from: Option, + pub date_to: Option, /// Import date prefix (Recently Added). pub imported: Option, } @@ -91,6 +97,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), "date" => p.date().starts_with(val), + "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), _ => false, }; @@ -100,6 +107,48 @@ fn token_matches(p: &Photo, tok: &str) -> bool { } impl Filter { + /// Human-readable summary of the active rules (smart album tooltips, `album.list`). + pub fn describe(&self) -> String { + let mut v: Vec = Vec::new(); + if self.rating > 0 { + let op = match self.rating_op { + RatingOp::AtLeast => "≥", + RatingOp::Exactly => "=", + RatingOp::AtMost => "≤", + }; + v.push(format!("rating {op} {}", self.rating)); + } + if let Some(f) = self.flag { + v.push(format!("flag {}", format!("{f:?}").to_lowercase())); + } + if let Some(l) = self.label { + v.push(format!("label {}", format!("{l:?}").to_lowercase())); + } + if let Some(k) = self.kind { + v.push(format!("kind {}", format!("{k:?}").to_lowercase())); + } + if let Some(e) = self.edited { + v.push(if e { "edited".into() } else { "unedited".into() }); + } + for (name, val) in + [("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)] + { + if let Some(x) = val { + v.push(format!("{name} {x}")); + } + } + match (&self.date_from, &self.date_to) { + (Some(a), Some(b)) => v.push(format!("captured {a} – {b}")), + (Some(a), None) => v.push(format!("captured from {a}")), + (None, Some(b)) => v.push(format!("captured until {b}")), + _ => {} + } + if !self.text.trim().is_empty() { + v.push(format!("“{}”", self.text.trim())); + } + if v.is_empty() { "all photos".into() } else { v.join(", ") } + } + pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { if p.deleted != self.deleted { return false; @@ -121,7 +170,22 @@ impl Filter { return false; } if let Some(a) = self.album - && !cat.album(a).is_some_and(|al| al.photos.contains(&p.id)) + && !cat.album_contains(a, p) + { + return false; + } + if let Some(from) = &self.date_from + && p.date() < from.as_str() + { + return false; + } + if let Some(to) = &self.date_to + && p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str() + { + return false; + } + if let Some(l) = &self.lens + && !p.meta.lens.to_lowercase().contains(&l.to_lowercase()) { return false; } diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs new file mode 100644 index 000000000..ba97e0f80 --- /dev/null +++ b/crates/catalog/src/stacks.rs @@ -0,0 +1,316 @@ +//! Stacks: photos grouped under a top photo. +//! +//! A stack is catalog data ([`Stack`], changed by `AddStack` / `RemoveStack` / `SetStack` ops), so +//! grouping is undoable and journaled like everything else. The helpers here build the ops for the +//! usual gestures (group, ungroup, remove from stack, set top, collapse) and for programmatic +//! grouping such as merge results stacked with their sources ([`Catalog::stack_with_ops`]). +//! [`Catalog::arrange_stacks`] turns a query result into display order (members together, +//! collapsed stacks reduced to one photo). + +use std::collections::{HashMap, HashSet}; + +use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId}; + +impl Catalog { + pub fn stack(&self, id: StackId) -> Option<&Stack> { + self.stacks.get(&id) + } + pub fn stacks(&self) -> impl Iterator { + self.stacks.values() + } + /// The stack a photo belongs to. + pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> { + self.stacks.values().find(|s| s.photos.contains(&photo)) + } + /// Photo → (stack, position in the stack), for every stacked photo. + pub fn stack_index(&self) -> HashMap { + self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect() + } + + pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> { + if photos.len() < 2 { + return Err(CatalogError::Invalid("a stack needs at least two photos".into())); + } + let mut seen = HashSet::new(); + for p in photos { + if !seen.insert(*p) { + return Err(CatalogError::Invalid(format!("photo {p:?} twice in a stack"))); + } + if !self.photos.contains_key(p) { + return Err(CatalogError::NoPhoto(*p)); + } + } + if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) { + return Err(CatalogError::Invalid("a photo can be in only one stack".into())); + } + Ok(()) + } + + /// Ops that put `top` and `members` into one stack with `top` on top. Stacks they already + /// belong to are merged into it (their other members follow, in order). `None` if fewer than + /// two photos would be in the stack. + pub fn group_ops(&mut self, top: PhotoId, members: &[PhotoId], collapsed: bool) -> Option { + let mut order: Vec = Vec::new(); + let push = |order: &mut Vec, p: PhotoId| { + if !order.contains(&p) && self.photos.contains_key(&p) { + order.push(p); + } + }; + push(&mut order, top); + for m in members { + push(&mut order, *m); + } + let mut touched: Vec = Vec::new(); + for p in order.clone() { + if let Some(s) = self.stack_of(p) + && !touched.contains(&s.id) + { + touched.push(s.id); + } + } + for sid in &touched { + for p in self.stacks[sid].photos.clone() { + push(&mut order, p); + } + } + if order.len() < 2 || order.first() != Some(&top) { + return None; + } + let mut ops: Vec = touched.iter().skip(1).map(|id| Op::RemoveStack { id: *id }).collect(); + match touched.first() { + Some(keep) => ops.push(Op::SetStack { id: *keep, photos: order, collapsed }), + None => { + let id = self.alloc_stack_id(); + ops.push(Op::AddStack { stack: Stack { id, photos: order, collapsed } }); + } + } + Some(Op::Batch { ops }) + } + + /// Stack `sources` under `result` (e.g. an HDR or panorama merge with the photos it was made + /// from), collapsed so the grid shows the result. + pub fn stack_with_ops(&mut self, result: PhotoId, sources: &[PhotoId]) -> Option { + self.group_ops(result, sources, true) + } + + /// Ops that dissolve every stack containing one of `photos`. + pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec { + self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect() + } + + /// Ops that take `photos` out of their stacks (a stack left with one photo is dissolved). + pub fn remove_from_stacks_ops(&self, photos: &[PhotoId]) -> Vec { + self.stacks + .values() + .filter(|s| s.photos.iter().any(|p| photos.contains(p))) + .map(|s| { + let rest: Vec = s.photos.iter().copied().filter(|p| !photos.contains(p)).collect(); + if rest.len() < 2 { Op::RemoveStack { id: s.id } } else { Op::SetStack { id: s.id, photos: rest, collapsed: s.collapsed } } + }) + .collect() + } + + /// The op that moves `photo` to the top of its stack (`None` if unstacked or already on top). + pub fn set_top_ops(&self, photo: PhotoId) -> Option { + let s = self.stack_of(photo)?; + if s.top() == photo { + return None; + } + let mut photos = vec![photo]; + photos.extend(s.photos.iter().copied().filter(|p| *p != photo)); + Some(Op::SetStack { id: s.id, photos, collapsed: s.collapsed }) + } + + /// Ops that collapse or expand stacks. + pub fn set_collapsed_ops(&self, stacks: &[StackId], collapsed: bool) -> Vec { + stacks + .iter() + .filter_map(|id| self.stacks.get(id)) + .filter(|s| s.collapsed != collapsed) + .map(|s| Op::SetStack { id: s.id, photos: s.photos.clone(), collapsed }) + .collect() + } + + /// Display order for a query result: a stack's members appear together (in stack order) where + /// its first member appears; a collapsed stack shows only its highest-ranked member in `ids` (the top, + /// unless the filter hides it). + pub fn arrange_stacks(&self, ids: &[PhotoId]) -> Vec { + if self.stacks.is_empty() { + return ids.to_vec(); + } + let index = self.stack_index(); + let present: HashSet = ids.iter().copied().collect(); + let mut done: HashSet = HashSet::new(); + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + match index.get(id) { + None => out.push(*id), + Some((sid, _)) => { + if !done.insert(*sid) { + continue; + } + let s = &self.stacks[sid]; + let members = s.photos.iter().copied().filter(|p| present.contains(p)); + if s.collapsed { + out.extend(members.take(1)); + } else { + out.extend(members); + } + } + } + } + out + } + + /// Auto-stack by capture time: runs of `ids` (not deleted, not already stacked, with a + /// capture time) whose consecutive captures are at most `gap_secs` apart, in time order. + /// Only runs of two or more are returned. + pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec> { + let index = self.stack_index(); + let mut timed: Vec<(i64, PhotoId)> = ids + .iter() + .filter_map(|id| self.photos.get(id)) + .filter(|p| !p.deleted && !index.contains_key(&p.id)) + .filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id))) + .collect(); + timed.sort(); + timed.dedup_by_key(|(_, id)| *id); + let mut groups: Vec> = Vec::new(); + let mut last: Option = None; + for (t, id) in timed { + match (last, groups.last_mut()) { + (Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id), + _ => groups.push(vec![id]), + } + last = Some(t); + } + groups.retain(|g| g.len() >= 2); + groups + } +} + +/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`, +/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse. +pub fn iso_seconds(s: &str) -> Option { + let s = s.trim(); + let num = |a: usize, b: usize| -> Option { s.get(a..b).filter(|x| x.bytes().all(|c| c.is_ascii_digit())).and_then(|x| x.parse().ok()) }; + let (y, m, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + if !(1..=12).contains(&m) || !(1..=31).contains(&d) { + return None; + } + let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) }; + // days from civil (proleptic Gregorian) + let y2 = if m <= 2 { y - 1 } else { y }; + let era = y2.div_euclid(400); + let yoe = y2 - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + Some(days * 86_400 + hh * 3600 + mm * 60 + ss) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Photo, Source}; + + fn cat(n: usize, times: &[&str]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for i in 0..n { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.captured = times.get(i).map(|t| t.to_string()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) + } + + #[test] + fn iso_seconds_parses() { + assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0)); + assert_eq!(iso_seconds("1970-01-02"), Some(86_400)); + assert_eq!(iso_seconds("2026-04-01T10:00:05.123+02:00").unwrap() - iso_seconds("2026-04-01T10:00:00").unwrap(), 5); + assert_eq!(iso_seconds("2024-03-01").unwrap() - iso_seconds("2024-02-28").unwrap(), 2 * 86_400, "leap year"); + assert_eq!(iso_seconds("2000-01-01T00:00:00"), Some(946_684_800)); + assert_eq!(iso_seconds("garbage"), None); + } + + #[test] + fn group_merge_set_top_remove_and_arrange() { + let (mut c, ids) = cat(6, &[]); + let [a, b, x, d, e, f] = ids[..] else { unreachable!() }; + let op = c.group_ops(b, &[a, b, x], false).unwrap(); + let inv = c.apply(op).unwrap(); + let s = c.stack_of(a).unwrap().clone(); + assert_eq!(s.photos, vec![b, a, x]); + // query order a b x d e f → stack shown where its first member appears, in stack order + assert_eq!(c.arrange_stacks(&ids), vec![b, a, x, d, e, f]); + // collapse: only the top + let ops = c.set_collapsed_ops(&[s.id], true); + c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.arrange_stacks(&ids), vec![b, d, e, f]); + // filtered-out top: first visible member stands in + assert_eq!(c.arrange_stacks(&[x, d, a]), vec![a, d]); + // grouping with another stack merges both + let op = c.group_ops(d, &[e], false).unwrap(); + c.apply(op).unwrap(); + let op = c.group_ops(f, &[a, e], true).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.stacks().count(), 1); + assert_eq!(c.stack_of(f).unwrap().photos, vec![f, a, e, b, x, d]); + // set top, remove from stack, ungroup + c.apply(c.set_top_ops(x).unwrap()).unwrap(); + assert_eq!(c.stack_of(f).unwrap().top(), x); + assert!(c.set_top_ops(x).is_none()); + c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[x, a, e, b]) }).unwrap(); + assert_eq!(c.stack_of(f).unwrap().photos, vec![f, d]); + c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[d]) }).unwrap(); + assert_eq!(c.stacks().count(), 0, "a one-photo stack dissolves"); + // undo everything back to the first group's inverse state + let (mut c2, _) = cat(6, &[]); + let op = c2.group_ops(a, &[b], false).unwrap(); + c2.apply(op).unwrap(); + c2.apply(Op::Batch { ops: c2.ungroup_ops(&[b]) }).unwrap(); + assert_eq!(c2.stacks().count(), 0); + let _ = inv; + } + + #[test] + fn stack_validation_and_permanent_delete() { + let (mut c, ids) = cat(3, &[]); + let id = c.alloc_stack_id(); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0]], collapsed: false } }).is_err(), "one photo"); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[0]], collapsed: false } }).is_err(), "duplicate"); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], PhotoId(99)], collapsed: false } }).is_err(), "missing"); + c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap(); + let id2 = c.alloc_stack_id(); + assert!(c.apply(Op::AddStack { stack: Stack { id: id2, photos: vec![ids[1], ids[2]], collapsed: false } }).is_err(), "one stack per photo"); + // deleting a member permanently dissolves the 2-photo stack; undo restores it + let before = c.to_snapshot(); + let inv = c.apply(c.delete_permanently_ops(ids[1])).unwrap(); + assert!(c.stack_of(ids[0]).is_none()); + c.apply(inv).unwrap(); + assert_eq!(c.to_snapshot(), before); + // snapshot round trip keeps stacks and the id allocator + let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + assert_eq!(back.stack(id), c.stack(id)); + assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id()); + } + + #[test] + fn auto_stack_by_capture_time() { + let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"]; + let (mut c, ids) = cat(6, ×); + let g = c.auto_stack_groups(&ids, 5.0); + assert_eq!(g, vec![vec![ids[0], ids[1]], vec![ids[3], ids[4]]]); + assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[0], ids[1], ids[2]], vec![ids[3], ids[4]]]); + assert_eq!(c.auto_stack_groups(&ids, 3600.0).len(), 1); + // already stacked photos are skipped + let op = c.group_ops(ids[0], &[ids[1]], true).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[3], ids[4]]]); + } +} diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 37ecec432..a18f94794 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -57,9 +57,11 @@ fn albums_and_folders() { let mut c = Catalog::new(); let a = photo(&mut c, "a.jpg", "2026-04-01"); let f = c.alloc_album_id(); - c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None } }).unwrap(); + c.apply(Op::AddAlbum { album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None } }) + .unwrap(); let al = c.alloc_album_id(); - c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None } }).unwrap(); + c.apply(Op::AddAlbum { album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None } }) + .unwrap(); assert_eq!(c.albums_of(a), vec![al]); assert!(c.apply(Op::RemoveAlbum { id: f }).is_err(), "non-empty folder"); assert!(c.apply(Op::SetAlbumPhotos { id: f, photos: vec![a] }).is_err(), "folders hold no photos"); @@ -141,3 +143,51 @@ proptest::proptest! { proptest::prop_assert_eq!(c.to_snapshot(), start); } } + +#[test] +fn smart_albums_update_live() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-04-01T10:00:00"); + let b = photo(&mut c, "b.jpg", "2026-05-02T10:00:00"); + let z = photo(&mut c, "z.jpg", "2025-12-31T10:00:00"); + let id = c.alloc_album_id(); + let rules = Filter { rating: 3, date_from: Some("2026-01-01".into()), date_to: Some("2026-04".into()), ..Default::default() }; + c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Good spring") } }).unwrap(); + assert!(c.album_photos(id).is_empty()); + for p in [a, b, z] { + c.apply(Op::SetRating { id: p, rating: 4 }).unwrap(); + } + // b is after the range, z before it + assert_eq!(c.album_photos(id), vec![a]); + assert_eq!(c.album_count(id), 1); + assert_eq!(c.albums_of(a), vec![id]); + // via the album filter (what the grid uses) + assert_eq!(c.query(&Filter { album: Some(id), ..Default::default() }, &Sort::default()), vec![a]); + // deleted photos drop out + c.apply(Op::SetDeleted { id: a, deleted: true }).unwrap(); + assert!(c.album_photos(id).is_empty()); + c.apply(Op::SetDeleted { id: a, deleted: false }).unwrap(); + // change the rules; inverse restores + let inv = c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { rating: 4, rating_op: RatingOp::Exactly, ..Default::default() }) }).unwrap(); + assert_eq!(c.album_photos(id), vec![a, b, z]); + c.apply(inv).unwrap(); + assert_eq!(c.album_photos(id), vec![a]); + // smart albums hold no photos and can't nest smart rules + assert!(c.apply(Op::SetAlbumPhotos { id, photos: vec![b] }).is_err()); + let id2 = c.alloc_album_id(); + let bad = Filter { album: Some(id), ..Default::default() }; + assert!(c.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(bad)), ..Album::new(id2, "x") } }).is_err()); + assert!(c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { deleted: true, ..Default::default() }) }).is_err()); + // rules on a manual album are rejected + let manual = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(manual, "m") }).unwrap(); + assert!(c.apply(Op::SetAlbumRules { id: manual, rules: Box::new(Filter::default()) }).is_err()); + // a smart album may narrow a manual album + c.apply(Op::SetAlbumPhotos { id: manual, photos: vec![b, z] }).unwrap(); + c.apply(Op::SetAlbumRules { id, rules: Box::new(Filter { album: Some(manual), lens: Some(String::new()), ..Default::default() }) }).unwrap(); + assert_eq!(c.album_photos(id), vec![b, z]); + // persisted in snapshots + let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + assert_eq!(back.album_photos(id), vec![b, z]); + assert!(Filter { rating: 3, keyword: Some("sea".into()), ..Default::default() }.describe().contains("rating ≥ 3, keyword sea")); +} diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 2cbe8098a..01d1f03d4 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -19,7 +19,14 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { let albums: Vec = c.albums().map(|a| a.id).collect(); let pid = photos.get(a as usize % photos.len().max(1)).copied().unwrap_or(PhotoId(99)); let aid = albums.get(a as usize % albums.len().max(1)).copied().unwrap_or(AlbumId(99)); - match kind % 10 { + let rules = |b: u8| Filter { + rating: b % 6, + flag: [None, Some(Flag::Pick), Some(Flag::Reject)][b as usize % 3], + date_from: (b % 4 == 1).then(|| "2026-01".to_string()), + text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() }, + ..Default::default() + }; + match kind % 16 { 0 | 1 => { let id = c.alloc_photo_id(); let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01"); @@ -42,11 +49,36 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { } 5 => { let id = c.alloc_album_id(); - Op::AddAlbum { album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None } } + Op::AddAlbum { + album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None, smart: None }, + } } 6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() }, 7 => Op::RenameAlbum { id: aid, name: format!("Renamed {b} ✓") }, 8 => Op::SetDeleted { id: pid, deleted: b.is_multiple_of(2) }, + 10 => { + let id = c.alloc_album_id(); + Op::AddAlbum { album: Album { smart: Some(Box::new(rules(b))), ..Album::new(id, format!("Smart {b}")) } } + } + 11 => Op::SetAlbumRules { id: aid, rules: Box::new(rules(b)) }, + 12 => { + let members: Vec = photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(4)).collect(); + c.group_ops(pid, &members, b.is_multiple_of(2)).unwrap_or(Op::Batch { ops: vec![] }) + } + 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, + 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), + 15 => match c.photo(pid).cloned() { + // a virtual copy: same source, own id, copy_of/copy_name set + Some(src) => { + let id = c.alloc_photo_id(); + let mut v = (*src).clone(); + v.id = id; + v.copy_of = Some(pid); + v.copy_name = Some(format!("Copy {b}")); + Op::AddPhoto { photo: Box::new(v) } + } + None => Op::Batch { ops: vec![] }, + }, _ => c.delete_permanently_ops(pid), } } diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index 697a71489..5696d6706 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -1,6 +1,9 @@ //! Control specs: one table describes every numeric develop slider — id, label, section, range, //! default, step, display precision and track style. The UI builds panels from it, MCP exposes it as //! a schema, and `get`/`set` address settings by id (e.g. `light.exposure`, `mixer.red.hue`). +//! +//! Settings that come in lists (Point Color samples) are *indexed* controls: one template spec in +//! [`INDEXED`] (`pointColor.hueShift`) addresses every element as `pointColor..hueShift`. use serde::{Deserialize, Serialize}; @@ -22,6 +25,9 @@ pub enum Section { Optics, Geometry, Profile, + Calibration, + PointColor, + RedEye, } impl Section { @@ -40,6 +46,9 @@ impl Section { Section::Optics => "Optics", Section::Geometry => "Geometry", Section::Profile => "Profile", + Section::Calibration => "Calibration", + Section::PointColor => "Point Color", + Section::RedEye => "Red Eye", } } } @@ -103,7 +112,7 @@ macro_rules! controls { pub fn get(s: &DevelopSettings, id: &str) -> Option { match id { $( $id => Some(s.$($field).+ as f64), )* - _ => None, + _ => indexed_get(s, id), } } @@ -113,7 +122,7 @@ macro_rules! controls { let v = spec.clamp(v); match id { $( $id => { s.$($field).+ = v as _; true } )* - _ => false, + _ => indexed_set(s, id, v), } } }; @@ -138,6 +147,7 @@ controls! { "curve.splitShadows" => curve.split_shadows, "Shadows split", Curve, 10, 70, 25, 1, 0, Plain; "curve.splitMid" => curve.split_mid, "Midtones split", Curve, 20, 80, 50, 1, 0, Plain; "curve.splitHighlights" => curve.split_highlights, "Highlights split", Curve, 30, 90, 75, 1, 0, Plain; + "curve.refineSaturation" => curve.refine_saturation, "Refine Saturation", Curve, 0, 100, 100, 1, 0, Gradient { from: "#7a7a7a", to: "#d85a3a" }; "color.vibrance" => color.vibrance, "Vibrance", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#d8406a" }; "color.saturation" => color.saturation, "Saturation", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e04a3a" }; "mixer.red.hue" => mixer.red.hue, "Red Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 0 }; @@ -227,11 +237,207 @@ controls! { "geometry.scale" => geometry.scale, "Scale", Geometry, 50, 150, 100, 1, 0, Plain; "geometry.offsetX" => geometry.offset_x, "Offset X", Geometry, -100, 100, 0, 0.1, 1, Centered; "geometry.offsetY" => geometry.offset_y, "Offset Y", Geometry, -100, 100, 0, 0.1, 1, Centered; + "calibration.shadowsTint" => calibration.shadows_tint, "Shadows Tint", Calibration, -100, 100, 0, 1, 0, Tint; + "calibration.redHue" => calibration.red_hue, "Red Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#e0306a", to: "#e08a30" }; + "calibration.redSat" => calibration.red_sat, "Red Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e03a3a" }; + "calibration.greenHue" => calibration.green_hue, "Green Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#b0c030", to: "#30c08a" }; + "calibration.greenSat" => calibration.green_sat, "Green Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40b050" }; + "calibration.blueHue" => calibration.blue_hue, "Blue Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#30a0d0", to: "#8040d0" }; + "calibration.blueSat" => calibration.blue_sat, "Blue Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#3a60e0" }; "crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered; } +/// Template specs of the indexed controls (`.`; addressed as `..`). +pub static INDEXED: &[ControlSpec] = &[ + ControlSpec { + id: "pointColor.hueShift", + label: "Hue", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Rainbow, + }, + ControlSpec { + id: "pointColor.satShift", + label: "Saturation", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#7a7a7a", to: "#e04a3a" }, + }, + ControlSpec { + id: "pointColor.lumShift", + label: "Luminance", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#202020", to: "#f0f0f0" }, + }, + ControlSpec { + id: "pointColor.variance", + label: "Variance", + section: Section::PointColor, + min: -100.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: Centered, + }, + ControlSpec { + id: "pointColor.range", + label: "Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Plain, + }, + ControlSpec { + id: "pointColor.hueRange", + label: "Hue Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Rainbow, + }, + ControlSpec { + id: "pointColor.satRange", + label: "Saturation Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#7a7a7a", to: "#e04a3a" }, + }, + ControlSpec { + id: "pointColor.lumRange", + label: "Luminance Range", + section: Section::PointColor, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#202020", to: "#f0f0f0" }, + }, + ControlSpec { + id: "redEye.pupilSize", + label: "Pupil Size", + section: Section::RedEye, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Plain, + }, + ControlSpec { + id: "redEye.darken", + label: "Darken", + section: Section::RedEye, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Gradient { from: "#d0d0d0", to: "#101010" }, + }, +]; + +/// `pointColor.3.hueShift` → (`pointColor`, 3, `hueShift`). +fn split_indexed(id: &str) -> Option<(&str, usize, &str)> { + let mut it = id.splitn(3, '.'); + let (fam, i, field) = (it.next()?, it.next()?, it.next()?); + Some((fam, i.parse().ok()?, field)) +} + +fn indexed_field<'a>(s: &'a mut DevelopSettings, id: &str) -> Option<&'a mut f64> { + let (fam, i, field) = split_indexed(id)?; + match fam { + "pointColor" => { + let p = s.point_colors.get_mut(i)?; + Some(match field { + "hueShift" => &mut p.hue_shift, + "satShift" => &mut p.sat_shift, + "lumShift" => &mut p.lum_shift, + "variance" => &mut p.variance, + "range" => &mut p.range, + "hueRange" => &mut p.hue_range, + "satRange" => &mut p.sat_range, + "lumRange" => &mut p.lum_range, + _ => return None, + }) + } + "redEye" => { + let e = s.red_eye.get_mut(i)?; + Some(match field { + "pupilSize" => &mut e.pupil_size, + "darken" => &mut e.darken, + _ => return None, + }) + } + _ => None, + } +} + +fn indexed_get(s: &DevelopSettings, id: &str) -> Option { + // (a clone keeps one accessor for get and set; these lists are tiny) + let mut c = DevelopSettings { point_colors: s.point_colors.clone(), red_eye: s.red_eye.clone(), ..DevelopSettings::default() }; + indexed_field(&mut c, id).map(|v| *v) +} + +fn indexed_set(s: &mut DevelopSettings, id: &str, v: f64) -> bool { + match indexed_field(s, id) { + Some(f) => { + *f = v; + true + } + None => false, + } +} + +/// The spec of a control id, including indexed ids (`pointColor.0.hueShift` → the +/// `pointColor.hueShift` template). pub fn find(id: &str) -> Option<&'static ControlSpec> { - CONTROLS.iter().find(|c| c.id == id) + if let Some(c) = CONTROLS.iter().find(|c| c.id == id) { + return Some(c); + } + let (fam, _, field) = split_indexed(id)?; + INDEXED.iter().find(|c| c.id.strip_prefix(fam).and_then(|r| r.strip_prefix('.')) == Some(field)) +} + +/// Every indexed control that exists in `s` (id, template spec), e.g. `pointColor.0.hueShift`. +pub fn indexed_instances(s: &DevelopSettings) -> Vec<(String, &'static ControlSpec)> { + let mut out = Vec::new(); + for spec in INDEXED { + let Some((fam, field)) = spec.id.split_once('.') else { continue }; + let n = match fam { + "pointColor" => s.point_colors.len(), + "redEye" => s.red_eye.len(), + _ => 0, + }; + for i in 0..n { + out.push((format!("{fam}.{i}.{field}"), spec)); + } + } + out } pub fn in_section(section: Section) -> impl Iterator { @@ -267,6 +473,26 @@ mod tests { assert_eq!(get(&s, "nope"), None); } + #[test] + fn indexed_controls_address_list_elements() { + let mut s = DevelopSettings::default(); + assert_eq!(get(&s, "pointColor.0.hueShift"), None); + assert!(!set(&mut s, "pointColor.0.hueShift", 10.0), "no sample yet"); + s.point_colors.push(Default::default()); + s.point_colors.push(Default::default()); + assert!(set(&mut s, "pointColor.1.hueShift", 250.0)); + assert_eq!(s.point_colors[1].hue_shift, 100.0, "clamped by the template spec"); + assert_eq!(get(&s, "pointColor.1.range"), Some(50.0)); + assert_eq!(find("pointColor.7.lumRange").map(|c| c.id), Some("pointColor.lumRange")); + assert!(find("pointColor.x.lumRange").is_none() && find("pointColor.0.nope").is_none()); + let ids: Vec = indexed_instances(&s).into_iter().map(|(id, _)| id).collect(); + assert_eq!(ids.len(), 2 * INDEXED.iter().filter(|c| c.id.starts_with("pointColor.")).count()); + for (id, spec) in indexed_instances(&s) { + assert!(get(&s, &id).is_some(), "{id}"); + assert!(spec.min <= spec.default && spec.default <= spec.max); + } + } + #[test] fn formatting() { let e = find("light.exposure").unwrap(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 349d700b6..f00e7bca6 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -32,10 +32,11 @@ pub enum SettingsGroup { Spots, RedEye, LensBlur, + Calibration, } impl SettingsGroup { - pub const ALL: [SettingsGroup; 19] = [ + pub const ALL: [SettingsGroup; 20] = [ SettingsGroup::Profile, SettingsGroup::Treatment, SettingsGroup::WhiteBalance, @@ -55,6 +56,7 @@ impl SettingsGroup { SettingsGroup::Spots, SettingsGroup::RedEye, SettingsGroup::LensBlur, + SettingsGroup::Calibration, ]; /// Groups included by default when copying (Lightroom excludes crop, masks and spots by default). @@ -86,6 +88,7 @@ impl SettingsGroup { SettingsGroup::Spots => "Remove", SettingsGroup::RedEye => "Red Eye", SettingsGroup::LensBlur => "Lens Blur", + SettingsGroup::Calibration => "Calibration", } } @@ -98,7 +101,7 @@ impl SettingsGroup { SettingsGroup::Light => &["light"], SettingsGroup::ToneCurve => &["curve"], SettingsGroup::Color => &["color"], - SettingsGroup::ColorMixer => &["mixer", "bw_mix"], + SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"], SettingsGroup::ColorGrading => &["grading"], SettingsGroup::Effects => &["effects"], SettingsGroup::Vignette => &["vignette"], @@ -111,6 +114,7 @@ impl SettingsGroup { SettingsGroup::Spots => &["spots"], SettingsGroup::RedEye => &["red_eye"], SettingsGroup::LensBlur => &["lens_blur"], + SettingsGroup::Calibration => &["calibration"], } } } diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index fa257c367..080a574e6 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -17,6 +17,8 @@ pub struct DevelopSettings { pub curve: ToneCurve, pub color: ColorAdj, pub mixer: Mixer, + /// Point Color: up to [`MAX_POINT_COLORS`] sampled colours, each with its own adjustment. + pub point_colors: Vec, pub bw_mix: BwMix, pub grading: ColorGrading, pub effects: Effects, @@ -32,6 +34,8 @@ pub struct DevelopSettings { pub red_eye: Vec, pub lens_blur: LensBlur, pub enhance: Enhance, + /// Camera calibration: shadows tint and primary hue/saturation (applied before tone mapping). + pub calibration: Calibration, /// Section on/off toggles (the "eye" buttons on panel headers): section id → enabled. pub disabled_sections: Vec, } @@ -47,6 +51,7 @@ impl Default for DevelopSettings { curve: ToneCurve::default(), color: ColorAdj::default(), mixer: Mixer::default(), + point_colors: Vec::new(), bw_mix: BwMix::default(), grading: ColorGrading::default(), effects: Effects::default(), @@ -62,6 +67,7 @@ impl Default for DevelopSettings { red_eye: Vec::new(), lens_blur: LensBlur::default(), enhance: Enhance::default(), + calibration: Calibration::default(), disabled_sections: Vec::new(), } } @@ -188,6 +194,9 @@ pub struct ToneCurve { pub red: Vec, pub green: Vec, pub blue: Vec, + /// Refine Saturation 0..100: 100 keeps the saturation a curve produces, lower values pull it + /// back towards the saturation before the curve (strong contrast curves oversaturate). + pub refine_saturation: f64, } impl Default for ToneCurve { @@ -204,6 +213,7 @@ impl Default for ToneCurve { red: Vec::new(), green: Vec::new(), blue: Vec::new(), + refine_saturation: 100.0, } } } @@ -256,6 +266,63 @@ impl Mixer { } } +/// Most Point Color samples per photo (Lightroom's limit). +pub const MAX_POINT_COLORS: usize = 8; + +/// One Point Color sample: a colour picked on the image (OkLCh, at the stage where Point Color +/// applies: after the colour mixer) and the adjustment of the colours around it. The range is a +/// box in OkLCh around the sample, widened by `range` (overall) and the per-axis widths, with a +/// soft edge. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PointColor { + /// Sampled OkLab lightness (0..1). + pub lum: f64, + /// Sampled OkLCh chroma (0..~0.37). + pub chroma: f64, + /// Sampled OkLCh hue, degrees. + pub hue: f64, + /// Hue shift −100..100 (±~29° at the sample). + pub hue_shift: f64, + /// Saturation shift −100..100 (chroma scale). + pub sat_shift: f64, + /// Luminance shift −100..100. + pub lum_shift: f64, + /// −100 compresses the colour spread within the range towards the sample (evens out e.g. + /// skin), +100 expands it. + pub variance: f64, + /// Overall range 0..100 (scales all three widths). + pub range: f64, + pub hue_range: f64, + pub sat_range: f64, + pub lum_range: f64, +} + +impl Default for PointColor { + fn default() -> Self { + Self { + lum: 0.5, + chroma: 0.1, + hue: 0.0, + hue_shift: 0.0, + sat_shift: 0.0, + lum_shift: 0.0, + variance: 0.0, + range: 50.0, + hue_range: 50.0, + sat_range: 50.0, + lum_range: 50.0, + } + } +} + +impl PointColor { + /// True when the sample changes nothing. + pub fn is_neutral(&self) -> bool { + self.hue_shift == 0.0 && self.sat_shift == 0.0 && self.lum_shift == 0.0 && self.variance == 0.0 + } +} + /// B&W mix (used when treatment = Bw): per-band luminance contribution −100..100. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -319,6 +386,31 @@ impl ColorGrading { } } +/// Camera calibration (Lightroom Classic's Calibration panel): a green/magenta tint of the shadows, +/// and a hue rotation / saturation scale of each working-space primary (−100..100), i.e. a +/// white-preserving 3×3 matrix applied in scene-linear light before tone mapping. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Calibration { + pub shadows_tint: f64, + pub red_hue: f64, + pub red_sat: f64, + pub green_hue: f64, + pub green_sat: f64, + pub blue_hue: f64, + pub blue_sat: f64, +} + +impl Calibration { + /// (hue, saturation) of the red, green and blue primaries. + pub fn primaries(&self) -> [(f64, f64); 3] { + [(self.red_hue, self.red_sat), (self.green_hue, self.green_sat), (self.blue_hue, self.blue_sat)] + } + pub fn is_neutral(&self) -> bool { + *self == Calibration::default() + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Effects { @@ -750,14 +842,27 @@ impl Default for Spot { } } +/// A red eye / pet eye correction: the user's ellipse (centre normalized, radii as fractions of +/// the long edge); the pupil inside it is found automatically. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct RedEye { pub center: Point, pub rx: f64, pub ry: f64, + /// 0..100 (50 = the detected pupil). pub pupil_size: f64, + /// 0..100. pub darken: f64, pub pet: bool, + /// Pet eye catchlight: offset of its centre from the pupil centre, in pupil radii. + #[serde(default)] + pub catchlight: Option, +} + +impl Default for RedEye { + fn default() -> Self { + Self { center: Point::new(0.5, 0.5), rx: 0.02, ry: 0.015, pupil_size: 50.0, darken: 50.0, pet: false, catchlight: None } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index dcaab9690..a514ecf25 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -21,11 +21,12 @@ lightcraft-preview = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raw = { workspace = true } lightcraft-meta = { workspace = true } +lightcraft-merge = { workspace = true } +rayon = { workspace = true } 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 c1cb9040f..ac6a9a60d 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -158,7 +158,7 @@ fn main() { let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box)); let s = typical(); let view = RenderRequest::fit(1920, 1280); - let draft = RenderRequest { max_w: 1152, max_h: 768, quality: Quality::Draft, apply_crop: true }; + let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(1152, 768) }; if gpu { let c = develop(&preview, &info, &s, &view, None, false).image; let g = develop(&preview, &info, &s, &view, None, true).image; diff --git a/crates/engine/src/cmd/color.rs b/crates/engine/src/cmd/color.rs new file mode 100644 index 000000000..65a9a709e --- /dev/null +++ b/crates/engine/src/cmd/color.rs @@ -0,0 +1,209 @@ +//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool +//! (`develop.targeted`: a vertical drag on the photo raises/lowers the tone-curve region or the +//! colour-mixer bands under the pointer; the UI sends one call per drag step inside an interaction). +//! +//! Sampling renders a small proxy with every stage *after* the sampled one neutralized, so the +//! picked colour is the one the adjustment will see (e.g. Point Color samples after the colour +//! mixer, before vibrance, grading, vignette and curves). + +use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; +use lightcraft_color::spline::MonotoneCurve; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, MIXER_BANDS, PointColor, ToneCurve, Treatment, controls}; +use lightcraft_geom::Point; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, f64_req, has_active}; +use crate::media::SourceLevel; +use crate::{Result, Session}; + +/// Long edge of the proxy rendered for sampling. +const PROBE_EDGE: usize = 384; + +/// The sRGB-encoded colour (0..1) at normalized image point `(x, y)` of the active photo rendered +/// with its settings changed by `neutral`, averaged over 3 × 3 proxy pixels. +pub(crate) fn probe(s: &mut Session, c: &str, x: f64, y: f64, neutral: impl FnOnce(&mut DevelopSettings)) -> Result<[f32; 3]> { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad(c, e))?; + let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default(); + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + neutral(&mut d); + let req = lightcraft_pipeline::RenderRequest::fit(PROBE_EDGE, PROBE_EDGE); + let plan = lightcraft_pipeline::plan(&src, &info, &d, &req); + let q = plan.frame.norm_to_out(plan.w, plan.h).apply(Point::new(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0))); + let img = lightcraft_pipeline::render(&src, &info, &d, &req).image; + let (cx, cy) = (q.x.floor() as isize, q.y.floor() as isize); + let mut acc = [0.0f32; 3]; + let mut n = 0.0; + for dy in -1..=1 { + for dx in -1..=1 { + let (px, py) = ((cx + dx).clamp(0, img.width as isize - 1) as usize, (cy + dy).clamp(0, img.height as isize - 1) as usize); + let p = img.data[py * img.width + px]; + for k in 0..3 { + acc[k] += p[k] as f32 / 255.0; + } + n += 1.0; + } + } + Ok(acc.map(|v| v / n)) +} + +/// sRGB-encoded → OkLCh (lightness, chroma, hue in radians) via linear Rec.2020. +pub(crate) fn encoded_to_oklch(e: [f32; 3]) -> [f32; 3] { + let lin = SRGB.to_space(&REC2020).apply_f32(e.map(srgb_to_linear)); + lab_to_lch(oklab_from_2020(lin)) +} + +/// Everything after the colour mixer, neutralized (the colour Point Color sees). +pub(crate) fn after_mixer_neutral(d: &mut DevelopSettings) { + d.color.vibrance = 0.0; + d.color.saturation = 0.0; + d.point_colors.clear(); + d.treatment = Treatment::Color; + d.grading = Default::default(); + d.vignette.amount = 0.0; + d.grain.amount = 0.0; + d.curve = Default::default(); +} + +/// The targeted adjustment tool: the controls (and their new values) a vertical drag of `delta` +/// at `(x, y)` changes, computed on `d` (applied by the caller). +fn targeted(s: &mut Session, p: &Value) -> Result { + let c = "develop.targeted"; + let (x, y, delta) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?, f64_req(p, "delta", c)?); + let target = p.get("target").and_then(Value::as_str).unwrap_or("curve").to_string(); + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + let mut changed = serde_json::Map::new(); + fn nudge(changed: &mut serde_json::Map, d: &mut DevelopSettings, ctl: &str, by: f64) { + let v = controls::get(d, ctl).unwrap_or(0.0) + by; + controls::set(d, ctl, v); + changed.insert(ctl.to_string(), json!(controls::get(d, ctl))); + } + let label; + match target.as_str() { + "curve" => { + // the encoded value the tone curve sees there (curves and grain neutralized) + let e = probe(s, c, x, y, |d| { + d.curve = ToneCurve { refine_saturation: d.curve.refine_saturation, ..Default::default() }; + d.grain.amount = 0.0; + })?; + let channel = p.get("channel").and_then(Value::as_str).unwrap_or("parametric"); + label = "Tone Curve"; + if channel == "parametric" { + let v = (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]) as f64 * 100.0; + let cv = &d.curve; + let region = if v < cv.split_shadows { + "curve.shadows" + } else if v < cv.split_mid { + "curve.darks" + } else if v < cv.split_highlights { + "curve.lights" + } else { + "curve.highlights" + }; + nudge(&mut changed, &mut d, region, delta); + } else { + // point curve: move the point at the sampled input up/down (or add one there) + let (pts, v) = match channel { + "red" => (&mut d.curve.red, e[0]), + "green" => (&mut d.curve.green, e[1]), + "blue" => (&mut d.curve.blue, e[2]), + _ => (&mut d.curve.master, 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]), + }; + let v = v as f64; + if pts.is_empty() { + *pts = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)]; + } + let curve = MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::>()); + let dy = delta / 255.0; + match pts.iter().position(|q| (q.x - v).abs() < 0.03) { + Some(i) => pts[i].y = (pts[i].y + dy).clamp(0.0, 1.0), + None => { + let i = pts.iter().position(|q| q.x > v).unwrap_or(pts.len()); + pts.insert(i, Point::new(v, (curve.eval(v) + dy).clamp(0.0, 1.0))); + } + } + changed.insert(format!("curve.{channel}"), json!(pts.iter().map(|q| [q.x, q.y]).collect::>())); + } + } + "hue" | "sat" | "lum" => { + // the hue the colour mixer sees there (the mixer and everything after it neutralized) + let e = probe(s, c, x, y, |d| { + d.mixer = Default::default(); + d.bw_mix = Default::default(); + after_mixer_neutral(d); + })?; + let [_, _, h] = encoded_to_oklch(e); + let w = lightcraft_pipeline::colorops::band_weights(h); + let top = w.iter().cloned().fold(0.0f32, f32::max).max(1e-6); + let bw = d.treatment == Treatment::Bw || d.profile.id == "lc.mono"; + label = if bw { "B&W Mix" } else { "Color Mixer" }; + for (i, band) in MIXER_BANDS.iter().enumerate() { + let k = (w[i] / top) as f64; + if k < 0.05 { + continue; + } + let ctl = if bw { format!("bw.{band}") } else { format!("mixer.{band}.{target}") }; + nudge(&mut changed, &mut d, &ctl, delta * k); + } + } + _ => return Err(bad(c, "target must be curve, hue, sat or lum")), + } + s.set_develop(id, d, label)?; + Ok(Value::Object(changed)) +} + +fn index(p: &Value, c: &str) -> Result { + Ok(f64_req(p, "index", c)? as usize) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "pointColor.pick", + "Add Point Color Sample", + [], + None, + "{x, y} normalized image coords — samples the colour there (max 8); returns {index, lum, chroma, hue}", + has_active, + |s, p| { + let c = "pointColor.pick"; + let (x, y) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?); + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + if s.develop_of(id).is_some_and(|d| d.point_colors.len() >= MAX_POINT_COLORS) { + return Err(bad(c, format!("at most {MAX_POINT_COLORS} point colours"))); + } + let [l, ch, h] = encoded_to_oklch(probe(s, c, x, y, after_mixer_neutral)?); + let sample = PointColor { lum: l as f64, chroma: ch as f64, hue: (h as f64).to_degrees().rem_euclid(360.0), ..Default::default() }; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + d.point_colors.push(sample); + let i = d.point_colors.len() - 1; + s.set_develop(id, d, "Point Color")?; + Ok(json!({"index": i, "lum": sample.lum, "chroma": sample.chroma, "hue": sample.hue})) + } + ), + cmd!( + "develop.targeted", + "Targeted Adjustment", + [], + None, + "{target: curve|hue|sat|lum, x, y (normalized image coords), delta (slider units; point curves: output levels 0..255), channel?: parametric|master|red|green|blue (curve)} — adjusts the curve region / colour-mixer bands under the point; returns the changed controls", + has_active, + targeted + ), + cmd!("pointColor.delete", "Delete Point Color Sample", [], None, "{index}", has_active, |s, p| { + let c = "pointColor.delete"; + let i = index(p, c)?; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + if i >= d.point_colors.len() { + return Err(bad(c, "no such sample")); + } + d.point_colors.remove(i); + s.set_develop(id, d, "Delete Point Color")?; + Ok(Value::Null) + }), + ] +} diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index b7b790670..7b805ae2a 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -330,6 +330,28 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "crop.autoStraighten", + "Auto Straighten", + [], + None, + "{} — level the horizon (or plumb lines) with the crop angle", + has_active, + |s, _| { + let id = active(s, "crop.autoStraighten")?; + let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?; + let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default(); + let d = s.develop_of(id).unwrap_or_default(); + let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else { + return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"})); + }; + // Same convention as the crop angle (verified: a horizon descending 6° to the right + // comes out level with angle = level ≈ −6°). + let angle = level.clamp(-45.0, 45.0); + s.execute("crop.straighten", &json!({"angle": (angle * 100.0).round() / 100.0}))?; + Ok(json!({"changed": true, "angle": angle})) + } + ), cmd!( "crop.aspect", "Crop Aspect", diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 5030286e4..4cbfc22d3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -292,25 +292,63 @@ pub fn specs() -> Vec { } ), // ---- albums + 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.create", - "New Album", - ["File"], - Some("Cmd+Shift+N"), - "{name, parent?: folderId, folder?: bool, addSelected?: bool}", + "album.createSmart", + "New Smart Album…", + [], + None, + "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)", always, |s, p| { - let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); + let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string(); if name.is_empty() { - return Err(bad("album.create", "empty name")); + return Err(bad("album.createSmart", "empty name")); } - let folder = bool_or(p, "folder", false); + let rules = match p.get("rules") { + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?, + None => view_rules(s), + }; 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 } })?; - Ok(json!({"id": id.0})) + let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) }; + s.commit("New Smart Album", Op::AddAlbum { album })?; + Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) + } + ), + cmd!( + "album.setRules", + "Edit Smart Album", + [], + None, + "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}", + always, + |s, p| { + let id = album_param(p, "id", "album.setRules")?; + let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; + let rules = if bool_or(p, "fromView", false) { + view_rules(s) + } else { + let base = if bool_or(p, "replace", false) { Default::default() } else { cur }; + merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")? + }; + s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?; + Ok(json!({"count": s.catalog.album_count(id)})) } ), cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| { @@ -337,6 +375,9 @@ pub fn specs() -> Vec { let id = album_param(p, "id", "album.addPhotos")?; let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); + } let mut photos = al.photos.clone(); let before = photos.len(); for t in targets { @@ -353,6 +394,9 @@ pub fn specs() -> Vec { let id = album_param(p, "id", "album.removePhotos")?; let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; + if al.is_smart() { + return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + } let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; ok() @@ -452,6 +496,51 @@ pub fn specs() -> Vec { ] } +/// `base` with a partial Filter (JSON) merged on top. +fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { + let mut v = serde_json::to_value(base).unwrap_or_default(); + lightcraft_develop::presets::deep_merge(&mut v, patch); + serde_json::from_value(v).map_err(|e| bad(c, e.to_string())) +} + +/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from +/// its rules with the filter bar's settings on top. +fn view_rules(s: &Session) -> lightcraft_catalog::Filter { + use lightcraft_catalog::Filter; + let smart = match s.source { + LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()), + _ => None, + }; + match smart { + Some(base) => { + // overlay the fields the filter bar changed + let cur = serde_json::to_value(&s.filter).unwrap_or_default(); + let def = serde_json::to_value(Filter::default()).unwrap_or_default(); + let mut patch = serde_json::Map::new(); + if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) { + for (k, v) in c { + if d.get(k) != Some(v) { + patch.insert(k.clone(), v.clone()); + } + } + } + merge_rules(&base, &Value::Object(patch), "").unwrap_or(base) + } + None => { + let mut f = s.source.to_filter(&s.filter, &s.catalog); + f.deleted = false; + f + } + } +} + +impl Session { + /// The current view (source + filter) as smart-album rules (see `album.createSmart`). + pub fn view_rules(&self) -> lightcraft_catalog::Filter { + view_rules(self) + } +} + fn has_library(s: &Session) -> std::result::Result<(), String> { if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) } } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 70e1f759a..0db1c58c4 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -1,6 +1,6 @@ //! Masking and Remove (spot) commands on the active photo. -use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, Spot, SpotMode}; +use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; use lightcraft_geom::Point; use serde_json::{Value, json}; @@ -292,6 +292,69 @@ pub fn specs() -> Vec { s.set_develop(id, dd, "Delete Spot")?; ok() }), + // ---- Red eye / pet eye + cmd!( + "redeye.add", + "Add Red Eye Correction", + [], + None, + "{center: [x,y] normalized, rx, ry: radii as fractions of the long edge, pet?: bool, pupilSize?: 0..100, darken?: 0..100} — the pupil inside is found automatically; returns {index}", + has_active, + |s, p| { + let c = "redeye.add"; + let center = point(p, "center").ok_or_else(|| bad(c, "missing center"))?; + let d = RedEye::default(); + let eye = RedEye { + center, + rx: f64_or(p, "rx", d.rx).clamp(1e-4, 0.5), + ry: f64_or(p, "ry", d.ry).clamp(1e-4, 0.5), + pupil_size: f64_or(p, "pupilSize", d.pupil_size).clamp(0.0, 100.0), + darken: f64_or(p, "darken", d.darken).clamp(0.0, 100.0), + pet: bool_or(p, "pet", false), + catchlight: None, + }; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + dd.red_eye.push(eye); + let n = dd.red_eye.len(); + s.set_develop(id, dd, if eye.pet { "Pet Eye" } else { "Red Eye" })?; + Ok(json!({"index": n - 1})) + } + ), + cmd!( + "redeye.catchlight", + "Pet Eye Catchlight", + [], + None, + "{index, on?: bool (default true), offset?: [dx, dy] from the pupil centre in pupil radii (default [-0.35, -0.35])}", + has_active, + |s, p| { + let c = "redeye.catchlight"; + let i = super::f64_req(p, "index", c)? as usize; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?; + if !eye.pet { + return Err(bad(c, "catchlights are for pet eyes")); + } + let off = point(p, "offset").or(eye.catchlight).unwrap_or(Point::new(-0.35, -0.35)); + eye.catchlight = bool_or(p, "on", true).then(|| Point::new(off.x.clamp(-1.0, 1.0), off.y.clamp(-1.0, 1.0))); + s.set_develop(id, dd, "Catchlight")?; + ok() + } + ), + cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| { + let c = "redeye.delete"; + let i = super::f64_req(p, "index", c)? as usize; + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); + if i >= dd.red_eye.len() { + return Err(bad(c, "no such eye")); + } + dd.red_eye.remove(i); + s.set_develop(id, dd, "Delete Red Eye")?; + ok() + }), ] } diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs new file mode 100644 index 000000000..102ff0d3f --- /dev/null +++ b/crates/engine/src/cmd/merge.rs @@ -0,0 +1,72 @@ +//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`. +//! +//! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with +//! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames +//! and (with `previewPath`) writes the rendered preview as PNG instead of adding a photo. + +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, bool_or, cmd, str_param}; +use crate::{Result, Session}; + +fn can_merge(s: &Session) -> std::result::Result<(), String> { + if cfg!(target_arch = "wasm32") && s.media.file_bytes.is_none() { + return Err("Photo Merge needs access to the photo files".into()); + } + // the photo count is checked when run (an explicit `ids` parameter may name them) + if s.catalog.photos().nth(1).is_none() { Err("the library has fewer than 2 photos".into()) } else { Ok(()) } +} + +fn run(s: &mut Session, id: &str, p: &Value) -> Result { + let (kind, finish) = crate::merge::parse(id, p)?; + let ids: Vec = s.targets(p); + if !bool_or(p, "preview", false) { + return s.merge_now(kind, finish, &ids); + } + let job = s.plan_merge(kind, finish, &ids, true)?; + let out = job.run(&|_, _| true).map_err(|e| bad(id, e))?; + let mut info = out.info; + if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) { + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()) + .map_err(|e| bad(id, e.to_string()))?; + std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?; + info["previewPath"] = json!(path); + } + if let Some(img) = &out.preview { + info["previewSize"] = json!([img.width, img.height]); + } + Ok(info) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "merge.hdr", + "HDR Merge", + [], + None, + "{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}", + can_merge, + |s, p| run(s, "merge.hdr", p) + ), + cmd!( + "merge.panorama", + "Panorama Merge", + [], + None, + "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}", + can_merge, + |s, p| run(s, "merge.panorama", p) + ), + cmd!( + "merge.hdrPanorama", + "HDR Panorama Merge", + [], + None, + "{ids?, bracket=0 (auto from EXIF), align, deghost, projection, boundaryWarp, autoCrop, fillEdges, autoSettings, stack, preview, previewPath?}", + can_merge, + |s, p| run(s, "merge.hdrPanorama", p) + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3e1b8c540..8a385ec56 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,10 +4,13 @@ //! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`, //! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…). +mod color; mod develop; mod edit; mod library; mod masks; +mod merge; +mod organize; mod preset_files; mod query; mod xmp; @@ -101,7 +104,10 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(library::specs()); v.extend(develop::specs()); + v.extend(color::specs()); v.extend(masks::specs()); + v.extend(organize::specs()); + v.extend(merge::specs()); v.extend(query::specs()); v.extend(xmp::specs()); v.extend(preset_files::specs()); diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs new file mode 100644 index 000000000..255fc0888 --- /dev/null +++ b/crates/engine/src/cmd/organize.rs @@ -0,0 +1,207 @@ +//! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture +//! time) and virtual copies. + +use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection}; +use crate::{Result, Selection, Session}; + +/// Stacks containing any of `targets`, in first-seen order. +fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec { + let mut v = Vec::new(); + for t in targets { + if let Some(st) = s.catalog.stack_of(*t) + && !v.contains(&st.id) + { + v.push(st.id); + } + } + v +} + +fn in_stack(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } +} + +fn has_stacks(s: &Session) -> std::result::Result<(), String> { + if s.catalog.stacks().next().is_none() { Err("no stacks".into()) } else { Ok(()) } +} + +fn commit_ops(s: &mut Session, label: &str, ops: Vec) -> Result { + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(n) +} + +/// After collapsing, keep the active photo visible (move it to its stack's top). +fn fix_active(s: &mut Session) { + let vis = s.visible_cloned(); + if let Some(a) = s.selection.active + && !vis.contains(&a) + && let Some(st) = s.catalog.stack_of(a) + { + let top = vis.iter().copied().find(|p| st.photos.contains(p)).unwrap_or(st.top()); + s.selection = Selection::single(top); + } +} + +fn set_collapsed(s: &mut Session, stacks: Vec, collapsed: bool, label: &str) -> Result { + let ops = s.catalog.set_collapsed_ops(&stacks, collapsed); + let n = commit_ops(s, label, ops)?; + if collapsed { + fix_active(s); + } + Ok(json!({"changed": n})) +} + +/// "Copy 3" → 3. +fn copy_number(name: Option<&str>) -> u32 { + name.and_then(|n| n.strip_prefix("Copy ")).and_then(|n| n.trim().parse().ok()).unwrap_or(0) +} + +/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and +/// metadata, added to the same albums and stacked right after it (the stack is expanded so the +/// copy is visible). +fn virtual_copy(s: &mut Session, src: PhotoId) -> Result> { + let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) }; + let master = orig.copy_of.unwrap_or(src); + let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0); + let id = s.catalog.alloc_photo_id(); + let mut c = (*orig).clone(); + c.id = id; + c.copy_of = Some(master); + c.copy_name = Some(format!("Copy {n}")); + c.versions.clear(); + c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }]; + c.deleted = false; + let mut ops = vec![Op::AddPhoto { photo: Box::new(c) }]; + for a in s.catalog.albums().filter(|a| !a.is_smart() && a.photos.contains(&src)) { + let mut photos = a.photos.clone(); + let at = photos.iter().position(|p| *p == src).map_or(photos.len(), |i| i + 1); + photos.insert(at, id); + ops.push(Op::SetAlbumPhotos { id: a.id, photos }); + } + match s.catalog.stack_of(src).cloned() { + Some(st) => { + let mut photos = st.photos.clone(); + let at = st.position(src).map_or(photos.len(), |i| i + 1); + photos.insert(at, id); + ops.push(Op::SetStack { id: st.id, photos, collapsed: false }); + } + None => { + let sid = s.catalog.alloc_stack_id(); + ops.push(Op::AddStack { stack: Stack { id: sid, photos: vec![src, id], collapsed: false } }); + } + } + s.commit("Create Virtual Copy", Op::Batch { ops })?; + Ok(Some(id)) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "photo.virtualCopy", + "Create Virtual Copy", + ["Photo"], + Some("Cmd+'"), + "{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"", + has_selection, + |s, p| { + let targets = s.targets(p); + let mut made = Vec::new(); + for t in targets { + if let Some(id) = virtual_copy(s, t)? { + made.push(id); + } + } + s.merge_undo(made.len(), "Create Virtual Copy"); + if let Some(last) = made.last() { + s.selection = Selection { ids: made.clone(), active: Some(*last) }; + } + Ok(json!({"ids": made.iter().map(|i| i.0).collect::>()})) + } + ), + cmd!( + "stack.group", + "Group into Stack", + ["Photo", "Stack"], + Some("Cmd+G"), + "{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged", + has_selection, + |s, p| { + let targets = s.targets(p); + let top = p + .get("top") + .and_then(Value::as_u64) + .map(PhotoId) + .or(s.selection.active.filter(|a| targets.contains(a))) + .or(targets.first().copied()) + .ok_or_else(|| bad("stack.group", "no photos"))?; + let op = s + .catalog + .group_ops(top, &targets, bool_or(p, "collapsed", true)) + .ok_or_else(|| bad("stack.group", "select at least two photos"))?; + s.commit("Group into Stack", op)?; + let st = s.catalog.stack_of(top).map(|st| (st.id.0, st.photos.len())).unwrap_or_default(); + s.selection = Selection::single(top); + Ok(json!({"stack": st.0, "count": st.1})) + } + ), + cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| { + let ops = s.catalog.ungroup_ops(&s.targets(p)); + Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?})) + }), + cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| { + let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)); + Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) + }), + cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?; + let op = s.catalog.set_top_ops(id); + Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?})) + }), + cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { + let stacks = stacks_of(s, &s.targets(p)); + let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); + let collapsed = bool_or(p, "collapsed", !first); + set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) + }), + cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { + let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); + set_collapsed(s, all, false, "Expand All Stacks") + }), + cmd!("stack.collapseAll", "Collapse All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { + let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); + set_collapsed(s, all, true, "Collapse All Stacks") + }), + cmd!( + "stack.auto", + "Auto-Stack by Capture Time", + [], + None, + "{gap?: seconds between consecutive captures (default 60), ids? (default: the selection if several photos, else the current view), preview?: bool} → {stacks, photos}", + always, + |s, p| { + let gap = f64_or(p, "gap", 60.0).max(0.0); + let explicit = p.get("ids").is_some(); + let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let groups = s.catalog.auto_stack_groups(&pool, gap); + let photos: usize = groups.iter().map(Vec::len).sum(); + if bool_or(p, "preview", false) { + return Ok(json!({"stacks": groups.len(), "photos": photos})); + } + let mut ops = Vec::new(); + for g in &groups { + ops.extend(s.catalog.group_ops(g[0], g, true)); + } + commit_ops(s, "Auto-Stack", ops)?; + fix_active(s); + Ok(json!({"stacks": groups.len(), "photos": photos})) + } + ), + ] +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 964a0d8d0..f77ef1258 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -24,18 +24,26 @@ pub fn photo_summary(p: &Photo) -> Value { "keywords": p.meta.keywords, "camera": p.meta.camera, "deleted": p.deleted, + "copyOf": p.copy_of.map(|c| c.0), + "copyName": p.copy_name, }) } -fn album_json(a: &Album, all: &[Album]) -> Value { - json!({ +fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value { + let mut v = json!({ "id": a.id.0, "name": a.name, "folder": a.folder, - "count": a.photos.len(), + "count": cat.album_count(a.id), "cover": a.cover.map(|c| c.0), - "children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all)).collect::>(), - }) + "children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::>(), + }); + if let Some(rules) = &a.smart { + v["smart"] = json!(true); + v["rules"] = serde_json::to_value(rules).unwrap_or_default(); + v["rulesText"] = json!(rules.describe()); + } + v } fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result { @@ -88,7 +96,7 @@ pub fn specs() -> Vec { }), cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| { let all: Vec = s.catalog.albums().cloned().collect(); - Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all)).collect())) + Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect())) }), cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| { let id = photo_arg(s, p, "photo.inspect")?; @@ -96,6 +104,14 @@ pub fn specs() -> Vec { let mut v = serde_json::to_value(ph.as_ref()).unwrap_or_default(); v["albums"] = json!(s.catalog.albums_of(id).iter().map(|a| a.0).collect::>()); v["history"] = json!(ph.history.iter().map(|h| h.label.clone()).collect::>()); + if let Some(st) = s.catalog.stack_of(id) { + v["stack"] = json!({ + "id": st.id.0, + "photos": st.photos.iter().map(|p| p.0).collect::>(), + "position": st.position(id), + "collapsed": st.collapsed, + }); + } Ok(v) }), cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| { @@ -114,6 +130,13 @@ pub fn specs() -> Vec { v["value"] = json!(controls::get(&d, c.id)); v }) + // indexed controls of the list elements that exist (`pointColor.0.hueShift`, …) + .chain(controls::indexed_instances(&d).into_iter().filter(|(_, c)| sec.is_none_or(|x| x == c.section)).map(|(id, c)| { + let mut v = serde_json::to_value(c).unwrap_or_default(); + v["value"] = json!(controls::get(&d, &id)); + v["id"] = json!(id); + v + })) .collect(), )) }), diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index d35641e7e..6dfaaa93c 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -190,6 +190,7 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "ParametricShadowSplit", "curve.split_shadows"); n(o, "ParametricMidtoneSplit", "curve.split_mid"); n(o, "ParametricHighlightSplit", "curve.split_highlights"); + n(o, "CurveRefineSaturation", "curve.refine_saturation"); for (crs, ch) in [("ToneCurvePV2012", "master"), ("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] { @@ -261,6 +262,15 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "DefringeGreenHueLo", "optics.defringe_green_hue_lo"); n(o, "DefringeGreenHueHi", "optics.defringe_green_hue_hi"); + // ---- Calibration + n(o, "ShadowTint", "calibration.shadows_tint"); + n(o, "RedHue", "calibration.red_hue"); + n(o, "RedSaturation", "calibration.red_sat"); + n(o, "GreenHue", "calibration.green_hue"); + n(o, "GreenSaturation", "calibration.green_sat"); + n(o, "BlueHue", "calibration.blue_hue"); + n(o, "BlueSaturation", "calibration.blue_sat"); + // ---- Geometry n(o, "PerspectiveVertical", "geometry.vertical"); n(o, "PerspectiveHorizontal", "geometry.horizontal"); @@ -330,13 +340,15 @@ mod tests { crs:Whites2012="+9" crs:Blacks2012="-17" crs:Texture="+6" crs:Clarity2012="+11" crs:Dehaze="+4" crs:Vibrance="+19" crs:Saturation="-3" crs:HueAdjustmentOrange="-7" crs:SaturationAdjustmentBlue="-22" crs:LuminanceAdjustmentAqua="+13" crs:ParametricShadows="+5" crs:ParametricHighlights="-8" - crs:ParametricMidtoneSplit="55" crs:SplitToningShadowHue="210" crs:SplitToningShadowSaturation="14" + crs:ParametricMidtoneSplit="55" crs:CurveRefineSaturation="40" crs:SplitToningShadowHue="210" crs:SplitToningShadowSaturation="14" crs:SplitToningHighlightHue="40" crs:SplitToningHighlightSaturation="9" crs:SplitToningBalance="+20" crs:ColorGradeMidtoneHue="120" crs:ColorGradeMidtoneSat="6" crs:ColorGradeBlending="70" crs:Sharpness="55" crs:SharpenRadius="+1.2" crs:SharpenDetail="30" crs:SharpenEdgeMasking="12" crs:LuminanceSmoothing="18" crs:ColorNoiseReduction="25" crs:PostCropVignetteAmount="-21" crs:PostCropVignetteStyle="2" crs:GrainAmount="15" crs:GrainSize="30" crs:GrainFrequency="60" crs:PerspectiveUpright="2" crs:PerspectiveVertical="-10" crs:AutoLateralCA="1" crs:ConvertToGrayscale="False" + crs:ShadowTint="-7" crs:RedHue="+12" crs:RedSaturation="+20" crs:GreenHue="-5" crs:GreenSaturation="+3" + crs:BlueHue="-15" crs:BlueSaturation="+25" crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.05" crs:CropBottom="0.9" crs:CropRight="0.8" crs:CropAngle="1.5"> 0, 064, 52192, 205255, 255 @@ -369,6 +381,7 @@ mod tests { assert_eq!(s.mixer.aqua.lum, 13.0); assert_eq!((s.curve.shadows, s.curve.highlights, s.curve.split_mid), (5.0, -8.0, 55.0)); assert_eq!(s.curve.master.len(), 4); + assert_eq!(s.curve.refine_saturation, 40.0); assert!((s.curve.master[1].x - 64.0 / 255.0).abs() < 1e-9 && (s.curve.master[1].y - 52.0 / 255.0).abs() < 1e-9); assert!(s.curve.red.is_empty(), "identity channel curve maps to no curve"); assert_eq!((s.grading.shadows.hue, s.grading.shadows.sat), (210.0, 14.0)); @@ -384,6 +397,11 @@ mod tests { assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); + let cal = s.calibration; + assert_eq!( + (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), + (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) + ); let c = s.crop.geometry; assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); } diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 9c2358f8b..7b4951c75 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -61,7 +61,9 @@ pub fn load(s: &mut Session) { ops.push(Op::AddPhoto { photo: Box::new(p) }); } let folder = s.catalog.alloc_album_id(); - ops.push(Op::AddAlbum { album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None } }); + ops.push(Op::AddAlbum { + album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None }, + }); let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() }; for (name, parent, photos) in [ ("Mountains", Some(folder), by_kw("mountains")), @@ -73,7 +75,7 @@ pub fn load(s: &mut Session) { ] { let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); - ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover } }); + ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None } }); } for op in ops { let _ = s.catalog.apply(op); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6fcddef33..291ec8db7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod import; pub mod library; pub mod media; pub mod memory; +pub mod merge; pub mod presets; pub mod sidecar; mod view; @@ -95,6 +96,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 { @@ -127,6 +130,7 @@ impl Session { active_mask: None, library: None, xmp: sidecar::XmpPrefs::default(), + last_export: None, } } @@ -183,6 +187,17 @@ impl Session { Ok(()) } + /// Fold the last `n` undo steps into one (commands that commit step by step because each op + /// depends on the state the previous one left). + pub fn merge_undo(&mut self, n: usize, label: &str) { + if n < 2 || n > self.undo.len() { + return; + } + let tail = self.undo.split_off(self.undo.len() - n); + let ops = tail.into_iter().rev().map(|e| e.op).collect(); + self.undo.push(UndoEntry { label: label.to_string(), op: Op::Batch { ops } }); + } + /// Apply without recording undo (interactive previews). fn apply_silent(&mut self, op: Op) -> Result<()> { self.catalog.apply(op)?; @@ -286,6 +301,7 @@ impl Session { && self.sort.key == lightcraft_catalog::SortKey::CaptureDate && let LibrarySource::Album(a) = self.source && let Some(al) = self.catalog.album(a) + && !al.is_smart() && self.filter == Filter::default() { let order = al.photos.clone(); @@ -294,6 +310,9 @@ impl Session { self.visible.reverse(); } } + if self.source != LibrarySource::RecentlyDeleted { + self.visible = self.catalog.arrange_stacks(&self.visible); + } self.visible_key = Some(key); } &self.visible @@ -318,8 +337,14 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_color; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_library; #[cfg(test)] +mod tests_merge; +#[cfg(test)] +mod tests_organize; +#[cfg(test)] mod tests_xmp; 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 49d7a6db2..77ff7e815 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -106,6 +106,8 @@ pub struct MediaCache { pub file_loader: Option, pub file_probe: Option, pub preview_loader: Option, + /// Reads a photo file's bytes (Photo Merge); `None` = the local file system. + pub file_bytes: Option, scenes: Vec, /// Rendered thumbnails (memory, plus disk once a library is attached). pub rendered: Arc, @@ -123,6 +125,7 @@ impl Default for MediaCache { file_loader: None, file_probe: None, preview_loader: None, + file_bytes: None, scenes: Vec::new(), rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), } @@ -321,6 +324,21 @@ impl RenderJob { self } + /// Draw a diagnostic overlay over the result (e.g. Point Color's visualized range). Gets its + /// own result key. + pub fn with_overlay(mut self, overlay: lightcraft_pipeline::Overlay) -> Self { + if self.request.overlay != overlay { + self.key ^= self.request.overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + self.request.overlay = overlay; + self.key ^= overlay.key().wrapping_mul(0x9e37_79b9_7f4a_7c15); + } + // a diagnostic view (visualized range / spots) must never become the photo's cached preview + if overlay != lightcraft_pipeline::Overlay::None { + self.view_cache = None; + } + self + } + /// Reuse `stages` across this view's renders. pub fn with_stages(mut self, stages: Arc) -> Self { self.stages = Some(stages); @@ -483,8 +501,15 @@ impl crate::Session { let level = SourceLevel::for_size(max_w.max(max_h)); let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() }; - let request = RenderRequest { max_w, max_h, quality: Quality::Full, apply_crop }; - let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60; + let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; + // the photo id is part of the key: two photos with the same settings and size must not + // share a result (a view slot showing photo A would otherwise look current for photo B) + let key = settings.hash64() + ^ ((max_w as u64) << 40) + ^ ((max_h as u64) << 20) + ^ (apply_crop as u64) + ^ (level as u64) << 60 + ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15); let cache = thumb_bucket.map(|b| { 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) @@ -672,5 +697,9 @@ mod tests { let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); assert_eq!(loupe.level, SourceLevel::Preview); assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); + // two photos with identical settings and size never share a result key + let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); + let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); + assert_ne!(a.key, b.key); } } diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs new file mode 100644 index 000000000..c7bab82f5 --- /dev/null +++ b/crates/engine/src/merge.rs @@ -0,0 +1,413 @@ +//! Photo Merge (HDR, Panorama, HDR Panorama) in the library. +//! +//! A merge is planned from the session ([`crate::Session::plan_merge`] → [`MergeJob`], which is +//! `Send` and self-contained), run anywhere (a worker thread in the app, inline for the CLI/MCP: +//! [`MergeJob::run`], with progress and cancellation), and finished on the session +//! ([`crate::Session::finish_merge`]): the DNG is written next to the first source photo +//! (`-HDR.dng`, `-Pano.dng`, `-HDR-Pano.dng`), imported, selected, and given Auto Settings / +//! the auto-crop rectangle when asked. A *preview* run works on ≤ 1024 px frames and returns a +//! rendered image (with the deghost overlay when asked) instead of a file. + +use std::sync::Arc; + +use lightcraft_catalog::{PhotoId, Source}; +use lightcraft_develop::DevelopSettings; +use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection}; +use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo}; +use lightcraft_raster::{Rgb32f, Rgba8}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{EngineError, Result, Session}; + +/// Long edge of the frames of a preview merge. +pub const PREVIEW_EDGE: usize = 1024; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MergeKind { + Hdr { + hdr: HdrOptions, + }, + Panorama { + pano: PanoOptions, + }, + /// Merge each bracket (`bracket` consecutive photos; 0 = detect from EXIF), then stitch. + HdrPanorama { + hdr: HdrOptions, + pano: PanoOptions, + bracket: usize, + }, +} + +impl MergeKind { + fn suffix(&self) -> &'static str { + match self { + MergeKind::Hdr { .. } => "HDR", + MergeKind::Panorama { .. } => "Pano", + MergeKind::HdrPanorama { .. } => "HDR-Pano", + } + } + fn min_photos(&self) -> usize { + match self { + MergeKind::HdrPanorama { .. } => 4, + _ => 2, + } + } +} + +/// Options that apply after the merge. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MergeFinish { + /// Run Auto Settings on the result. + pub auto_settings: bool, + /// Stack the result on top of its sources (collapsed); the result's caption also lists them. + pub stack: bool, + /// Preview only: tint deghosted areas red. + pub show_overlay: bool, +} + +/// Reads a source photo's bytes (by catalog path). +pub type ByteReader = Arc std::result::Result, String> + Send + Sync>; + +/// A planned merge, detached from the session. +#[derive(Clone)] +pub struct MergeJob { + pub kind: MergeKind, + pub finish: MergeFinish, + pub sources: Vec<(PhotoId, String)>, + /// `Some(edge)`: preview at that frame size; `None`: the full-resolution merge. + pub preview: Option, + pub read: ByteReader, +} + +/// What a merge produced. +pub struct MergeOutput { + /// The DNG (also for previews, at preview size). + pub dng: Vec, + /// Rendered preview (previews only). + pub preview: Option, + /// Auto-crop rectangle (normalised x, y, w, h) for panoramas. + pub crop: Option<[f64; 4]>, + /// Statistics for the caller (exposures, alignment, projection, …). + pub info: Value, +} + +pub type ProgressFn<'a> = dyn Fn(f32, &str) -> bool + Sync + 'a; + +fn err(e: impl std::fmt::Display) -> String { + e.to_string() +} + +impl MergeJob { + fn load(&self, orient: bool, progress: &ProgressFn, span: (f32, f32)) -> std::result::Result, String> { + use rayon::prelude::*; + let done = std::sync::atomic::AtomicUsize::new(0); + let n = self.sources.len(); + let frames: std::result::Result, String> = self + .sources + .par_iter() + .map(|(_, path)| { + let bytes = (self.read)(path)?; + let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}")); + let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); + f + }) + .collect(); + frames + } + + /// Run the merge. `progress(fraction, stage)` returns false to cancel. + pub fn run(&self, progress: &ProgressFn) -> std::result::Result { + if self.sources.len() < self.kind.min_photos() { + return Err(format!("select at least {} photos", self.kind.min_photos())); + } + if !progress(0.0, "Reading photos") { + return Err("cancelled".into()); + } + let sub = |a: f32, b: f32| move |f: f32, s: &str| progress(a + (b - a) * f, s); + let (image, color, orientation, metadata, baseline, samples, crop, mut info, ghost) = match &self.kind { + MergeKind::Hdr { hdr } => { + let frames = self.load(false, progress, (0.0, 0.3))?; + let p = sub(0.3, 0.9); + let r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?; + let info = json!({ + "reference": self.sources[r.reference].0.0, + "ev": r.ev, "evExif": r.ev_exif, + "alignment": r.alignments.iter().map(|a| json!({"model": a.model, "inliers": a.inliers, "rmsPx": a.rms})).collect::>(), + }); + let ghost = (!r.ghost.data.is_empty()).then(|| r.ghost.oriented(r.orientation)); + (r.radiance, r.color, r.orientation, r.metadata, r.baseline_exposure, lightcraft_merge::DngSamples::Half, None, info, ghost) + } + MergeKind::Panorama { pano } => { + let frames = self.load(true, progress, (0.0, 0.25))?; + let p = sub(0.25, 0.9); + let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?; + let info = pano_info(&r, &self.sources); + ( + r.image, + r.color, + lightcraft_geom::Orientation::Normal, + r.metadata, + r.baseline_exposure, + lightcraft_merge::DngSamples::U16, + r.crop, + info, + None, + ) + } + MergeKind::HdrPanorama { hdr, pano, bracket } => { + let frames = self.load(false, progress, (0.0, 0.2))?; + let b = + if *bracket >= 2 { *bracket } else { detect_bracket(&frames).ok_or("could not detect the bracket size from EXIF; set it")? }; + if !frames.len().is_multiple_of(b) || frames.len() / b < 2 { + return Err(format!("{} photos don't form brackets of {b} for at least two positions", frames.len())); + } + let groups = frames.len() / b; + let mut merged = Vec::new(); + let mut it = frames.into_iter(); + for g in 0..groups { + let set: Vec = it.by_ref().take(b).collect(); + let p = sub(0.2 + 0.4 * g as f32 / groups as f32, 0.2 + 0.4 * (g + 1) as f32 / groups as f32); + let r = lightcraft_merge::merge_hdr(set, hdr, &p).map_err(err)?; + let mut f = Frame { + image: r.radiance, + clip: f32::INFINITY, + exposure: None, + raw: r.raw, + color: r.color, + orientation: r.orientation, + metadata: r.metadata, + baseline_exposure: r.baseline_exposure, + }; + f.orient(); + merged.push(f); + } + let p = sub(0.6, 0.9); + let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?; + let mut info = pano_info(&r, &self.sources); + info["bracket"] = json!(b); + ( + r.image, + r.color, + lightcraft_geom::Orientation::Normal, + r.metadata, + r.baseline_exposure, + lightcraft_merge::DngSamples::Half, + r.crop, + info, + None, + ) + } + }; + if !progress(0.9, "Writing DNG") { + return Err("cancelled".into()); + } + let mut metadata = metadata; + if self.finish.stack { + let names: Vec<&str> = + self.sources.iter().map(|(_, p)| std::path::Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p)).collect(); + metadata.caption = Some(format!("Merged from {}", names.join(", "))); + } + metadata.software = Some("LightCraft Photo Merge".into()); + let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?; + info["width"] = json!(image.width); + info["height"] = json!(image.height); + let preview = match self.preview { + Some(edge) => Some(render_preview(&dng, edge, self.finish.auto_settings, crop, ghost.as_ref().filter(|_| self.finish.show_overlay))?), + None => None, + }; + progress(1.0, "Done"); + Ok(MergeOutput { dng, preview, crop, info }) + } +} + +fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value { + json!({ + "projection": r.projection, + "used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::>(), + "fov": [r.fov.0, r.fov.1], + "rmsPx": r.rms, + "gains": r.gains, + "crop": r.crop, + }) +} + +/// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2). +pub fn detect_bracket(frames: &[Frame]) -> Option { + let ev: Vec = frames.iter().map(|f| f.exposure).collect::>()?; + let n = ev.len(); + (2..=n / 2).find(|&b| n.is_multiple_of(b) && (0..n).all(|k| (ev[k] - ev[k % b]).abs() < 0.2) && (1..b).any(|k| (ev[k] - ev[0]).abs() > 0.5)) +} + +/// Render a merged DNG like the library will show it (default raw settings, optionally Auto +/// Settings and the auto crop), with the deghost overlay tinted red. +fn render_preview( + dng: &[u8], + edge: usize, + auto: bool, + crop: Option<[f64; 4]>, + ghost: Option<&lightcraft_raster::Plane>, +) -> std::result::Result { + let (src, info) = crate::files::load_bytes(dng, edge)?; + let mut s = DevelopSettings::for_raw(info.as_shot_temp, info.as_shot_tint); + if auto { + apply_auto(&mut s, &src, &info); + } + if let Some(c) = crop { + s.crop.geometry.rect = lightcraft_geom::Rect::from_xywh(c[0], c[1], c[2], c[3]); + } + let req = RenderRequest { quality: Quality::Full, apply_crop: false, ..RenderRequest::fit(edge, edge) }; + let mut img = lightcraft_pipeline::render(&src, &info, &s, &req).image; + if let Some(g) = ghost { + let (w, h) = (img.width, img.height); + for y in 0..h { + for x in 0..w { + let gx = ((x as f32 + 0.5) / w as f32 * g.width as f32) as usize; + let gy = ((y as f32 + 0.5) / h as f32 * g.height as f32) as usize; + let a = g.get(gx.min(g.width - 1), gy.min(g.height - 1)).clamp(0.0, 1.0) * 0.6; + if a > 0.01 { + let p = &mut img.data[y * w + x]; + p[0] = (p[0] as f32 * (1.0 - a) + 255.0 * a) as u8; + p[1] = (p[1] as f32 * (1.0 - a)) as u8; + p[2] = (p[2] as f32 * (1.0 - a)) as u8; + } + } + } + } + if let Some(c) = crop { + // draw the auto-crop frame + let (w, h) = (img.width as f64, img.height as f64); + let (x0, y0, x1, y1) = ((c[0] * w) as usize, (c[1] * h) as usize, ((c[0] + c[2]) * w) as usize, ((c[1] + c[3]) * h) as usize); + for y in y0..y1.min(img.height) { + for x in x0..x1.min(img.width) { + if y == y0 || y + 1 == y1 || x == x0 || x + 1 == x1 { + img.data[y * img.width + x] = [255, 255, 255, 255]; + } + } + } + } + Ok(img) +} + +fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) { + let a = lightcraft_pipeline::auto::auto_tone(src, info, s); + s.light.exposure = a.exposure; + s.light.contrast = a.contrast; + s.light.highlights = a.highlights; + s.light.shadows = a.shadows; + s.light.whites = a.whites; + s.light.blacks = a.blacks; + s.color.vibrance = a.vibrance; + s.color.saturation = a.saturation; +} + +/// A free file name `dir/-.dng` (`-2`, `-3`… when taken). +fn output_path(first: &str, suffix: &str) -> String { + let p = std::path::Path::new(first); + let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default(); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into()); + for k in 1.. { + let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") }; + let cand = dir.join(name); + if !cand.exists() { + return cand.to_string_lossy().to_string(); + } + } + unreachable!() +} + +/// Parse `merge.*` command parameters into a kind and finishing options. +pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { + let bad = |m: String| EngineError::BadParams { cmd: id.into(), msg: m }; + let deghost: Deghost = serde_json::from_value(p.get("deghost").cloned().unwrap_or(json!("none"))).map_err(|e| bad(format!("deghost: {e}")))?; + let hdr = HdrOptions { align: p.get("align").and_then(Value::as_bool).unwrap_or(true), deghost }; + let projection: Projection = + serde_json::from_value(p.get("projection").cloned().unwrap_or(json!("auto"))).map_err(|e| bad(format!("projection: {e}")))?; + let pano = PanoOptions { + projection, + boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0), + auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false), + fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false), + max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0), + }; + let kind = match id { + "merge.hdr" => MergeKind::Hdr { hdr }, + "merge.panorama" => MergeKind::Panorama { pano }, + _ => MergeKind::HdrPanorama { hdr, pano, bracket: p.get("bracket").and_then(Value::as_u64).unwrap_or(0) as usize }, + }; + let finish = MergeFinish { + auto_settings: p.get("autoSettings").and_then(Value::as_bool).unwrap_or(true), + stack: p.get("stack").and_then(Value::as_bool).unwrap_or(false), + show_overlay: p.get("showOverlay").and_then(Value::as_bool).unwrap_or(false), + }; + Ok((kind, finish)) +} + +impl Session { + /// Plan a merge of `ids` (catalog order of the given ids is kept). + pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result { + let mut sources = Vec::new(); + for id in ids { + let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?; + match &p.source { + Source::File { path } => sources.push((*id, path.clone())), + Source::Demo { .. } => return Err(EngineError::Other(format!("{} is a demo photo; Photo Merge needs photo files", p.file_name))), + } + } + if sources.len() < kind.min_photos() { + return Err(EngineError::Other(format!("select at least {} photos to merge", kind.min_photos()))); + } + let read: ByteReader = match &self.media.file_bytes { + Some(r) => r.clone(), + None => Arc::new(|path: &str| std::fs::read(path).map_err(|e| format!("{path}: {e}"))), + }; + Ok(MergeJob { kind, finish, sources, preview: preview.then_some(PREVIEW_EDGE), read }) + } + + /// Write a finished (non-preview) merge next to its first source, import it, select it and + /// apply Auto Settings / the auto crop. Returns `{id, path, …info}`. + pub fn finish_merge(&mut self, job: &MergeJob, out: MergeOutput) -> Result { + let first = &job.sources.first().ok_or_else(|| EngineError::Other("nothing merged".into()))?.1; + let path = output_path(first, job.kind.suffix()); + std::fs::write(&path, &out.dng).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let report = crate::import::import(self, std::slice::from_ref(&path), crate::import::ImportMode::Add)?; + let id = report + .imported + .first() + .copied() + .map(PhotoId) + .ok_or_else(|| EngineError::Other(format!("the merged file {path} could not be imported: {:?}", report.failed)))?; + if job.finish.stack { + let sources: Vec = job.sources.iter().map(|(p, _)| *p).collect(); + if let Some(op) = self.catalog.stack_with_ops(id, &sources) { + self.commit("Stack with Sources", op)?; + } + } + self.selection = crate::Selection::single(id); + if job.finish.auto_settings { + self.execute("develop.auto", &json!({}))?; + } + if let Some(c) = out.crop + && let Some(d) = self.develop_of(id) + { + let mut d = (*d).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect::from_xywh(c[0], c[1], c[2], c[3]); + self.set_develop(id, d, "Auto Crop")?; + } + let mut info = out.info; + info["id"] = json!(id.0); + info["path"] = json!(path); + info["bytes"] = json!(out.dng.len()); + Ok(info) + } + + /// Plan, run and finish a merge synchronously (CLI, MCP, control channel). + pub fn merge_now(&mut self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId]) -> Result { + let job = self.plan_merge(kind, finish, ids, false)?; + let out = job.run(&|_, _| true).map_err(EngineError::Other)?; + self.finish_merge(&job, out) + } +} diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c84c3eb11..e62d2b091 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -241,6 +241,9 @@ impl Session { /// Write the photo's XMP sidecar; returns its path. pub fn save_sidecar(&self, id: PhotoId) -> Result { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; + if p.copy_of.is_some() { + return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name))); + } let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?; let path = sidecar_path(orig, self.xmp.naming); write_atomic(&path, sidecar_packet(p).as_bytes()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; @@ -272,7 +275,7 @@ impl Session { let mut ids = Vec::new(); ops.iter().for_each(|o| op_photos(o, &mut ids)); for id in ids { - if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some()) + if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) && let Err(e) = self.save_sidecar(id) { log::warn!("auto-write XMP: {e}"); diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs new file mode 100644 index 000000000..e765e0671 --- /dev/null +++ b/crates/engine/src/tests_color.rs @@ -0,0 +1,123 @@ +//! Sampling commands: Point Color, the targeted adjustment tool, red eye. + +use serde_json::json; + +use crate::Session; + +fn demo() -> Session { + Session::with_demo() +} + +fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { + (*s.develop_of(s.active().unwrap()).unwrap()).clone() +} + +/// Spread of the channels (a saturation proxy) at normalized `(x, y)`. +fn chroma_at(img: &lightcraft_raster::Rgba8, x: f64, y: f64) -> i32 { + let p = img.data[(img.height as f64 * y) as usize * img.width + (img.width as f64 * x) as usize]; + p[0].max(p[1]).max(p[2]) as i32 - p[0].min(p[1]).min(p[2]) as i32 +} + +#[test] +fn point_color_pick_and_adjust() { + let mut s = demo(); + let id = s.active().unwrap(); + let before = s.render_now(id, 384, 384).unwrap().image; + let r = s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap(); + assert_eq!(r["index"], 0); + let d = active_dev(&s); + assert_eq!(d.point_colors.len(), 1); + assert!(d.point_colors[0].chroma > 0.01, "the sky has colour: {r}"); + // the sample's sliders are develop controls (and listed as such) + s.execute("develop.set", &json!({"control": "pointColor.0.satShift", "value": -100})).unwrap(); + assert_eq!(active_dev(&s).point_colors[0].sat_shift, -100.0); + let ctl = s.execute("develop.controls", &json!({"section": "pointColor"})).unwrap(); + assert!(ctl.as_array().unwrap().iter().any(|c| c["id"] == "pointColor.0.satShift" && c["value"] == -100.0), "{ctl}"); + // the picked colour is desaturated in the render + let after = s.render_now(id, 384, 384).unwrap().image; + assert!( + chroma_at(&after, 0.5, 0.08) + 8 < chroma_at(&before, 0.5, 0.08), + "{} vs {}", + chroma_at(&after, 0.5, 0.08), + chroma_at(&before, 0.5, 0.08) + ); + // at most 8 samples; delete + for _ in 0..7 { + s.execute("pointColor.pick", &json!({"x": 0.3, "y": 0.5})).unwrap(); + } + assert!(s.execute("pointColor.pick", &json!({"x": 0.3, "y": 0.5})).is_err()); + s.execute("pointColor.delete", &json!({"index": 3})).unwrap(); + assert_eq!(active_dev(&s).point_colors.len(), 7); + // survives the settings JSON (what the catalog and XMP sidecars store) + let d = active_dev(&s); + assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d); +} + +#[test] +fn red_eye_commands_and_controls() { + let mut s = demo(); + let r = s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02})).unwrap(); + assert_eq!(r["index"], 0); + s.execute("redeye.add", &json!({"center": [0.6, 0.4], "rx": 0.03, "ry": 0.02, "darken": 80})).unwrap(); + let d = active_dev(&s); + assert_eq!(d.red_eye.len(), 2); + assert_eq!((d.red_eye[0].pupil_size, d.red_eye[0].darken, d.red_eye[1].darken), (50.0, 50.0, 80.0)); + // per-eye sliders are develop controls + s.execute("develop.set", &json!({"values": {"redEye.1.pupilSize": 70, "redEye.0.darken": 140}})).unwrap(); + let d = active_dev(&s); + assert_eq!((d.red_eye[1].pupil_size, d.red_eye[0].darken), (70.0, 100.0)); + let ctl = s.execute("develop.controls", &json!({"section": "redEye"})).unwrap(); + assert_eq!(ctl.as_array().unwrap().len(), 4, "{ctl}"); + // the render runs with eyes (detection falls back gracefully on a photo without red pupils) + let id = s.active().unwrap(); + assert!(s.render_now(id, 200, 200).is_ok()); + s.execute("redeye.delete", &json!({"index": 0})).unwrap(); + assert_eq!(active_dev(&s).red_eye.len(), 1); + assert!(s.execute("redeye.delete", &json!({"index": 5})).is_err()); + assert!(s.execute("redeye.add", &json!({"rx": 0.03})).is_err()); + let d = active_dev(&s); + assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d); +} + +#[test] +fn pet_eye_catchlight_command() { + let mut s = demo(); + s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.03})).unwrap(); + s.execute("redeye.add", &json!({"center": [0.6, 0.4], "rx": 0.03, "ry": 0.03, "pet": true})).unwrap(); + assert!(s.execute("redeye.catchlight", &json!({"index": 0})).is_err(), "red eyes have no catchlight"); + s.execute("redeye.catchlight", &json!({"index": 1})).unwrap(); + assert_eq!(active_dev(&s).red_eye[1].catchlight, Some(lightcraft_geom::Point::new(-0.35, -0.35))); + s.execute("redeye.catchlight", &json!({"index": 1, "offset": [0.2, -3.0]})).unwrap(); + assert_eq!(active_dev(&s).red_eye[1].catchlight, Some(lightcraft_geom::Point::new(0.2, -1.0))); + s.execute("redeye.catchlight", &json!({"index": 1, "on": false})).unwrap(); + assert_eq!(active_dev(&s).red_eye[1].catchlight, None); +} + +#[test] +fn targeted_adjustment_on_curve_and_mixer() { + let mut s = demo(); + // the bright sky lies in the upper regions of the parametric curve + let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.5, "y": 0.35, "delta": 20})).unwrap(); + let (k, v) = r.as_object().unwrap().iter().next().map(|(k, v)| (k.clone(), v.clone())).unwrap(); + assert!(k == "curve.lights" || k == "curve.highlights", "{r}"); + assert_eq!(v, 20.0); + assert_eq!(lightcraft_develop::controls::get(&active_dev(&s), &k), Some(20.0)); + // the dark trees in the lower ones + let r = s.execute("develop.targeted", &json!({"target": "curve", "x": 0.4, "y": 0.62, "delta": -10})).unwrap(); + assert!(r.get("curve.shadows").is_some() || r.get("curve.darks").is_some(), "{r}"); + // point curve: a point appears at the sampled input, raised by delta levels + s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap(); + let m = active_dev(&s).curve.master; + assert_eq!(m.len(), 3); + assert!((m[1].y - m[1].x - 0.05).abs() < 1e-6, "{m:?}"); + // a second step moves the same point + s.execute("develop.targeted", &json!({"target": "curve", "channel": "master", "x": 0.5, "y": 0.35, "delta": 12.75})).unwrap(); + assert_eq!(active_dev(&s).curve.master.len(), 3); + // colour mixer: the purple sky's bands lose saturation, the dominant one by the full delta + let r = s.execute("develop.targeted", &json!({"target": "sat", "x": 0.5, "y": 0.1, "delta": -30})).unwrap(); + let o = r.as_object().unwrap(); + assert!(!o.is_empty() && o.keys().all(|k| k.starts_with("mixer.") && k.ends_with(".sat")), "{r}"); + assert!(o.values().any(|v| v.as_f64() == Some(-30.0)), "{r}"); + assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0)); + assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err()); +} diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs new file mode 100644 index 000000000..93791fba0 --- /dev/null +++ b/crates/engine/src/tests_merge.rs @@ -0,0 +1,90 @@ +//! Photo Merge through the command layer: synthetic brackets / panorama views on disk → merged +//! DNG next to the sources → imported, selected, Auto Settings / auto crop applied. + +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-merge-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn import(s: &mut Session, paths: Vec) -> Vec { + let r = s.execute("library.import", &json!({"paths": paths})).unwrap(); + r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +#[test] +fn hdr_merge_command_creates_and_imports_a_dng() { + let dir = temp_dir("hdr"); + let mut paths = Vec::new(); + for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, b).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + assert_eq!(ids.len(), 3); + // preview: rendered PNG, nothing added + let png = dir.join("preview.png"); + let pv = s.execute("merge.hdr", &json!({"ids": ids, "deghost": "medium", "preview": true, "showOverlay": true, "previewPath": png})).unwrap(); + assert!(png.exists() && pv["previewSize"][0].as_u64().unwrap() > 100, "{pv}"); + assert_eq!(s.catalog.photos().count(), 3); + let ev: Vec = pv["ev"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert!((ev[0] + 2.0).abs() < 0.1 && (ev[2] - 2.0).abs() < 0.1, "{ev:?}"); + // full merge + let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": true, "stack": true})).unwrap(); + let path = r["path"].as_str().unwrap(); + assert!(path.ends_with("IMG_0-HDR.dng"), "{path}"); + let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + assert_eq!((p.width, p.height), (480, 320)); + assert_eq!(p.kind, lightcraft_catalog::MediaKind::Raw); + assert_eq!(s.selection.active, Some(id)); + let d = s.develop_of(id).unwrap(); + assert!(d.light.exposure != 0.0 || d.light.highlights != 0.0, "auto settings applied"); + let img = s.render_now(id, 256, 256).unwrap(); + assert_eq!(img.image.width, 256); + // Create Stack: the result tops a collapsed stack of its sources, so the grid shows the result + let st = s.catalog.stack_of(id).expect("merge result stacked").clone(); + assert_eq!(st.top(), id); + assert_eq!(st.photos[1..].iter().map(|p| p.0).collect::>(), ids); + assert!(st.collapsed); + assert_eq!(s.visible_cloned(), vec![id]); + // the second merge doesn't overwrite the first + let r2 = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap(); + assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng")); + let id2 = lightcraft_catalog::PhotoId(r2["id"].as_u64().unwrap()); + assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn panorama_command_with_auto_crop() { + let dir = temp_dir("pano"); + let mut paths = Vec::new(); + for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() { + let img = v.to_srgb8(); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let p = dir.join(format!("P_{i}.png")); + std::fs::write(&p, png).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + let r = s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoCrop": true, "autoSettings": false})).unwrap(); + assert_eq!(r["projection"], "cylindrical"); + assert_eq!(r["used"].as_array().unwrap().len(), 3); + let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + assert!(p.width > 800 && p.height > 300, "{}×{}", p.width, p.height); + let crop = s.develop_of(id).unwrap().crop.geometry.rect; + assert!(crop.width() < 1.0 && crop.width() * crop.height() > 0.5, "{crop:?}"); + // too few photos + assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs new file mode 100644 index 000000000..9c0b73cfe --- /dev/null +++ b/crates/engine/src/tests_organize.rs @@ -0,0 +1,178 @@ +//! Organizing commands: smart albums, stacks, virtual copies — through `Session::execute`, with +//! undo and a persistent library (journal replay must reproduce the live state). + +use lightcraft_catalog::Flag; +use serde_json::json; + +use crate::{LibrarySource, Session}; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-organize-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn open(dir: &std::path::Path) -> Session { + let mut s = Session::new(); + s.open_library(dir, true).unwrap(); + s +} + +#[test] +fn smart_album_from_view_updates_live_and_persists() { + let dir = temp_dir("smart"); + let mut s = open(&dir); + s.execute("library.filter", &json!({"rating": 4})).unwrap(); + let want = s.visible_cloned(); + assert!(!want.is_empty()); + let r = s.execute("album.createSmart", &json!({"name": "Four plus"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + assert_eq!(r["count"].as_u64().unwrap() as usize, want.len()); + s.execute("library.clearFilter", &json!({})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); + let mut got = s.visible_cloned(); + got.sort(); + let mut w = want.clone(); + w.sort(); + assert_eq!(got, w); + // live: rating another photo 5 adds it; rating a member 1 removes it + let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id; + s.execute("photo.rate", &json!({"ids": [other.0], "rating": 5})).unwrap(); + assert!(s.visible_cloned().contains(&other)); + s.execute("photo.rate", &json!({"ids": [want[0].0], "rating": 1})).unwrap(); + assert!(!s.visible_cloned().contains(&want[0])); + // manual membership changes are refused + assert!(s.execute("album.addPhotos", &json!({"id": id, "ids": [want[0].0]})).is_err()); + // edit rules (merge), rename; undo the rename + s.execute("album.setRules", &json!({"id": id, "rules": {"flag": "pick"}})).unwrap(); + let rules = s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().smart.clone().unwrap(); + assert_eq!((rules.rating, rules.flag), (4, Some(Flag::Pick))); + s.execute("album.rename", &json!({"id": id, "name": "Best picks"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().name, "Four plus"); + // rules via params: camera + lens + date range + let r2 = s + .execute("album.createSmart", &json!({"name": "Range", "rules": {"dateFrom": "2000-01-01", "dateTo": "2100", "lens": "", "edited": true}})) + .unwrap(); + let edited = s.catalog.photos().filter(|p| !p.deleted && p.is_edited()).count(); + assert_eq!(r2["count"].as_u64().unwrap() as usize, edited); + let listed = s.execute("albums.list", &json!({})).unwrap(); + assert!(listed.to_string().contains("\"rulesText\""), "{listed}"); + let expect = s.catalog.to_snapshot(); + drop(s); // crash: replay the log + let mut s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + // deleting the smart album + s2.execute("album.delete", &json!({"id": id})).unwrap(); + assert!(s2.catalog.album(lightcraft_catalog::AlbumId(id)).is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn smart_album_from_smart_album_view_keeps_its_rules() { + let mut s = Session::with_demo(); + let id = s.execute("album.createSmart", &json!({"name": "Picks", "rules": {"flag": "pick"}})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); + s.execute("library.filter", &json!({"rating": 3})).unwrap(); + let r = s.view_rules(); + assert_eq!((r.flag, r.rating, r.album), (Some(Flag::Pick), 3, None)); + assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); +} + +#[test] +fn virtual_copies_are_independent_and_persist() { + let dir = temp_dir("vc"); + let mut s = open(&dir); + let src = s.visible_cloned()[1]; + let album = s.execute("album.create", &json!({"name": "Keep", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": [src.0]})).unwrap(); + s.execute("library.select", &json!({"ids": [src.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); + let undo_before = s.undo.len(); + let r = s.execute("photo.virtualCopy", &json!({})).unwrap(); + assert_eq!(s.undo.len(), undo_before + 1); + let c1 = lightcraft_catalog::PhotoId(r["ids"][0].as_u64().unwrap()); + let r2 = s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap(); + let c2 = lightcraft_catalog::PhotoId(r2["ids"][0].as_u64().unwrap()); + let (p, a, b) = (s.catalog.photo(src).unwrap().clone(), s.catalog.photo(c1).unwrap().clone(), s.catalog.photo(c2).unwrap().clone()); + assert_eq!((a.copy_of, a.copy_name.as_deref()), (Some(src), Some("Copy 1"))); + assert_eq!(b.copy_name.as_deref(), Some("Copy 2")); + assert_eq!(a.source, p.source, "shares the original file"); + assert_eq!(a.develop.light.exposure, 0.5, "starts from the current settings"); + // a copy of a copy names itself after the master + let r3 = s.execute("photo.virtualCopy", &json!({"ids": [c1.0]})).unwrap(); + let c3 = s.catalog.photo(lightcraft_catalog::PhotoId(r3["ids"][0].as_u64().unwrap())).unwrap().clone(); + assert_eq!((c3.copy_of, c3.copy_name.as_deref()), (Some(src), Some("Copy 3"))); + // independent settings + s.execute("library.select", &json!({"ids": [c1.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap(); + assert_eq!(s.catalog.photo(src).unwrap().develop.light.exposure, 0.5); + assert_eq!(s.catalog.photo(c1).unwrap().develop.light.exposure, -1.0); + // same album, stacked (expanded) with the original, visible in the grid + assert!(s.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.starts_with(&[src, c2, c1, c3.id])); + assert_eq!(s.catalog.stack_of(c1).unwrap().photos, vec![src, c2, c1, c3.id]); + assert!(s.visible_cloned().contains(&c2)); + let found = s.execute("catalog.query", &json!({"filter": {"text": "copy:yes"}})).unwrap(); + assert_eq!(found["total"], 3); + // undo removes a copy completely + s.execute("edit.undo", &json!({})).unwrap(); // exposure + s.execute("edit.undo", &json!({})).unwrap(); // third copy + assert!(s.catalog.photo(c3.id).is_none()); + let expect = s.catalog.to_snapshot(); + drop(s); + let s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn stacks_group_collapse_and_replay() { + let dir = temp_dir("stacks"); + let mut s = open(&dir); + let vis = s.visible_cloned(); + let n = vis.len(); + let (a, b, c) = (vis[2], vis[3], vis[4]); + s.execute("library.select", &json!({"ids": [a.0, b.0, c.0], "active": b.0})).unwrap(); + let r = s.execute("stack.group", &json!({})).unwrap(); + assert_eq!(r["count"], 3); + // collapsed by default: the top (the active photo) stands for the stack + let v = s.visible_cloned(); + assert_eq!(v.len(), n - 2); + assert!(v.contains(&b) && !v.contains(&a) && !v.contains(&c)); + assert_eq!(s.selection.active, Some(b)); + let info = s.execute("photo.inspect", &json!({"id": a.0})).unwrap(); + assert_eq!(info["stack"]["photos"], json!([b.0, a.0, c.0])); + // expand: members follow the top + s.execute("stack.toggle", &json!({})).unwrap(); + let v = s.visible_cloned(); + let i = v.iter().position(|x| *x == b).unwrap(); + assert_eq!(&v[i..i + 3], &[b, a, c]); + // set top, collapse with a member active → active moves to the visible top + s.execute("library.select", &json!({"ids": [c.0]})).unwrap(); + s.execute("stack.setTop", &json!({})).unwrap(); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("stack.toggle", &json!({})).unwrap(); + assert_eq!(s.selection.active, Some(c)); + // remove from stack; undo + s.execute("stack.remove", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.catalog.stack_of(c).unwrap().photos, vec![c, b]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(c).unwrap().photos.len(), 3); + // auto-stack the rest of the view: a huge gap groups everything unstacked + let pre = s.execute("stack.auto", &json!({"gap": 1e9, "preview": true})).unwrap(); + assert_eq!(pre["stacks"], 1); + assert_eq!(s.catalog.stacks().count(), 1, "preview changes nothing"); + s.execute("stack.auto", &json!({"gap": 1e9})).unwrap(); + assert_eq!(s.catalog.stacks().count(), 2); + assert_eq!(s.visible_cloned().len(), 2); + s.execute("stack.expandAll", &json!({})).unwrap(); + assert_eq!(s.visible_cloned().len(), n); + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + s2.execute("library.select", &json!({"ids": [b.0]})).unwrap(); + s2.execute("stack.ungroup", &json!({})).unwrap(); + assert_eq!(s2.catalog.stacks().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index e02a2818b..1fbf3fefa 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -38,7 +38,7 @@ const MODULES: &[Module] = &[ Module { src: include_str!("wgsl/map.wgsl"), bindings: &[("a", false, "f32"), ("b", false, "f32"), ("c", false, "f32"), ("dst", true, "f32")], - entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample"], + entries: &["log_lum_k", "dark_k", "guided_pre", "guided_ab", "guided_apply", "wb_k", "nr_lum", "chroma_k", "nr_col", "subsample", "redeye_k"], }, Module { src: include_str!("wgsl/mask.wgsl"), @@ -81,17 +81,50 @@ impl Drop for Buf { // Outside a render this thread has no commands recorded (only renders record), so the // buffer is reusable at once; without this, buffers dropped on threads that rarely // submit (the UI thread clearing a view's stages) would wait indefinitely. - if !IN_RENDER.with(|c| c.get() > 0) - && let Some(g) = crate::existing_device() - { + let Ok(in_render) = IN_RENDER.try_with(|c| c.get() > 0) else { + // thread teardown: wgpu's thread-locals may be gone too, and dropping (or trimming + // the pool, which drops) could abort — leak it (the device owns its memory) + std::mem::forget(b); + return; + }; + if !in_render && let Some(g) = crate::existing_device() { g.pool([b]); return; } - RETIRED_BYTES.fetch_add(b.0.size(), Ordering::Relaxed); - RETIRED.with(|r| r.borrow_mut().push(b)); + // during thread teardown the list may already be gone: then the buffer leaks + let size = b.0.size(); + let mut b = Some(b); + let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take())); + match b { + Some(b) => std::mem::forget(b), + None => { + RETIRED_BYTES.fetch_add(size, Ordering::Relaxed); + } + } } } +/// Buffers dropped on a thread since its last submit. +struct Retired(std::cell::RefCell>); + +impl Drop for Retired { + /// At thread exit wgpu's own thread-locals may already be destroyed, and dropping a buffer + /// then aborts the process: leak the few buffers left instead (the device owns their memory). + fn drop(&mut self) { + for b in self.0.get_mut().drain(..) { + RETIRED_BYTES.fetch_sub(b.0.size(), Ordering::Relaxed); + std::mem::forget(b); + } + } +} + +/// This thread's retired buffers (empty during thread teardown). +fn take_retired() -> Vec { + let retired = RETIRED.try_with(|r| std::mem::take(&mut *r.0.borrow_mut())).unwrap_or_default(); + RETIRED_BYTES.fetch_sub(retired.iter().map(|b| b.0.size()).sum(), Ordering::Relaxed); + retired +} + thread_local! { /// Renders running on this thread (they may hold recorded, unsubmitted commands). static IN_RENDER: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -115,9 +148,7 @@ impl Drop for RenderScope<'_> { c.get() == 0 }); if outer { - let retired = RETIRED.with(|r| std::mem::take(&mut *r.borrow_mut())); - RETIRED_BYTES.fetch_sub(retired.iter().map(|b| b.0.size()).sum(), Ordering::Relaxed); - self.0.pool(retired); + self.0.pool(take_retired()); } } } @@ -139,8 +170,7 @@ impl Drop for Tracked { } thread_local! { - /// Buffers dropped on this thread since its last submit. - static RETIRED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) }; } /// Bytes of device buffers that exist (in use, retired or pooled). @@ -198,6 +228,9 @@ fn constants() -> String { let b = lightcraft_pipeline::geometry::BLANK; s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]); s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N); + s += &format!("const POINT_WORDS: u32 = {}u;\n", lightcraft_pipeline::colorops::POINT_WORDS); + s += &format!("const EYE_WORDS: u32 = {}u;\n", lightcraft_pipeline::redeye::EYE_WORDS); + s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT); for (i, h) in GRAIN_HASH.iter().enumerate() { s += &format!("const GRAIN_H{i}: u32 = {h}u;\n"); } @@ -325,8 +358,7 @@ impl Gpu { /// Submit command buffers, then make the buffers this thread dropped before reusable. pub fn submit(&self, cmds: impl IntoIterator) { self.queue.submit(cmds); - let retired = RETIRED.with(|r| std::mem::take(&mut *r.borrow_mut())); - RETIRED_BYTES.fetch_sub(retired.iter().map(|b| b.0.size()).sum(), Ordering::Relaxed); + let retired = take_retired(); self.pool(retired); } diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 64d722d7e..b18461a2e 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -2,6 +2,7 @@ //! `finish` kernel reads (`F_*` constants, generated from [`FIELDS`]), plus its auxiliary tables. use lightcraft_develop::VignetteStyle; +use lightcraft_pipeline::colorops::POINT_WORDS; use lightcraft_pipeline::finish::{FinishParams, MASK_TERMS, srgb_lut}; /// Entries per tone-curve table (`lightcraft_pipeline::finish` builds them at this size). @@ -37,6 +38,8 @@ const FIELDS: &[(&str, usize)] = &[ ("MIX_HUE", 8), ("MIX_SAT", 8), ("MIX_LUM", 8), + ("NPC", 1), + ("PC", 8 * POINT_WORDS), ("BW", 1), ("BW_MIX", 8), ("GRADING", 1), @@ -59,6 +62,10 @@ const FIELDS: &[(&str, usize)] = &[ ("GRAIN_ROUGH", 1), ("GRAIN_SEED", 1), ("GRAIN_AFF", 6), + ("REFINE_SAT", 1), + ("CALIB", 1), + ("CALIB_M", 9), + ("SHADOW_TINT", 1), ]; /// `(name, index)` of every field (for the WGSL constants). @@ -144,6 +151,7 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.u("SRGB_OFF", srgb_off as u32); p.u("CURVE_OFF", curve_off as u32); p.b("CURVES", fp.curves.is_some()); + p.f("REFINE_SAT", fp.refine_sat); p.f("GAIN", fp.gain); p.f("EV", fp.ev); p.f("AIR", fp.air); @@ -167,6 +175,9 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.fs("MIX_HUE", &ops.hue); p.fs("MIX_SAT", &ops.sat); p.fs("MIX_LUM", &ops.lum); + p.u("NPC", ops.points.len().min(8) as u32); + let pc: Vec = ops.points.iter().take(8).flat_map(|k| k.words()).collect(); + p.fs("PC", &pc); p.b("BW", ops.bw.is_some()); p.fs("BW_MIX", &ops.bw.unwrap_or([0.0; 8])); if let Some((wheels, blending, balance)) = &ops.grading { @@ -196,6 +207,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr }, ); } + if let Some(m) = &fp.calib { + p.b("CALIB", true); + p.fs("CALIB_M", m.as_flattened()); + } + p.f("SHADOW_TINT", fp.shadow_tint); if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index d229cdbae..bfa0a5b42 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -521,6 +521,8 @@ pub fn render( lap("finish + readback", &mut t, &mut cx); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t, &mut cx); + let mut image = image; + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan); Some(Rendered { image, histogram }) } @@ -543,7 +545,15 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho p.extend(m.unwrap_or_default().iter().flatten().map(|v| v.to_bits())); let out = cx.gpu.buffer(n * 3); map(cx, "wb_k", n, &p, [Some(sampled), None, None], &out); - out + if plan.eyes.is_empty() { + out + } else { + let mut p = vec![w as u32, plan.eyes.len() as u32]; + p.extend(plan.eyes.iter().flat_map(|e| e.words()).map(f32::to_bits)); + let eyed = cx.gpu.buffer(n * 3); + map(cx, "redeye_k", n, &p, [Some(&out), None, None], &eyed); + eyed + } }; denoise(cx, img, plan) } diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 0548ab5db..d405e9495 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -51,6 +51,39 @@ fn band_weights(h: f32) -> array { return w; } +// `PointK::apply` for Point Color sample `k` (OkLCh in, OkLCh out). +fn point_color(k: u32, lch: vec3) -> vec3 { + let o = F_PC + k * POINT_WORDS; + let sl = pf(o); + let sc = pf(o + 1u); + let sh = pf(o + 2u); + let var_k = pf(o + 6u); + let wh_ = pf(o + 7u); + let wc_ = pf(o + 8u); + let wl_ = pf(o + 9u); + var l = lch.x; + var c = lch.y; + var h = lch.z; + var wh = 1.0; + if (sc >= 0.02) { + wh = (1.0 - sstep(0.5 * wh_, wh_, abs(wrap_angle(h - sh)))) * sstep(0.005, 0.025, c); + } + if (wh <= 0.0) { + return lch; + } + let wc = 1.0 - sstep(0.5 * wc_, wc_, abs(c - sc)); + let wl = 1.0 - sstep(0.5 * wl_, wl_, abs(l - sl)); + let w = wh * wc * wl; + if (w <= 0.0) { + return lch; + } + h += w * (var_k * wrap_angle(h - sh) + pf(o + 3u)); + c += w * var_k * (c - sc); + c = max(c * (1.0 + w * pf(o + 4u)), 0.0); + l += w * (var_k * (l - sl) + pf(o + 5u)); + return vec3(l, c, h); +} + // `ColorOps::apply`. fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { if (pu(F_OPS_IDENTITY) != 0u && local_sat == 0.0 && local_hue == 0.0) { @@ -75,6 +108,12 @@ fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { c *= max(1.0 + ds, 0.0); l += dl * chroma_w * sqrt(max(l, 0.05)); } + for (var k = 0u; k < pu(F_NPC); k++) { + let r = point_color(k, vec3(l, c, h)); + l = r.x; + c = r.y; + h = r.z; + } let vib = pf(F_VIBRANCE); if (vib != 0.0) { let low = 1.0 - clamp(c / 0.22, 0.0, 1.0); @@ -117,6 +156,42 @@ fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { return oklab_inv(lab); } +// `colorops::calibrate`: primaries matrix, then the shadows tint (luminance kept). +fn calibrate(c0: vec3) -> vec3 { + var c = c0; + if (pu(F_CALIB) != 0u) { + let m = array, 3>( + vec3(pf(F_CALIB_M), pf(F_CALIB_M + 1u), pf(F_CALIB_M + 2u)), + vec3(pf(F_CALIB_M + 3u), pf(F_CALIB_M + 4u), pf(F_CALIB_M + 5u)), + vec3(pf(F_CALIB_M + 6u), pf(F_CALIB_M + 7u), pf(F_CALIB_M + 8u)), + ); + c = max(mul3(m, c), vec3(0.0)); + } + let st = pf(F_SHADOW_TINT); + if (st != 0.0) { + let y0 = lum2020(c); + let w = 1.0 - sstep(-5.0, -0.5, log2(max(y0, 1e-7) / 0.18)); + c.y *= max(1.0 - SHADOW_TINT_K * st * w, 0.0); + let y1 = max(lum2020(c), 1e-9); + c = c * y0 / y1; + } + return c; +} + +// `finish::refine_saturation`. +fn refine_saturation(before: vec3, after: vec3, refine: f32) -> vec3 { + let lw = vec3(0.2126, 0.7152, 0.0722); + let y0 = dot(before, lw); + let y1 = dot(after, lw); + let s0 = (max(before.x, max(before.y, before.z)) - min(before.x, min(before.y, before.z))) / max(y0, 1e-4); + let s1 = (max(after.x, max(after.y, after.z)) - min(after.x, min(after.y, after.z))) / max(y1, 1e-4); + if (s1 <= 1e-6 || s0 <= 1e-6) { + return after; + } + let k = clamp(pow(s0 / s1, 1.0 - refine), 0.0, 4.0); + return y1 + (after - y1) * k; +} + fn ghash(i: i32, j: i32, seed: u32) -> f32 { var v = (bitcast(i) * GRAIN_H0) ^ (bitcast(j) * GRAIN_H1) ^ (seed * GRAIN_H2); v ^= v >> 13u; @@ -263,6 +338,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { c = c * exp2(delta); } + // --- calibration (scene linear, before the tone map) + if (pu(F_CALIB) != 0u || pf(F_SHADOW_TINT) != 0.0) { + c = calibrate(c); + } + // --- tone map on luminance, highlight desaturation let yl = lum2020(c); let o = tone_apply(yl); @@ -338,7 +418,12 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // --- encode, curves, grain var e = vec3(encode_srgb(r.x), encode_srgb(r.y), encode_srgb(r.z)); if (pu(F_CURVES) != 0u) { + let e0 = e; e = vec3(curve(0u, e.x), curve(1u, e.y), curve(2u, e.z)); + let rs = pf(F_REFINE_SAT); + if (rs < 1.0) { + e = refine_saturation(e0, e, rs); + } } if (pu(F_GRAIN) != 0u) { let px = f32(x) + 0.5; diff --git a/crates/gpu/src/wgsl/map.wgsl b/crates/gpu/src/wgsl/map.wgsl index c0a4419a9..9a54e7f66 100644 --- a/crates/gpu/src/wgsl/map.wgsl +++ b/crates/gpu/src/wgsl/map.wgsl @@ -135,3 +135,49 @@ fn subsample(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroup } dst[i] = a[i * pu(1u)]; } + +// `redeye::eye_pixel` (a: image). P[1] = width, P[2] = eye count, then EYE_WORDS per eye: +// cx, cy, r, darken, pet, has catchlight, catchlight x, y, r (output pixels). +@compute @workgroup_size(256) +fn redeye_k(@builtin(global_invocation_id) g: vec3, @builtin(num_workgroups) nw: vec3) { + let i = lin_index(g, nw); + if (i >= pu(0u)) { + return; + } + let w = pu(1u); + let x = f32(i % w) + 0.5; + let y = f32(i / w) + 0.5; + var c = rgb_a(i); + for (var e = 0u; e < pu(2u); e++) { + let o = 3u + e * EYE_WORDS; + let ex = x - pf(o); + let ey = y - pf(o + 1u); + let d = sqrt(ex * ex + ey * ey) / pf(o + 2u); + var m = 1.0 - sstep(1.0, 1.35, d); + if (pf(o + 4u) != 0.0) { + m = 1.0 - sstep(0.95, 1.2, d); + } + let darken = pf(o + 3u); + if (m > 0.0) { + if (pf(o + 4u) != 0.0) { + let k = min(lum2020(c), 0.04) * (1.0 - 0.85 * darken); + c = c + (vec3(k) - c) * m; + } else { + let red = (c.x - max(c.y, c.z)) / max(c.x, 1e-6); + let a = m * sstep(0.25, 0.5, red); + let n = 0.5 * (c.y + c.z) * (1.0 - 0.9 * darken); + c = c + (vec3(n) - c) * a; + } + } + if (pf(o + 5u) != 0.0) { + let lx = x - pf(o + 6u); + let ly = y - pf(o + 7u); + let dc = sqrt(lx * lx + ly * ly) / pf(o + 8u); + let mc = 1.0 - sstep(0.5, 1.0, dc); + if (mc > 0.0) { + c = c + (vec3(0.9) - c) * mc; + } + } + } + put_rgb(i, c); +} diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 474d34043..d6fdab543 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -84,6 +84,23 @@ fn cases() -> Vec<(&'static str, Edit)> { s.mixer.orange.hue = 30.0; s.mixer.green.lum = 40.0; }), + ("point color", |s| { + use lightcraft_develop::PointColor; + s.point_colors = vec![ + PointColor { + lum: 0.62, + chroma: 0.09, + hue: 60.0, + hue_shift: 40.0, + sat_shift: -30.0, + lum_shift: 20.0, + range: 80.0, + ..Default::default() + }, + PointColor { lum: 0.5, chroma: 0.12, hue: 270.0, variance: -60.0, sat_shift: 40.0, hue_range: 80.0, ..Default::default() }, + PointColor { lum: 0.4, chroma: 0.01, hue: 0.0, lum_shift: -30.0, ..Default::default() }, + ]; + }), ("b&w mix", |s| { s.treatment = Treatment::Bw; s.bw_mix.blue = 60.0; @@ -102,6 +119,11 @@ fn cases() -> Vec<(&'static str, Edit)> { s.curve.master = vec![Point::new(0.0, 0.05), Point::new(0.5, 0.55), Point::new(1.0, 0.95)]; s.curve.blue = vec![Point::new(0.0, 0.0), Point::new(0.5, 0.45), Point::new(1.0, 1.0)]; }), + ("tone curves + refine saturation", |s| { + s.curve.master = vec![Point::new(0.0, 0.0), Point::new(0.25, 0.15), Point::new(0.75, 0.88), Point::new(1.0, 1.0)]; + s.curve.lights = 30.0; + s.curve.refine_saturation = 20.0; + }), ("vignette (highlight)", |s| { s.vignette.amount = -60.0; s.vignette.highlights = 50.0; @@ -253,6 +275,14 @@ fn cases() -> Vec<(&'static str, Edit)> { }, ]; }), + ("calibration", |s| { + s.calibration.shadows_tint = 40.0; + s.calibration.red_hue = 50.0; + s.calibration.red_sat = -30.0; + s.calibration.green_hue = -40.0; + s.calibration.blue_sat = 60.0; + s.calibration.blue_hue = 25.0; + }), ("spots + defringe (cpu stage)", |s| { s.spots = vec![Spot { points: vec![Point::new(0.3, 0.3)], size: 0.03, source_offset: Some(Point::new(0.1, 0.0)), ..Default::default() }]; s.optics.defringe_purple_amount = 5.0; @@ -357,3 +387,78 @@ fn cached_renders_match_uncached() { assert_eq!(warm.image, fresh.image, "step {k}"); } } + +#[test] +fn overlays_match() { + if !gpu() { + return; + } + // Diagnostic overlays run on the finished 8-bit image, after either renderer. + let src = scene(0, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + let s = DevelopSettings { + point_colors: vec![lightcraft_develop::PointColor { lum: 0.6, chroma: 0.05, hue: 300.0, hue_shift: 50.0, range: 90.0, ..Default::default() }], + ..Default::default() + }; + let req = RenderRequest { overlay: lightcraft_pipeline::Overlay::PointColorRange(0), ..RenderRequest::fit(640, 640) }; + check("point color range overlay", &src, &raw, &s, &req); + // Visualize Spots is a binary threshold of a high-pass: a 1-LSB difference near the threshold + // flips a pixel, so compare the share of differing pixels instead of LSBs. + let s = DevelopSettings::default(); + for t in [20u8, 50, 90] { + let req = RenderRequest { overlay: lightcraft_pipeline::Overlay::Spots(t), ..RenderRequest::fit(640, 640) }; + let cpu = render(&src, &raw, &s, &req).image; + let gpu = lightcraft_gpu::render(&src, &raw, &s, &req, None).expect("gpu").image; + let differ = cpu.data.iter().zip(&gpu.data).filter(|(a, b)| a != b).count() as f64 / cpu.data.len() as f64; + let white = cpu.data.iter().filter(|p| p[0] == 255).count() as f64 / cpu.data.len() as f64; + eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0); + assert!(differ < 0.002, "spots t={t}: {differ}"); + } +} + +#[test] +fn red_eye_matches() { + if !gpu() { + return; + } + // A face-like patch with a red pupil (left) and a glowing pet pupil (right). + let src = Arc::new(Rgb32f::from_fn(800, 400, |x, y| { + let d = |cx: f32, cy: f32| ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + if d(250.0, 200.0) < 20.0 { + [0.55, 0.05, 0.04] + } else if d(560.0, 190.0) < 24.0 { + [0.5, 0.7, 0.3] + } else if d(250.0, 200.0) < 40.0 || d(560.0, 190.0) < 45.0 { + [0.12, 0.1, 0.08] + } else { + [0.45, 0.3, 0.22] + } + })); + let info = SourceInfo::default(); + let eyes = vec![ + lightcraft_develop::RedEye { center: Point::new(0.31, 0.5), rx: 0.06, ry: 0.05, darken: 70.0, ..Default::default() }, + lightcraft_develop::RedEye { + center: Point::new(0.7, 0.48), + rx: 0.06, + ry: 0.06, + pupil_size: 60.0, + pet: true, + catchlight: Some(Point::new(-0.35, -0.35)), + ..Default::default() + }, + ]; + let s = DevelopSettings { red_eye: eyes.clone(), ..Default::default() }; + check("red + pet eye", &src, &info, &s, &RenderRequest::fit(800, 800)); + let g = lightcraft_gpu::render(&src, &info, &s, &RenderRequest::fit(800, 800), None).expect("gpu").image; + let (red, pet) = (g.data[200 * 800 + 250], g.data[190 * 800 + 560]); + assert!(red[0] < 80 && red[0].abs_diff(red[1]) < 10, "red pupil fixed on the GPU: {red:?}"); + assert!(pet[1] < 80 && pet[0].abs_diff(pet[1]) < 5, "pet pupil darkened on the GPU: {pet:?}"); + check("red + pet eye (small)", &src, &info, &s, &RenderRequest::fit(300, 300)); + // with a spot the scene-linear stage runs on the CPU (eyes included) + let s = DevelopSettings { + red_eye: eyes, + spots: vec![Spot { points: vec![Point::new(0.1, 0.2)], size: 0.02, source_offset: Some(Point::new(0.05, 0.0)), ..Default::default() }], + ..Default::default() + }; + check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600)); +} diff --git a/crates/merge/Cargo.toml b/crates/merge/Cargo.toml new file mode 100644 index 000000000..296f06beb --- /dev/null +++ b/crates/merge/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "lightcraft-merge" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft Photo Merge: HDR merge (align, deghost) and panorama stitching (L3)" + +[lints] +workspace = true + +[dependencies] +lightcraft-geom = { workspace = true } +lightcraft-color = { workspace = true } +lightcraft-raster = { workspace = true } +lightcraft-raw = { workspace = true } +lightcraft-codecs = { workspace = true } +lightcraft-meta = { workspace = true } +lightcraft-scenes = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +rayon = { workspace = true } diff --git a/crates/merge/src/align.rs b/crates/merge/src/align.rs new file mode 100644 index 000000000..4298f8100 --- /dev/null +++ b/crates/merge/src/align.rs @@ -0,0 +1,142 @@ +//! Frame alignment for HDR brackets (handheld, small motion): features on exposure-normalised, +//! gamma-encoded luminance at ≤ 1200 px, matched to the reference frame, then a RANSAC homography +//! (falling back to similarity, then translation, then identity when the scene gives too little). + +use lightcraft_geom::{Homography, Point}; +use lightcraft_raster::resample::{Filter, fit}; +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; + +use crate::features::{self, Features}; +use crate::ransac::{self, Model}; + +/// Long edge of the images features are detected on. +pub const WORK_EDGE: usize = 1200; + +/// A grey, perceptually encoded work image: `(luminance · gain)^(1/2.2)`, clamped to 0..1, and +/// the scale from full-resolution pixels to work pixels. +pub fn work_image(img: &Rgb32f, gain: f32, max_edge: usize) -> (Plane, f64) { + let small = if img.width.max(img.height) > max_edge { fit(img, max_edge, max_edge, Filter::Box) } else { img.clone() }; + let scale = small.width as f64 / img.width as f64; + let p = small.map(|c| { + let y = (0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]) * gain; + y.clamp(0.0, 1.0).powf(1.0 / 2.2) + }); + (p, scale) +} + +/// Median luminance of the (unsaturated, non-black) pixels: a brightness estimate for frames +/// without EXIF. +pub fn median_luma(img: &Rgb32f, clip: f32) -> f32 { + let step = (img.data.len() / 100_000).max(1); + let mut v: Vec = + img.data.iter().step_by(step).filter(|p| p[0].max(p[1]).max(p[2]) < clip * 0.98).map(|c| 0.25 * c[0] + 0.6 * c[1] + 0.15 * c[2]).collect(); + if v.is_empty() { + return clip; + } + v.sort_by(|a, b| a.total_cmp(b)); + v[v.len() / 2].max(1e-6) +} + +#[derive(Clone, Debug)] +pub struct Alignment { + /// Maps reference pixel coordinates to this frame's coordinates (full resolution). + pub h: Homography, + pub model: &'static str, + pub inliers: usize, + pub rms: f64, +} + +impl Alignment { + pub fn identity() -> Alignment { + Alignment { h: Homography::IDENTITY, model: "identity", inliers: 0, rms: 0.0 } + } +} + +/// Conjugate a work-resolution homography to full resolution: S⁻¹ · H · S with S = diag(s, s, 1). +pub fn scale_homography(h: &Homography, s: f64) -> Homography { + let m = h.0; + Homography([m[0], m[1], m[2] / s, m[3], m[4], m[5] / s, m[6] * s, m[7] * s, m[8]]) +} + +/// Whether `h` is a plausible handheld motion for a `w × h` frame: corners move less than +/// `max_frac` of the diagonal and the area changes by < 20 %. +pub fn plausible(h: &Homography, w: f64, ht: f64, max_frac: f64) -> bool { + let diag = (w * w + ht * ht).sqrt(); + let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, ht), Point::new(0.0, ht)]; + let moved: Vec = corners.iter().map(|&c| h.apply(c)).collect(); + if corners.iter().zip(&moved).any(|(a, b)| a.dist(*b) > max_frac * diag || !b.x.is_finite() || !b.y.is_finite()) { + return false; + } + let area = |p: &[Point]| { + let mut s = 0.0; + for i in 0..p.len() { + let (a, b) = (p[i], p[(i + 1) % p.len()]); + s += a.x * b.y - b.x * a.y; + } + s.abs() / 2.0 + }; + let r = area(&moved) / (w * ht); + (0.8..1.25).contains(&r) +} + +/// Align `frame` to `reference` (both as work images with features already detected). +pub fn align_pair(ref_feat: &Features, feat: &Features, scale: f64, full_w: usize, full_h: usize, seed: u64) -> Alignment { + let m = features::match_features(ref_feat, feat, 0.8); + let a: Vec = m.iter().map(|(i, _)| Point::new(ref_feat.points[*i].x as f64, ref_feat.points[*i].y as f64)).collect(); + let b: Vec = m.iter().map(|(_, j)| Point::new(feat.points[*j].x as f64, feat.points[*j].y as f64)).collect(); + let (ww, wh) = (full_w as f64 * scale, full_h as f64 * scale); + // Fit every model; keep the simplest one that explains (almost) as many matches as the most + // general one — extra degrees of freedom fitted to clustered points extrapolate badly. + let mut fits: Vec<(&'static str, ransac::Fit)> = Vec::new(); + for (model, name, min) in [(Model::Translation, "translation", 5), (Model::Similarity, "similarity", 8), (Model::Homography, "homography", 16)] { + if let Some(f) = ransac::ransac(model, &a, &b, 1.5, min, seed) + && plausible(&f.h, ww, wh, 0.12) + { + fits.push((name, f)); + } + } + let Some(most) = fits.iter().map(|(_, f)| f.inliers.len()).max() else { return Alignment::identity() }; + let (name, f) = fits.into_iter().find(|(_, f)| f.inliers.len() as f64 >= most as f64 * 0.97).expect("the best fit qualifies"); + Alignment { h: scale_homography(&f.h, scale), model: name, inliers: f.inliers.len(), rms: f.rms / scale } +} + +/// Resample `img` into the reference geometry: `out(p) = img(h(p))`. Returns the warped image and +/// a validity mask (false where `h(p)` falls outside `img`). +pub fn warp(img: &Rgb32f, h: &Homography) -> (Rgb32f, Vec) { + let (w, ht) = (img.width, img.height); + if *h == Homography::IDENTITY { + return (img.clone(), vec![true; w * ht]); + } + let mut out = Rgb32f::new(w, ht); + let mut valid = vec![false; w * ht]; + out.data.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| { + for x in 0..w { + let p = h.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let (sx, sy) = (p.x as f32, p.y as f32); + if sx >= 0.0 && sy >= 0.0 && sx <= w as f32 && sy <= ht as f32 { + row[x] = img.sample_bilinear(sx, sy); + vrow[x] = true; + } + } + }); + (out, valid) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn homography_scaling_conjugates() { + let h = Homography([1.01, 0.02, 5.0, -0.01, 0.99, -3.0, 1e-5, 2e-5, 1.0]); + let s = 0.25; + let full = scale_homography(&h, s); + let p = Point::new(400.0, 300.0); + let w = h.apply(Point::new(p.x * s, p.y * s)); + let f = full.apply(p); + assert!((f.x * s - w.x).abs() < 1e-9 && (f.y * s - w.y).abs() < 1e-9); + assert!(plausible(&Homography::IDENTITY, 100.0, 100.0, 0.1)); + assert!(!plausible(&Homography([1.0, 0.0, 50.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), 100.0, 100.0, 0.1)); + } +} diff --git a/crates/merge/src/features.rs b/crates/merge/src/features.rs new file mode 100644 index 000000000..353bed8f7 --- /dev/null +++ b/crates/merge/src/features.rs @@ -0,0 +1,312 @@ +//! Feature points and descriptors (our own implementation, after the published MOPS method: +//! M. Brown, R. Szeliski, S. Winder, "Multi-Image Matching using Multi-Scale Oriented Patches", +//! CVPR 2005). +//! +//! - **Detector:** Harris corners on a Gaussian pyramid (octaves of ×½). The corner strength is the +//! harmonic mean of the structure tensor's eigenvalues, `det / trace` (MOPS); integration scale +//! σᵢ = 1.5, derivative scale σd = 1.0. Local maxima over 3×3 above a threshold, refined to +//! sub-pixel by a parabola fit in x and y. +//! - **Selection:** adaptive non-maximal suppression (ANMS) keeps the `max` points with the largest +//! suppression radius (distance to the nearest point that is ≥ 10 % stronger), which spreads +//! points evenly over the image. +//! - **Orientation:** the direction of the gradient of the level blurred with σ = 4.5. +//! - **Descriptor:** 8 × 8 samples with 5-pixel spacing (a 40 × 40 window) taken from the level +//! blurred with σ = 2.5, on a grid rotated by the orientation; normalised to zero mean and unit +//! variance (bias/gain invariant) — 64 floats. +//! - **Matching:** brute-force nearest neighbour in L2 with Lowe's ratio test, kept only when +//! mutually consistent. + +use lightcraft_raster::Plane; +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::resample::half; +use rayon::prelude::*; + +pub const DESC_LEN: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Keypoint { + /// Position in pixels of the input image (pixel centres at +0.5). + pub x: f32, + pub y: f32, + /// Pyramid level (0 = full resolution). + pub level: u8, + /// Orientation (radians). + pub angle: f32, + pub strength: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct Features { + pub points: Vec, + pub desc: Vec<[f32; DESC_LEN]>, +} + +impl Features { + pub fn len(&self) -> usize { + self.points.len() + } + pub fn is_empty(&self) -> bool { + self.points.is_empty() + } +} + +/// Detect up to `max` features in a grey image (values roughly perceptual, e.g. gamma-encoded 0..1). +pub fn detect(img: &Plane, max: usize) -> Features { + let mut levels = vec![img.clone()]; + while levels.len() < 4 { + let l = levels.last().expect("non-empty"); + if l.width.min(l.height) < 96 { + break; + } + levels.push(half(l)); + } + let per_level: Vec<(Vec, Vec<[f32; DESC_LEN]>)> = levels + .par_iter() + .enumerate() + .map(|(li, lvl)| { + let cands = harris(lvl); + let orient_img = gaussian(lvl, 4.5); + let desc_img = gaussian(lvl, 2.5); + let (gx, gy) = gradients(&orient_img); + let scale = (1u32 << li) as f32; + let mut pts = Vec::new(); + let mut descs = Vec::new(); + for (x, y, s) in cands { + let (xi, yi) = (x as usize, y as usize); + let (dx, dy) = (gx.get(xi.min(gx.width - 1), yi.min(gx.height - 1)), gy.get(xi.min(gx.width - 1), yi.min(gx.height - 1))); + let angle = dy.atan2(dx); + let Some(d) = descriptor(&desc_img, x, y, angle) else { continue }; + pts.push(Keypoint { x: x * scale, y: y * scale, level: li as u8, angle, strength: s }); + descs.push(d); + } + (pts, descs) + }) + .collect(); + let mut all: Vec<(Keypoint, [f32; DESC_LEN])> = per_level.into_iter().flat_map(|(p, d)| p.into_iter().zip(d)).collect(); + all.sort_by(|a, b| b.0.strength.total_cmp(&a.0.strength)); + all.truncate(max.saturating_mul(8).max(64)); + let keep = anms(&all.iter().map(|(k, _)| *k).collect::>(), max); + let mut f = Features::default(); + for i in keep { + f.points.push(all[i].0); + f.desc.push(all[i].1); + } + f +} + +fn gradients(p: &Plane) -> (Plane, Plane) { + let (w, h) = (p.width, p.height); + let gx = Plane::from_fn(w, h, |x, y| (p.get_clamped(x as isize + 1, y as isize) - p.get_clamped(x as isize - 1, y as isize)) * 0.5); + let gy = Plane::from_fn(w, h, |x, y| (p.get_clamped(x as isize, y as isize + 1) - p.get_clamped(x as isize, y as isize - 1)) * 0.5); + (gx, gy) +} + +/// Harris corners of one level: (x, y in level pixel coordinates (centre +0.5), strength). +fn harris(lvl: &Plane) -> Vec<(f32, f32, f32)> { + let (w, h) = (lvl.width, lvl.height); + if w < 24 || h < 24 { + return vec![]; + } + let s = gaussian(lvl, 1.0); + let (gx, gy) = gradients(&s); + let xx = gaussian(&gx.zip_map(&gx, |a, b| a * b), 1.5); + let yy = gaussian(&gy.zip_map(&gy, |a, b| a * b), 1.5); + let xy = gaussian(&gx.zip_map(&gy, |a, b| a * b), 1.5); + let mut r = Plane::new(w, h); + for i in 0..r.data.len() { + let (a, b, c) = (xx.data[i], yy.data[i], xy.data[i]); + let tr = a + b; + r.data[i] = if tr > 1e-12 { (a * b - c * c) / tr } else { 0.0 }; + } + // threshold relative to the level's strongest response (robust to image contrast) + let mut sorted: Vec = r.data.iter().copied().step_by(7).collect(); + sorted.sort_by(|a, b| b.total_cmp(a)); + let top = sorted.get(sorted.len() / 200).copied().unwrap_or(0.0); + let thr = (top * 0.01).max(1e-7); + let border = 22usize; + let rows: Vec> = (border..h.saturating_sub(border)) + .into_par_iter() + .map(|y| { + let mut out = Vec::new(); + for x in border..w.saturating_sub(border) { + let v = r.get(x, y); + if v <= thr { + continue; + } + let mut is_max = true; + 'n: for dy in -1i32..=1 { + for dx in -1i32..=1 { + if (dx, dy) != (0, 0) { + let o = r.get((x as i32 + dx) as usize, (y as i32 + dy) as usize); + if o > v || (o == v && (dy < 0 || (dy == 0 && dx < 0))) { + is_max = false; + break 'n; + } + } + } + } + if !is_max { + continue; + } + let sub = |a: f32, b: f32, c: f32| { + let d = a - 2.0 * b + c; + if d.abs() > 1e-12 { (0.5 * (a - c) / d).clamp(-0.5, 0.5) } else { 0.0 } + }; + let ox = sub(r.get(x - 1, y), v, r.get(x + 1, y)); + let oy = sub(r.get(x, y - 1), v, r.get(x, y + 1)); + out.push((x as f32 + 0.5 + ox, y as f32 + 0.5 + oy, v)); + } + out + }) + .collect(); + rows.into_iter().flatten().collect() +} + +/// Adaptive non-maximal suppression: indices (into `pts`, sorted by decreasing strength) of the `max` +/// points with the largest suppression radii. +fn anms(pts: &[Keypoint], max: usize) -> Vec { + if pts.len() <= max { + return (0..pts.len()).collect(); + } + let radii: Vec = (0..pts.len()) + .into_par_iter() + .map(|i| { + let p = pts[i]; + let mut best = f32::INFINITY; + for q in &pts[..i] { + if q.strength * 0.9 > p.strength { + let d = (q.x - p.x).powi(2) + (q.y - p.y).powi(2); + best = best.min(d); + } + } + best + }) + .collect(); + let mut idx: Vec = (0..pts.len()).collect(); + idx.sort_by(|&a, &b| radii[b].total_cmp(&radii[a]).then(a.cmp(&b))); + idx.truncate(max); + idx +} + +fn descriptor(img: &Plane, x: f32, y: f32, angle: f32) -> Option<[f32; DESC_LEN]> { + let (s, c) = angle.sin_cos(); + let mut d = [0f32; DESC_LEN]; + let (w, h) = (img.width as f32, img.height as f32); + for j in 0..8 { + for i in 0..8 { + let u = (i as f32 - 3.5) * 5.0; + let v = (j as f32 - 3.5) * 5.0; + let sx = x + c * u - s * v; + let sy = y + s * u + c * v; + if sx < 0.5 || sy < 0.5 || sx > w - 0.5 || sy > h - 0.5 { + return None; + } + d[j * 8 + i] = bilinear(img, sx, sy); + } + } + let mean = d.iter().sum::() / DESC_LEN as f32; + let var = d.iter().map(|v| (v - mean) * (v - mean)).sum::() / DESC_LEN as f32; + if var < 1e-10 { + return None; + } + let inv = 1.0 / var.sqrt(); + for v in d.iter_mut() { + *v = (*v - mean) * inv; + } + Some(d) +} + +#[inline] +pub fn bilinear(img: &Plane, x: f32, y: f32) -> f32 { + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = fx.floor(); + let y0 = fy.floor(); + let (tx, ty) = (fx - x0, fy - y0); + let (x0, y0) = (x0 as isize, y0 as isize); + let a = img.get_clamped(x0, y0); + let b = img.get_clamped(x0 + 1, y0); + let c = img.get_clamped(x0, y0 + 1); + let d = img.get_clamped(x0 + 1, y0 + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + top + (bot - top) * ty +} + +fn dist2(a: &[f32; DESC_LEN], b: &[f32; DESC_LEN]) -> f32 { + let mut s = 0.0; + for i in 0..DESC_LEN { + let d = a[i] - b[i]; + s += d * d; + } + s +} + +/// Nearest and second-nearest neighbour of each descriptor of `a` in `b`: (index, d1², d2²). +fn nearest(a: &Features, b: &Features) -> Vec<(usize, f32, f32)> { + a.desc + .par_iter() + .map(|da| { + let (mut bi, mut d1, mut d2) = (usize::MAX, f32::INFINITY, f32::INFINITY); + for (j, db) in b.desc.iter().enumerate() { + let d = dist2(da, db); + if d < d1 { + d2 = d1; + d1 = d; + bi = j; + } else if d < d2 { + d2 = d; + } + } + (bi, d1, d2) + }) + .collect() +} + +/// Putative matches `(index in a, index in b)`: ratio test (`ratio` on distances) and mutual +/// consistency. +pub fn match_features(a: &Features, b: &Features, ratio: f32) -> Vec<(usize, usize)> { + if a.is_empty() || b.is_empty() { + return vec![]; + } + let ab = nearest(a, b); + let ba = nearest(b, a); + let r2 = ratio * ratio; + ab.iter().enumerate().filter(|(i, (j, d1, d2))| *j != usize::MAX && *d1 < r2 * *d2 && ba[*j].0 == *i).map(|(i, (j, _, _))| (i, *j)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pattern(w: usize, h: usize, dx: f32, dy: f32) -> Plane { + Plane::from_fn(w, h, |x, y| { + let (u, v) = (x as f32 + 0.5 - dx, y as f32 + 0.5 - dy); + let a = ((u * 0.071).sin() * (v * 0.053).cos() + + (u * 0.023 + v * 0.031).sin() * 0.7 + + ((u * 0.011).floor() as i32 + (v * 0.013).floor() as i32).rem_euclid(2) as f32 * 0.6) + * 0.25 + + 0.5; + a.clamp(0.0, 1.0) + }) + } + + #[test] + fn shifted_images_match_with_the_shift() { + let a = pattern(400, 300, 0.0, 0.0); + let b = pattern(400, 300, 7.0, -4.0); + let fa = detect(&a, 300); + let fb = detect(&b, 300); + assert!(fa.len() > 50, "{} features", fa.len()); + let m = match_features(&fa, &fb, 0.8); + assert!(m.len() > 20, "{} matches", m.len()); + let good = m + .iter() + .filter(|(i, j)| { + let (p, q) = (fa.points[*i], fb.points[*j]); + ((q.x - p.x - 7.0).powi(2) + (q.y - p.y + 4.0).powi(2)).sqrt() < 1.5 + }) + .count(); + assert!(good as f32 > m.len() as f32 * 0.8, "{good}/{}", m.len()); + } +} diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs new file mode 100644 index 000000000..df016b5d4 --- /dev/null +++ b/crates/merge/src/frame.rs @@ -0,0 +1,200 @@ +//! Merge inputs: scene-linear frames decoded from raw files (camera RGB, before white balance) or +//! standard images (linear light in the file's primaries), with their exposure from EXIF. + +use lightcraft_color::Mat3; +use lightcraft_geom::Orientation; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; +use lightcraft_raster::resample::{Filter, fit}; +use lightcraft_raw::ColorData; + +use crate::{MergeError, Result}; + +/// What the RGB values of a frame mean. +#[derive(Clone, Debug, PartialEq)] +pub enum FrameColor { + /// Camera RGB of a raw file (not white balanced): the DNG colour tags of the source. + Camera(Box), + /// Linear RGB with these primaries: linear RGB → XYZ (D50-adapted, ICC PCS). + Linear { to_xyz_d50: Mat3 }, +} + +/// One input photo. +#[derive(Clone, Debug)] +pub struct Frame { + /// Scene-linear pixels; 1.0 = the sensor/file white level. + pub image: Rgb32f, + /// Values at or above this (any channel) are saturated. + pub clip: f32, + /// Capture brightness from EXIF, in EV: log2(t · ISO / N²) (higher = brighter frame); `None` + /// when the file doesn't say. + pub exposure: Option, + pub raw: bool, + pub color: FrameColor, + /// How `image` must be rotated/flipped for display (`Normal` once applied). + pub orientation: Orientation, + pub metadata: Metadata, + /// Raw files: the source's `BaselineExposure` (EV), carried into the output. + pub baseline_exposure: f64, +} + +impl Frame { + /// A frame from linear pixels in the develop working space (linear Rec.2020), e.g. a rendered + /// procedural scene. `exposure` as in [`Frame::exposure`]. + pub fn from_linear_rec2020(image: Rgb32f, exposure: Option) -> Frame { + let to_xyz_d50 = lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::REC2020.to_xyz()); + let clip = detect_clip(&image); + Frame { + image, + clip, + exposure, + raw: false, + color: FrameColor::Linear { to_xyz_d50 }, + orientation: Orientation::Normal, + metadata: Metadata::default(), + baseline_exposure: 0.0, + } + } + + pub fn width(&self) -> usize { + self.image.width + } + pub fn height(&self) -> usize { + self.image.height + } + + /// Apply the display orientation to the pixels. + pub fn orient(&mut self) { + if self.orientation != Orientation::Normal { + self.image = self.image.oriented(self.orientation); + self.orientation = Orientation::Normal; + } + } +} + +/// EV of a capture from its EXIF: log2(t · ISO / N²). ISO defaults to 100 and N to 1 when absent +/// (only ratios between frames matter); `None` without an exposure time. +pub fn exif_exposure(m: &Metadata) -> Option { + let t = m.exposure_time.filter(|t| *t > 0.0 && t.is_finite())?; + let iso = m.iso.filter(|i| *i > 0).unwrap_or(100) as f64; + let n = m.f_number.filter(|n| *n > 0.0 && n.is_finite()).unwrap_or(1.0); + Some((t * iso / (n * n)).log2()) +} + +/// The saturation level of a frame: 1.0, or the (lower) level at which values pile up when the +/// file's white level is optimistic. Returned slightly below the level. +pub fn detect_clip(img: &Rgb32f) -> f32 { + let maxv = img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).fold(0.0f32, f32::max); + if maxv.is_nan() || maxv <= 0.0 { + return 1.0; + } + let level = maxv.min(1.0); + // a pile-up: the top 0.5 % of the range is ≥ 4× denser than the 2 % just below it + let (mut near, mut below) = (0usize, 0usize); + for p in &img.data { + let m = p[0].max(p[1]).max(p[2]); + if m >= level * 0.995 { + near += 1; + } else if m >= level * 0.97 && m < level * 0.99 { + below += 1; + } + } + let piled = near as f64 > img.data.len() as f64 * 1e-4 && near >= 4 && near as f64 / 0.005 > 4.0 * below as f64 / 0.02; + if maxv >= 1.0 || piled { level * 0.995 } else { 1.0 } +} + +/// Decode a photo (raw or standard format) into a frame no larger than `max_edge` (when given). +/// `orient` applies the EXIF orientation to the pixels. +pub fn load_frame(bytes: &[u8], max_edge: Option, orient: bool) -> Result { + let metadata = lightcraft_meta::extract(bytes); + let exposure = exif_exposure(&metadata); + if lightcraft_raw::probe(bytes).is_some() { + let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; + // geometric lens corrections stay with the develop settings ("profile corrections") + raw.opcodes + .list3 + .retain(|op| !matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })); + let small = max_edge.is_some_and(|m| m <= 1200); + let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; + for p in image.data.iter_mut() { + *p = p.map(|v| v.max(0.0)); + } + let clip = detect_clip(&image); + if let Some(m) = max_edge + && image.width.max(image.height) > m + { + image = fit(&image, m, m, Filter::Box); + } + let mut f = Frame { + image, + clip, + exposure, + raw: true, + color: FrameColor::Camera(Box::new(raw.color.clone())), + orientation: raw.orientation, + metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, + baseline_exposure: raw.color.baseline_exposure, + }; + if f.exposure.is_none() { + f.exposure = exif_exposure(&f.metadata); + } + if orient { + f.orient(); + } + return Ok(f); + } + let opts = match max_edge { + Some(m) => lightcraft_codecs::DecodeOptions::fit(m as u32, m as u32), + None => lightcraft_codecs::DecodeOptions::default(), + }; + let d = lightcraft_codecs::decode(bytes, opts).map_err(|e| MergeError::Decode(e.to_string()))?; + let mut image = d.image; + if let Some(m) = max_edge + && image.width.max(image.height) > m + { + image = fit(&image, m, m, Filter::Box); + } + let clip = detect_clip(&image); + let mut f = Frame { + image, + clip, + exposure, + raw: false, + color: FrameColor::Linear { to_xyz_d50: d.space.to_xyz_d50 }, + orientation: Orientation::from_exif(d.orientation), + metadata, + baseline_exposure: 0.0, + }; + if orient { + f.orient(); + } + Ok(f) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposure_from_exif() { + let m = Metadata { exposure_time: Some(1.0 / 125.0), f_number: Some(8.0), iso: Some(100), ..Default::default() }; + let e = exif_exposure(&m).unwrap(); + let m2 = Metadata { exposure_time: Some(4.0 / 125.0), ..m.clone() }; + assert!((exif_exposure(&m2).unwrap() - e - 2.0).abs() < 1e-9); + assert!(exif_exposure(&Metadata::default()).is_none()); + } + + #[test] + fn clip_detection() { + let mut img = Rgb32f::from_fn(100, 100, |x, _| [x as f32 / 100.0; 3]); + assert_eq!(detect_clip(&img), 1.0); + for p in img.data.iter_mut().take(500) { + *p = [0.9, 0.9, 0.9]; + } + for p in img.data.iter_mut() { + *p = p.map(|v| v.min(0.9)); + } + assert!((detect_clip(&img) - 0.9 * 0.995).abs() < 1e-6); + } +} diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs new file mode 100644 index 000000000..7c596c4a7 --- /dev/null +++ b/crates/merge/src/hdr.rs @@ -0,0 +1,449 @@ +//! HDR merge of exposure brackets. +//! +//! 1. **Order and reference.** Frames are ordered by brightness (EXIF exposure, or median +//! luminance without EXIF); the middle one is the reference (geometry, colour and the output's +//! default brightness come from it). +//! 2. **Auto align** ([`crate::align`]): each frame is registered to the reference and resampled +//! into its geometry. +//! 3. **Exposure normalisation.** The exposure ratio between brightness-adjacent frames is +//! measured robustly — the median of log ratios over pixels well exposed in both — and chained +//! to the reference; EXIF is the fallback when too few pixels overlap. Radiance +//! `Rᵢ = vᵢ / kᵢ` is expressed in reference units. +//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer +//! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none +//! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a +//! tiny weight so fully saturated pixels still get a value. +//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test +//! against the reference: the reference radiance (or, where the reference is clipped/too dark, +//! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that +//! disagree by more than a threshold (in log units, with a noise floor) are masked there. Masks +//! are dilated and feathered (Gaussian), upsampled, and remove the frame's weight. Their union +//! is the deghost overlay. + +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::align::{self, Alignment}; +use crate::frame::{Frame, FrameColor}; +use crate::{MergeError, Progress, Result, check}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Deghost { + #[default] + None, + Low, + Medium, + High, +} + +impl Deghost { + /// (log-ratio threshold, dilation/feather radius in deghost-map pixels). + fn params(self) -> Option<(f32, usize)> { + match self { + Deghost::None => None, + Deghost::Low => Some((1.0, 2)), + Deghost::Medium => Some((0.6, 3)), + Deghost::High => Some((0.35, 5)), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HdrOptions { + pub align: bool, + pub deghost: Deghost, +} + +impl Default for HdrOptions { + fn default() -> Self { + HdrOptions { align: true, deghost: Deghost::None } + } +} + +/// The merged result, in the reference frame's geometry and units. +#[derive(Clone, Debug)] +pub struct HdrResult { + /// Scene-linear radiance; 1.0 = the reference frame's white level. + pub radiance: Rgb32f, + /// Deghost overlay (0..1, the union of the masks) at the radiance's size; empty without deghosting. + pub ghost: Plane, + /// Index (into the inputs) of the reference frame. + pub reference: usize, + /// Exposure of each input relative to the reference, in EV (as used for the merge). + pub ev: Vec, + /// The same from EXIF, when every frame had it. + pub ev_exif: Option>, + pub alignments: Vec, + pub color: FrameColor, + pub raw: bool, + pub orientation: lightcraft_geom::Orientation, + pub metadata: lightcraft_meta::Metadata, + pub baseline_exposure: f64, +} + +#[inline] +fn smoothstep(a: f32, b: f32, x: f32) -> f32 { + let t = ((x - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[inline] +fn maxc(p: [f32; 3]) -> f32 { + p[0].max(p[1]).max(p[2]) +} + +/// Merge exposure brackets. Frames must share their pixel size (and orientation). +pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) -> Result { + let n = frames.len(); + if n < 2 { + return Err(MergeError::TooFew(n, 2)); + } + let (w, h) = (frames[0].width(), frames[0].height()); + if frames.iter().any(|f| f.width() != w || f.height() != h) { + return Err(MergeError::Mismatch("HDR merge needs photos of the same size".into())); + } + if frames.iter().any(|f| f.orientation != frames[0].orientation) { + for f in frames.iter_mut() { + f.orient(); + } + let (w0, h0) = (frames[0].width(), frames[0].height()); + if frames.iter().any(|f| f.width() != w0 || f.height() != h0) { + return Err(MergeError::Mismatch("HDR merge needs photos of the same size".into())); + } + } + let (w, h) = (frames[0].width(), frames[0].height()); + check(progress, 0.02, "Analysing exposures")?; + + // 1. brightness order and reference + let exif: Option> = frames.iter().map(|f| f.exposure).collect(); + let medians: Vec = frames.par_iter().map(|f| align::median_luma(&f.image, f.clip)).collect(); + let brightness: Vec = match &exif { + Some(e) if e.iter().any(|v| (v - e[0]).abs() > 0.1) => e.clone(), + _ => medians.iter().map(|m| (*m as f64).log2()).collect(), + }; + let mut order: Vec = (0..n).collect(); + order.sort_by(|&a, &b| brightness[a].total_cmp(&brightness[b]).then(a.cmp(&b))); + let reference = order[n / 2]; + + // 2. alignment + let mut alignments = vec![Alignment::identity(); n]; + let mut valid: Vec>> = vec![None; n]; + if opts.align { + check(progress, 0.05, "Aligning")?; + let g0 = 0.18 / medians[reference].max(1e-6); + let rel: Vec = (0..n).map(|i| 2f64.powf(brightness[reference] - brightness[i]) as f32).collect(); + let rel: Vec = if exif.is_some() && brightness.iter().any(|b| (b - brightness[0]).abs() > 0.1) { + rel + } else { + (0..n).map(|i| medians[reference] / medians[i].max(1e-6)).collect() + }; + let feats: Vec<(crate::features::Features, f64)> = frames + .par_iter() + .enumerate() + .map(|(i, f)| { + let (p, s) = align::work_image(&f.image, g0 * rel[i], align::WORK_EDGE); + (crate::features::detect(&p, 1500), s) + }) + .collect(); + check(progress, 0.25, "Aligning")?; + let al: Vec<(usize, Alignment)> = (0..n) + .into_par_iter() + .filter(|&i| i != reference) + .map(|i| (i, align::align_pair(&feats[reference].0, &feats[i].0, feats[i].1, w, h, i as u64 + 1))) + .collect(); + for (i, a) in al { + alignments[i] = a; + } + check(progress, 0.35, "Resampling")?; + let warped: Vec<(usize, Rgb32f, Vec)> = (0..n) + .into_par_iter() + .filter(|&i| i != reference && alignments[i].model != "identity") + .map(|i| { + let (img, v) = align::warp(&frames[i].image, &alignments[i].h); + (i, img, v) + }) + .collect(); + for (i, img, v) in warped { + frames[i].image = img; + valid[i] = Some(v); + } + } + check(progress, 0.45, "Normalising exposures")?; + + // 3. exposure ratios between brightness neighbours, chained to the reference (ln units) + let mut step_ln = vec![0f64; n.saturating_sub(1)]; + for p in 0..n - 1 { + let (a, b) = (order[p], order[p + 1]); + let est = log_ratio(&frames[a], valid[a].as_deref(), &frames[b], valid[b].as_deref()); + step_ln[p] = match (est, &exif) { + (Some(r), _) => r, + (None, Some(e)) => (e[b] - e[a]) * std::f64::consts::LN_2, + (None, None) => ((medians[b] / medians[a].max(1e-6)) as f64).max(1e-6).ln(), + }; + } + let mut ln_k = vec![0f64; n]; + let rpos = n / 2; + for p in (0..rpos).rev() { + ln_k[order[p]] = ln_k[order[p + 1]] - step_ln[p]; + } + for p in rpos + 1..n { + ln_k[order[p]] = ln_k[order[p - 1]] + step_ln[p - 1]; + } + let k: Vec = ln_k.iter().map(|v| v.exp() as f32).collect(); + let ev: Vec = ln_k.iter().map(|v| v / std::f64::consts::LN_2).collect(); + let ev_exif = exif.as_ref().map(|e| e.iter().map(|v| v - e[reference]).collect()); + + // 4./5. deghost maps (small scale) + check(progress, 0.55, "Deghosting")?; + let ghost_small: Option<(Vec, usize)> = opts.deghost.params().map(|(thr, r)| deghost_maps(&frames, &valid, &k, &order, reference, thr, r)); + check(progress, 0.7, "Merging")?; + + let darkest = order[0]; + let clips: Vec = frames.iter().map(|f| f.clip).collect(); + let mut out = Rgb32f::new(w, h); + let fr = &frames; + let vd = &valid; + let gs = ghost_small.as_ref(); + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for x in 0..w { + let i_px = y * w + x; + let mut acc = [0f64; 3]; + let mut ws = 0f64; + for i in 0..n { + if vd[i].as_ref().is_some_and(|v| !v[i_px]) { + continue; + } + let v = fr[i].image.data[i_px]; + let m = maxc(v); + let mut wt = k[i] * (1.0 - smoothstep(0.80 * clips[i], 0.96 * clips[i], m)); + if i == darkest { + wt += 1e-6; + } + if i == reference { + wt += 1e-9; + } + if i != reference + && let Some((maps, fac)) = gs + { + let g = sample_plane(&maps[i], (x as f32 + 0.5) / *fac as f32, (y as f32 + 0.5) / *fac as f32); + wt *= 1.0 - g; + } + if wt <= 0.0 { + continue; + } + let inv = 1.0 / k[i]; + for c in 0..3 { + acc[c] += (wt * v[c] * inv) as f64; + } + ws += wt as f64; + } + if ws > 0.0 { + row[x] = acc.map(|a| (a / ws) as f32); + } else { + row[x] = fr[reference].image.data[i_px]; + } + } + }); + check(progress, 0.92, "Merging")?; + + let ghost = match &ghost_small { + Some((maps, fac)) => { + let fac = *fac as f32; + let mut g = Plane::new(w, h); + g.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let (sx, sy) = ((x as f32 + 0.5) / fac, (y as f32 + 0.5) / fac); + *o = (0..n).filter(|&i| i != reference).map(|i| sample_plane(&maps[i], sx, sy)).fold(0.0, f32::max); + } + }); + g + } + None => Plane::new(0, 0), + }; + let rf = &frames[reference]; + Ok(HdrResult { + radiance: out, + ghost, + reference, + ev, + ev_exif, + alignments, + color: rf.color.clone(), + raw: rf.raw, + orientation: rf.orientation, + metadata: rf.metadata.clone(), + baseline_exposure: rf.baseline_exposure, + }) +} + +/// Bilinear sample of a plane at continuous coordinates (pixel centres at +0.5). +pub(crate) fn sample_plane(p: &Plane, x: f32, y: f32) -> f32 { + if p.width == 0 || p.height == 0 { + return 0.0; + } + crate::features::bilinear(p, x, y) +} + +/// Median of ln(b / a) on the green channel over pixels well exposed in both (≥ 500 samples). +fn log_ratio(a: &Frame, va: Option<&[bool]>, b: &Frame, vb: Option<&[bool]>) -> Option { + let len = a.image.data.len(); + let step = (len / 400_000).max(1); + let (ca, cb) = (a.clip, b.clip); + let lo = |f: &Frame| if f.raw { 0.01 } else { 0.04 }; + let (la, lb) = (lo(a) * ca, lo(b) * cb); + let mut v: Vec = (0..len) + .into_par_iter() + .step_by(step) + .filter_map(|i| { + if va.is_some_and(|v| !v[i]) || vb.is_some_and(|v| !v[i]) { + return None; + } + let (pa, pb) = (a.image.data[i], b.image.data[i]); + if maxc(pa) >= 0.8 * ca || maxc(pb) >= 0.8 * cb || pa[1] <= la || pb[1] <= lb { + return None; + } + Some((pb[1] / pa[1]).ln()) + }) + .collect(); + if v.len() < 500 { + return None; + } + v.sort_by(|x, y| x.total_cmp(y)); + Some(v[v.len() / 2] as f64) +} + +/// Box-downsample by `fac` (with the fraction of valid source pixels). +fn shrink(img: &Rgb32f, valid: Option<&[bool]>, fac: usize) -> (Rgb32f, Vec) { + let (w, h) = (img.width.div_ceil(fac), img.height.div_ceil(fac)); + let mut out = Rgb32f::new(w, h); + let mut vf = vec![0f32; w * h]; + out.data.par_chunks_mut(w).zip(vf.par_chunks_mut(w)).enumerate().for_each(|(y, (row, vrow))| { + for x in 0..w { + let mut s = [0f32; 3]; + let (mut nv, mut nt) = (0usize, 0usize); + for sy in y * fac..((y + 1) * fac).min(img.height) { + for sx in x * fac..((x + 1) * fac).min(img.width) { + let i = sy * img.width + sx; + nt += 1; + if valid.is_none_or(|v| v[i]) { + let p = img.data[i]; + for c in 0..3 { + s[c] += p[c]; + } + nv += 1; + } + } + } + if nv > 0 { + row[x] = s.map(|v| v / nv as f32); + } + vrow[x] = nv as f32 / nt.max(1) as f32; + } + }); + (out, vf) +} + +/// Per-frame ghost masks (0..1) at 1/`fac` scale; the reference's is all zero. +fn deghost_maps( + frames: &[Frame], + valid: &[Option>], + k: &[f32], + order: &[usize], + reference: usize, + thr: f32, + r: usize, +) -> (Vec, usize) { + let n = frames.len(); + let (w, h) = (frames[0].width(), frames[0].height()); + let fac = w.max(h).div_ceil(1024).max(1); + let small: Vec<(Rgb32f, Vec)> = (0..n).into_par_iter().map(|i| shrink(&frames[i].image, valid[i].as_deref(), fac)).collect(); + let (sw, sh) = (small[0].0.width, small[0].0.height); + let rpos = order.iter().position(|&i| i == reference).unwrap_or(0); + let good = |i: usize, p: usize| -> bool { + let f = &frames[i]; + let m = maxc(small[i].0.data[p]); + let dark = if f.raw { 0.01 } else { 0.04 }; + small[i].1[p] > 0.99 && m < 0.8 * f.clip && m > dark * f.clip + }; + // predicted reference radiance per pixel + let pred: Vec<[f32; 3]> = (0..sw * sh) + .into_par_iter() + .map(|p| { + let rad = |i: usize| small[i].0.data[p].map(|v| v / k[i]); + if good(reference, p) { + return rad(reference); + } + let bright = maxc(small[reference].0.data[p]) >= 0.8 * frames[reference].clip; + if bright { + for q in (0..rpos).rev() { + if good(order[q], p) { + return rad(order[q]); + } + } + rad(order[0]) + } else { + for &i in &order[rpos + 1..] { + if good(i, p) { + return rad(i); + } + } + rad(reference) + } + }) + .collect(); + let maps: Vec = (0..n) + .into_par_iter() + .map(|i| { + if i == reference { + return Plane::new(sw, sh); + } + let f = &frames[i]; + let e = if f.raw { 0.01 } else { 0.03 } * f.clip; + let mut m = Plane::new(sw, sh); + for p in 0..sw * sh { + if small[i].1[p] < 0.5 { + continue; + } + let obs = small[i].0.data[p]; + let mut d = 0f32; + for c in 0..3 { + let o = obs[c].min(f.clip); + let pr = (pred[p][c] * k[i]).min(f.clip); + d = d.max(((o + e) / (pr + e)).ln().abs()); + } + m.data[p] = smoothstep(thr * 0.7, thr, d); + } + // dilate (max filter, square r) then feather + let dil = dilate(&m, r); + let mut g = gaussian(&dil, r as f32); + for v in g.data.iter_mut() { + *v = v.clamp(0.0, 1.0); + } + g + }) + .collect(); + (maps, fac) +} + +fn dilate(p: &Plane, r: usize) -> Plane { + let (w, h) = (p.width, p.height); + let mut tmp = Plane::new(w, h); + for y in 0..h { + for x in 0..w { + let (a, b) = (x.saturating_sub(r), (x + r).min(w - 1)); + tmp.data[y * w + x] = p.data[y * w + a..=y * w + b].iter().copied().fold(0.0, f32::max); + } + } + let mut out = Plane::new(w, h); + for y in 0..h { + let (a, b) = (y.saturating_sub(r), (y + r).min(h - 1)); + for x in 0..w { + out.data[y * w + x] = (a..=b).map(|yy| tmp.data[yy * w + x]).fold(0.0, f32::max); + } + } + out +} diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs new file mode 100644 index 000000000..65bd86c45 --- /dev/null +++ b/crates/merge/src/lib.rs @@ -0,0 +1,58 @@ +//! LightCraft Photo Merge: HDR merge and panorama stitching (layer L3, no UI dependencies). +//! +//! - [`frame`]: inputs — scene-linear frames from raw files (camera RGB) or standard images. +//! - [`hdr`]: exposure brackets → one scene-linear radiance image (auto-align, exposure +//! normalisation, saturation-aware weighting, deghosting with an overlay). +//! - [`pano`]: overlapping photos → one panorama (features, matching, RANSAC, rotation bundle +//! adjustment, spherical/cylindrical/perspective projection, exposure compensation, seams, +//! multi-band blending, boundary warp, auto crop, fill edges). +//! - [`output`]: results as linear DNGs (16-bit float for HDR) with the source's colour tags. +//! +//! Every algorithm here is our own implementation from published papers (cited in each module); +//! no code from GPL projects was consulted. +#![forbid(unsafe_code)] + +pub mod align; +pub mod features; +pub mod frame; +pub mod hdr; +pub mod linalg; +pub mod output; +pub mod pano; +pub mod ransac; +pub mod synth; + +pub use frame::{Frame, FrameColor, load_frame}; +pub use hdr::{Deghost, HdrOptions, HdrResult, merge_hdr}; +pub use output::{DngSamples, write_linear_dng}; +pub use pano::{PanoOptions, PanoResult, Projection, stitch}; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum MergeError { + #[error("{0} photo(s) selected; at least {1} are needed")] + TooFew(usize, usize), + #[error("{0}")] + Mismatch(String), + #[error("could not read a photo: {0}")] + Decode(String), + #[error("{0}")] + NoOverlap(String), + #[error("cancelled")] + Cancelled, + #[error("could not write the result: {0}")] + Output(String), +} + +pub type Result = std::result::Result; + +/// Progress callback: `(fraction 0..1, stage)`; return `false` to cancel. +pub type Progress<'a> = dyn Fn(f32, &str) -> bool + Sync + 'a; + +/// A progress callback that ignores progress and never cancels. +pub fn no_progress(_: f32, _: &str) -> bool { + true +} + +pub(crate) fn check(p: &Progress, f: f32, stage: &str) -> Result<()> { + if p(f.clamp(0.0, 1.0), stage) { Ok(()) } else { Err(MergeError::Cancelled) } +} diff --git a/crates/merge/src/linalg.rs b/crates/merge/src/linalg.rs new file mode 100644 index 000000000..5c8edf7cf --- /dev/null +++ b/crates/merge/src/linalg.rs @@ -0,0 +1,247 @@ +//! Small dense linear algebra: Gaussian elimination, symmetric eigen-decomposition (cyclic Jacobi), +//! 3×3 rotations (Rodrigues). Textbook methods (Golub & Van Loan, "Matrix Computations", §8.5 for +//! Jacobi); sizes here are tiny (≤ a few hundred unknowns), so clarity beats speed. + +/// 3×3 matrix, row-major. +pub type M3 = [[f64; 3]; 3]; + +pub const I3: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +pub fn mul3(a: &M3, b: &M3) -> M3 { + let mut r = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + r[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + r +} + +pub fn apply3(a: &M3, v: [f64; 3]) -> [f64; 3] { + [0, 1, 2].map(|i| a[i][0] * v[0] + a[i][1] * v[1] + a[i][2] * v[2]) +} + +pub fn transpose3(a: &M3) -> M3 { + [0, 1, 2].map(|i| [a[0][i], a[1][i], a[2][i]]) +} + +pub fn det3(a: &M3) -> f64 { + a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]) +} + +pub fn inv3(a: &M3) -> Option { + let d = det3(a); + if d.abs() < 1e-300 || !d.is_finite() { + return None; + } + let c = |r0: usize, c0: usize, r1: usize, c1: usize| a[r0][c0] * a[r1][c1] - a[r0][c1] * a[r1][c0]; + Some([ + [c(1, 1, 2, 2) / d, -c(0, 1, 2, 2) / d, c(0, 1, 1, 2) / d], + [-c(1, 0, 2, 2) / d, c(0, 0, 2, 2) / d, -c(0, 0, 1, 2) / d], + [c(1, 0, 2, 1) / d, -c(0, 0, 2, 1) / d, c(0, 0, 1, 1) / d], + ]) +} + +pub fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +pub fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +pub fn norm(a: [f64; 3]) -> f64 { + dot(a, a).sqrt() +} + +pub fn normalize(a: [f64; 3]) -> [f64; 3] { + let n = norm(a); + if n > 0.0 { a.map(|v| v / n) } else { a } +} + +/// Rotation matrix from a rotation vector (axis × angle), Rodrigues' formula. +pub fn rodrigues(w: [f64; 3]) -> M3 { + let th = norm(w); + if th < 1e-12 { + // first order: I + [w]x + return [[1.0, -w[2], w[1]], [w[2], 1.0, -w[0]], [-w[1], w[0], 1.0]]; + } + let k = w.map(|v| v / th); + let (s, c) = th.sin_cos(); + let v = 1.0 - c; + [ + [c + k[0] * k[0] * v, k[0] * k[1] * v - k[2] * s, k[0] * k[2] * v + k[1] * s], + [k[1] * k[0] * v + k[2] * s, c + k[1] * k[1] * v, k[1] * k[2] * v - k[0] * s], + [k[2] * k[0] * v - k[1] * s, k[2] * k[1] * v + k[0] * s, c + k[2] * k[2] * v], + ] +} + +/// Rotation vector of a rotation matrix (inverse of [`rodrigues`]). +pub fn rotation_vector(r: &M3) -> [f64; 3] { + let tr = (r[0][0] + r[1][1] + r[2][2] - 1.0) / 2.0; + let th = tr.clamp(-1.0, 1.0).acos(); + let a = [r[2][1] - r[1][2], r[0][2] - r[2][0], r[1][0] - r[0][1]]; + if th < 1e-9 { + return a.map(|v| v / 2.0); + } + if (std::f64::consts::PI - th).abs() < 1e-6 { + // axis from the largest diagonal element of (R + I) / 2 + let b = [0, 1, 2].map(|i| [0, 1, 2].map(|j| (r[i][j] + if i == j { 1.0 } else { 0.0 }) / 2.0)); + let i = (0..3).max_by(|&x, &y| b[x][x].total_cmp(&b[y][y])).unwrap_or(0); + let axis = normalize(b[i]); + return axis.map(|v| v * th); + } + let s = th / (2.0 * th.sin()); + a.map(|v| v * s) +} + +/// Nearest rotation to `m` (polar decomposition via a few Newton iterations X ← (X + X⁻ᵀ)/2). +pub fn orthonormalize(m: &M3) -> M3 { + let mut x = *m; + for _ in 0..20 { + let Some(inv) = inv3(&x) else { return I3 }; + let it = transpose3(&inv); + let mut n = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + n[i][j] = 0.5 * (x[i][j] + it[i][j]); + } + } + x = n; + } + if det3(&x) < 0.0 { + // a reflection: flip the least significant axis + for row in x.iter_mut() { + row[2] = -row[2]; + } + } + x +} + +/// Solve `a · x = b` (`a` is `n × n`, row-major) by Gaussian elimination with partial pivoting. +pub fn solve(mut a: Vec, mut b: Vec, n: usize) -> Option> { + for col in 0..n { + let piv = (col..n).max_by(|&i, &j| a[i * n + col].abs().total_cmp(&a[j * n + col].abs()))?; + let pv = a[piv * n + col]; + if pv.abs() < 1e-300 || !pv.is_finite() { + return None; + } + if piv != col { + for k in 0..n { + a.swap(piv * n + k, col * n + k); + } + b.swap(piv, col); + } + for r in col + 1..n { + let f = a[r * n + col] / a[col * n + col]; + if f != 0.0 { + for k in col..n { + a[r * n + k] -= f * a[col * n + k]; + } + b[r] -= f * b[col]; + } + } + } + let mut x = vec![0.0; n]; + for r in (0..n).rev() { + let s: f64 = (r + 1..n).map(|k| a[r * n + k] * x[k]).sum(); + x[r] = (b[r] - s) / a[r * n + r]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +/// Eigen-decomposition of a symmetric `n × n` matrix (cyclic Jacobi). Returns eigenvalues ascending +/// and the matching unit eigenvectors. +pub fn sym_eigen(a: &[f64], n: usize) -> (Vec, Vec>) { + let mut m = a.to_vec(); + let mut v = vec![0.0; n * n]; + for i in 0..n { + v[i * n + i] = 1.0; + } + for _sweep in 0..100 { + let off: f64 = (0..n).flat_map(|i| (0..n).filter(move |&j| j != i).map(move |j| (i, j))).map(|(i, j)| m[i * n + j] * m[i * n + j]).sum(); + let scale: f64 = (0..n).map(|i| m[i * n + i] * m[i * n + i]).sum::().max(1e-300); + if off <= 1e-24 * scale { + break; + } + for p in 0..n { + for q in p + 1..n { + let apq = m[p * n + q]; + if apq.abs() < 1e-300 { + continue; + } + let theta = (m[q * n + q] - m[p * n + p]) / (2.0 * apq); + let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt()); + let t = if theta == 0.0 { 1.0 } else { t }; + let c = 1.0 / (t * t + 1.0).sqrt(); + let s = t * c; + for k in 0..n { + let (mkp, mkq) = (m[k * n + p], m[k * n + q]); + m[k * n + p] = c * mkp - s * mkq; + m[k * n + q] = s * mkp + c * mkq; + } + for k in 0..n { + let (mpk, mqk) = (m[p * n + k], m[q * n + k]); + m[p * n + k] = c * mpk - s * mqk; + m[q * n + k] = s * mpk + c * mqk; + } + for k in 0..n { + let (vkp, vkq) = (v[k * n + p], v[k * n + q]); + v[k * n + p] = c * vkp - s * vkq; + v[k * n + q] = s * vkp + c * vkq; + } + } + } + } + let mut idx: Vec = (0..n).collect(); + idx.sort_by(|&a, &b| m[a * n + a].total_cmp(&m[b * n + b])); + let vals = idx.iter().map(|&i| m[i * n + i]).collect(); + let vecs = idx.iter().map(|&i| (0..n).map(|k| v[k * n + i]).collect()).collect(); + (vals, vecs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rodrigues_round_trip() { + for w in [[0.1, -0.2, 0.3], [0.0, 0.0, 0.0], [1.5, 0.2, -0.7], [0.0, 3.0, 0.0]] { + let r = rodrigues(w); + assert!((det3(&r) - 1.0).abs() < 1e-9); + let back = rotation_vector(&r); + let r2 = rodrigues(back); + for i in 0..3 { + for j in 0..3 { + assert!((r[i][j] - r2[i][j]).abs() < 1e-9, "{w:?}"); + } + } + } + } + + #[test] + fn solve_and_eigen() { + let a = vec![4.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0]; + let x = solve(a.clone(), vec![1.0, 2.0, 3.0], 3).unwrap(); + for r in 0..3 { + let s: f64 = (0..3).map(|k| a[r * 3 + k] * x[k]).sum(); + assert!((s - [1.0, 2.0, 3.0][r]).abs() < 1e-12); + } + let (vals, vecs) = sym_eigen(&a, 3); + assert!(vals[0] <= vals[1] && vals[1] <= vals[2]); + for (l, v) in vals.iter().zip(&vecs) { + for r in 0..3 { + let s: f64 = (0..3).map(|k| a[r * 3 + k] * v[k]).sum(); + assert!((s - l * v[r]).abs() < 1e-9); + } + } + let r = orthonormalize(&[[1.02, 0.01, 0.0], [-0.01, 0.98, 0.02], [0.0, -0.02, 1.01]]); + let rt = mul3(&r, &transpose3(&r)); + for i in 0..3 { + for j in 0..3 { + assert!((rt[i][j] - I3[i][j]).abs() < 1e-9); + } + } + } +} diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs new file mode 100644 index 000000000..3d0bbfe52 --- /dev/null +++ b/crates/merge/src/output.rs @@ -0,0 +1,142 @@ +//! Merge results as linear DNGs (`PhotometricInterpretation = LinearRaw`, three samples per +//! pixel), written with LightCraft's own TIFF/DNG writer. +//! +//! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the +//! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame. +//! - Raw sources: the reference frame's DNG colour tags (`ColorMatrix*`, `ForwardMatrix*`, +//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over unchanged — the data is +//! still camera RGB. +//! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB, +//! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1). + +use lightcraft_color::{D50, D65, Mat3, bradford}; +use lightcraft_geom::Orientation; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; +use lightcraft_raw::{BlackLevel, ColorData, DngCompression, DngWriteOptions, OpcodeLists, RawData, RawFormat, RawImage, Rect}; + +use crate::frame::FrameColor; +use crate::{MergeError, Result}; + +/// Sample encoding of the output DNG. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DngSamples { + /// 16-bit floating point, Deflate + floating-point predictor (HDR). + Half, + /// 32-bit floating point, Deflate + floating-point predictor. + Float, + /// 16-bit integer, Deflate + horizontal predictor (panoramas of normal-range photos). + U16, +} + +/// The DNG colour tags for frames of `color`. +pub fn color_data(color: &FrameColor) -> ColorData { + match color { + FrameColor::Camera(c) => (**c).clone(), + FrameColor::Linear { to_xyz_d50 } => { + let to_xyz_d65 = bradford(D50, D65).mul(to_xyz_d50); + let cm = to_xyz_d65.inverse().unwrap_or(Mat3::IDENTITY); + ColorData { + illuminant: [21, 0], + color_matrix: [Some(cm), None], + forward_matrix: [Some(*to_xyz_d50), None], + camera_calibration: [None, None], + analog_balance: None, + as_shot_neutral: Some([1.0, 1.0, 1.0]), + as_shot_white_xy: None, + baseline_exposure: 0.0, + } + } + } +} + +/// Encode `img` (scene-linear, 1.0 = source white) as a LinearRaw DNG. +pub fn write_linear_dng( + img: &Rgb32f, + color: &FrameColor, + orientation: Orientation, + metadata: &Metadata, + baseline_exposure: f64, + samples: DngSamples, +) -> Result> { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 { + return Err(MergeError::Output("empty image".into())); + } + let peak = img.data.iter().flat_map(|p| p.iter()).copied().filter(|v| v.is_finite()).fold(0.0f32, f32::max); + let target = 0.95f32; + // never brighten: values ≤ 1 keep their scale (so a plain panorama keeps its white level) + let scale = if peak > target { target / peak } else { 1.0 }; + let mut cd = color_data(color); + cd.baseline_exposure = baseline_exposure + (1.0 / scale as f64).log2(); + let clean = |v: f32| if v.is_finite() { (v * scale).max(0.0) } else { 0.0 }; + let (data, bits, white) = match samples { + DngSamples::Half | DngSamples::Float => { + (RawData::F32(img.data.iter().flat_map(|p| p.map(clean)).collect()), if samples == DngSamples::Half { 16 } else { 32 }, 1.0) + } + DngSamples::U16 => { + (RawData::U16(img.data.iter().flat_map(|p| p.map(|v| (clean(v) * 65535.0).round().min(65535.0) as u16)).collect()), 16, 65535.0) + } + }; + let mut meta = metadata.clone(); + meta.width = Some(w as u32); + meta.height = Some(h as u32); + meta.orientation = Some(orientation); + let raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 3, + data, + cfa: None, + bits, + black: BlackLevel::uniform(0.0), + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation, + color: cd, + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: meta, + }; + let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half }; + lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).map_err(|e| MergeError::Output(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linear_dng_round_trips_through_the_raw_decoder() { + let img = Rgb32f::from_fn(80, 60, |x, y| [x as f32 / 20.0, y as f32 / 30.0, 0.3]); + let color = FrameColor::Linear { to_xyz_d50: bradford(D65, D50).mul(&lightcraft_color::SRGB.to_xyz()) }; + let bytes = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::Half).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (80, 60, 3)); + let gain = 2f32.powf(raw.color.baseline_exposure as f32); + let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + for (a, b) in img.data.iter().zip(&back.data) { + for c in 0..3 { + assert!((a[c] - b[c] * gain).abs() <= a[c] * 2e-3 + 1e-5, "{a:?} {b:?}"); + } + } + // the colour model maps the source white to neutral (no white-balance shift) + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + assert!((xy.x - D65.x).abs() < 2e-3 && (xy.y - D65.y).abs() < 2e-3, "{xy:?}"); + let t = lightcraft_raw::color::camera_transform(&raw, xy); + let white = t.matrix.apply([t.wb[0] as f64, t.wb[1] as f64, t.wb[2] as f64]); + assert!(white.iter().all(|v| (v - white[0]).abs() < 1e-3), "{white:?}"); + // sRGB red maps to the Rec.2020 coordinates of sRGB red + let red = t.matrix.apply([t.wb[0] as f64, 0.0, 0.0]); + let want = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020).apply([1.0, 0.0, 0.0]); + let k = white[0]; + for c in 0..3 { + assert!((red[c] / k - want[c]).abs() < 5e-3, "{red:?} vs {want:?}"); + } + let u16 = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::U16).unwrap(); + assert!(lightcraft_raw::decode(&u16).is_ok()); + } +} diff --git a/crates/merge/src/pano/blend.rs b/crates/merge/src/pano/blend.rs new file mode 100644 index 000000000..e3147da17 --- /dev/null +++ b/crates/merge/src/pano/blend.rs @@ -0,0 +1,300 @@ +//! Multi-band blending (P. J. Burt & E. H. Adelson, "A Multiresolution Spline With Application to +//! Image Mosaics", ACM ToG 1983), as used by Brown & Lowe: each image's Laplacian pyramid is +//! blended with the Gaussian pyramid of its (binary) seam mask, so low frequencies blend over wide +//! regions and high frequencies over narrow ones. Blending happens in log space, which turns +//! residual exposure differences into additive offsets the coarse bands smooth away. +//! +//! Images arrive as tiles placed on a canvas padded to a multiple of 2^levels; tile origins are +//! multiples of 2^levels so every pyramid level of a tile lines up with the canvas pyramid. + +use rayon::prelude::*; + +/// A planar-interleaved float buffer with `c` channels. +#[derive(Clone, Debug, PartialEq)] +pub struct Buf { + pub w: usize, + pub h: usize, + pub c: usize, + pub data: Vec, +} + +impl Buf { + pub fn new(w: usize, h: usize, c: usize) -> Buf { + Buf { w, h, c, data: vec![0.0; w * h * c] } + } + #[inline] + fn at(&self, x: isize, y: isize, ch: usize) -> f32 { + let x = x.clamp(0, self.w as isize - 1) as usize; + let y = y.clamp(0, self.h as isize - 1) as usize; + self.data[(y * self.w + x) * self.c + ch] + } +} + +const K: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0]; + +/// Binomial 5-tap blur then decimate by 2 (output `⌈w/2⌉ × ⌈h/2⌉`). +pub fn down(b: &Buf) -> Buf { + let (w2, h2) = (b.w.div_ceil(2), b.h.div_ceil(2)); + // horizontal pass at decimated x + let mut tmp = Buf::new(w2, b.h, b.c); + tmp.data.par_chunks_mut(w2 * b.c).enumerate().for_each(|(y, row)| { + for x in 0..w2 { + for ch in 0..b.c { + let mut s = 0.0; + for (k, wk) in K.iter().enumerate() { + s += wk * b.at(2 * x as isize + k as isize - 2, y as isize, ch); + } + row[x * b.c + ch] = s; + } + } + }); + let mut out = Buf::new(w2, h2, b.c); + out.data.par_chunks_mut(w2 * b.c).enumerate().for_each(|(y, row)| { + for x in 0..w2 { + for ch in 0..b.c { + let mut s = 0.0; + for (k, wk) in K.iter().enumerate() { + s += wk * tmp.at(x as isize, 2 * y as isize + k as isize - 2, ch); + } + row[x * b.c + ch] = s; + } + } + }); + out +} + +/// Expand to `w × h` (bilinear, pixel-centre aligned with [`down`]'s grid). +pub fn up(b: &Buf, w: usize, h: usize) -> Buf { + let mut out = Buf::new(w, h, b.c); + out.data.par_chunks_mut(w * b.c).enumerate().for_each(|(y, row)| { + let fy = (y as f32 + 0.5) / 2.0 - 0.5; + let y0 = fy.floor(); + let ty = fy - y0; + let y0 = y0 as isize; + for x in 0..w { + let fx = (x as f32 + 0.5) / 2.0 - 0.5; + let x0 = fx.floor(); + let tx = fx - x0; + let x0 = x0 as isize; + for ch in 0..b.c { + let a = b.at(x0, y0, ch); + let bb = b.at(x0 + 1, y0, ch); + let c = b.at(x0, y0 + 1, ch); + let d = b.at(x0 + 1, y0 + 1, ch); + let top = a + (bb - a) * tx; + let bot = c + (d - c) * tx; + row[x * b.c + ch] = top + (bot - top) * ty; + } + } + }); + out +} + +/// Fill pixels with weight 0 from their neighbourhood (push–pull: weighted pyramid down, fill +/// from coarser levels up). `vals` has `c` channels; `wgt` one. +pub fn push_pull(vals: &mut Buf, wgt: &[f32]) { + if wgt.iter().all(|&w| w > 0.0) || !wgt.iter().any(|&w| w > 0.0) { + return; + } + let (w, h, c) = (vals.w, vals.h, vals.c); + // premultiplied values + weight as the last channel + let mut pm = Buf::new(w, h, c + 1); + for i in 0..w * h { + let a = wgt[i].clamp(0.0, 1.0); + for ch in 0..c { + pm.data[i * (c + 1) + ch] = vals.data[i * c + ch] * a; + } + pm.data[i * (c + 1) + c] = a; + } + let mut levels = vec![pm]; + while levels.last().is_some_and(|l| l.w > 1 || l.h > 1) { + let d = down(levels.last().expect("level")); + levels.push(d); + } + // normalise the coarsest, then fill each finer level's holes from the coarser one + let mut filled = { + let l = levels.last().expect("level"); + let mut f = Buf::new(l.w, l.h, c); + for i in 0..l.w * l.h { + let a = l.data[i * (c + 1) + c]; + for ch in 0..c { + f.data[i * c + ch] = if a > 1e-12 { l.data[i * (c + 1) + ch] / a } else { 0.0 }; + } + } + f + }; + for li in (0..levels.len() - 1).rev() { + let l = &levels[li]; + let coarse = up(&filled, l.w, l.h); + let mut f = Buf::new(l.w, l.h, c); + for i in 0..l.w * l.h { + let a = l.data[i * (c + 1) + c].min(1.0); + for ch in 0..c { + let v = if a > 1e-12 { l.data[i * (c + 1) + ch] / l.data[i * (c + 1) + c] } else { 0.0 }; + f.data[i * c + ch] = v * a + coarse.data[i * c + ch] * (1.0 - a); + } + } + filled = f; + } + for i in 0..w * h { + if wgt[i] <= 0.0 { + for ch in 0..c { + vals.data[i * c + ch] = filled.data[i * c + ch]; + } + } + } +} + +/// Accumulates tiles into the canvas Laplacian pyramid. +pub struct Blender { + pub levels: usize, + pub w: usize, + pub h: usize, + acc: Vec, + wsum: Vec, +} + +impl Blender { + /// A blender for a `w × h` canvas (both multiples of 2^levels). + pub fn new(w: usize, h: usize, levels: usize) -> Blender { + let mut acc = Vec::new(); + let mut wsum = Vec::new(); + for l in 0..=levels { + acc.push(Buf::new(w >> l, h >> l, 3)); + wsum.push(Buf::new(w >> l, h >> l, 1)); + } + Blender { levels, w, h, acc, wsum } + } + + /// Add a tile at canvas offset (`x0`, `y0`) (multiples of 2^levels; tile size too): `vals` + /// (3 channels, holes already filled) and its seam mask (0..1). + pub fn add(&mut self, x0: usize, y0: usize, vals: Buf, mask: Buf) { + let mut g = vals; + let mut m = mask; + for l in 0..=self.levels { + let (ox, oy) = (x0 >> l, y0 >> l); + let (gn, mn) = if l < self.levels { (Some(down(&g)), Some(down(&m))) } else { (None, None) }; + let lap = match &gn { + Some(gn) => { + let u = up(gn, g.w, g.h); + let mut lap = g.clone(); + for (a, b) in lap.data.iter_mut().zip(&u.data) { + *a -= b; + } + lap + } + None => g.clone(), + }; + let (acc, ws) = (&mut self.acc[l], &mut self.wsum[l]); + let aw = acc.w; + let tw = lap.w; + acc.data.par_chunks_mut(aw * 3).zip(ws.data.par_chunks_mut(aw)).enumerate().for_each(|(y, (arow, wrow))| { + if y < oy || y >= oy + lap.h { + return; + } + let ty = y - oy; + for tx in 0..tw { + let x = ox + tx; + if x >= aw { + break; + } + let mk = m.data[ty * tw + tx]; + if mk <= 0.0 { + continue; + } + for ch in 0..3 { + arow[x * 3 + ch] += lap.data[(ty * tw + tx) * 3 + ch] * mk; + } + wrow[x] += mk; + } + }); + if let (Some(gn), Some(mn)) = (gn, mn) { + g = gn; + m = mn; + } + } + } + + /// Normalise and collapse the pyramid into the canvas. + pub fn finish(self) -> Buf { + let mut res: Option = None; + for l in (0..=self.levels).rev() { + let mut b = self.acc[l].clone(); + let ws = &self.wsum[l]; + for i in 0..b.w * b.h { + let s = ws.data[i]; + for ch in 0..3 { + b.data[i * 3 + ch] = if s > 1e-8 { b.data[i * 3 + ch] / s } else { 0.0 }; + } + } + if let Some(r) = res { + let u = up(&r, b.w, b.h); + for (a, v) in b.data.iter_mut().zip(&u.data) { + *a += v; + } + } + res = Some(b); + } + res.expect("level 0") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_tile_reconstructs_exactly() { + let (w, h) = (64, 48); + let mut v = Buf::new(w, h, 3); + for (i, x) in v.data.iter_mut().enumerate() { + *x = ((i * 37) % 101) as f32 / 101.0; + } + let mut m = Buf::new(w, h, 1); + m.data.fill(1.0); + let mut b = Blender::new(w, h, 3); + b.add(0, 0, v.clone(), m); + let out = b.finish(); + for (a, b) in v.data.iter().zip(&out.data) { + assert!((a - b).abs() < 1e-4); + } + } + + #[test] + fn two_tiles_blend_without_a_step() { + // left tile value 0, right tile value 1, seam in the middle: the result is a smooth ramp + let (w, h) = (128, 32); + let mut b = Blender::new(w, h, 4); + for (val, side) in [(0.0f32, 0), (1.0, 1)] { + let mut v = Buf::new(w, h, 3); + v.data.fill(val); + let mut m = Buf::new(w, h, 1); + for y in 0..h { + for x in 0..w { + m.data[y * w + x] = if (x >= w / 2) == (side == 1) { 1.0 } else { 0.0 }; + } + } + b.add(0, 0, v, m); + } + let out = b.finish(); + let row: Vec = (0..w).map(|x| out.data[(16 * w + x) * 3]).collect(); + let max_step = row.windows(2).map(|p| (p[1] - p[0]).abs()).fold(0.0, f32::max); + assert!(max_step < 0.15, "max step {max_step}"); + assert!(row[2].abs() < 0.02 && (row[w - 3] - 1.0).abs() < 0.02); + } + + #[test] + fn push_pull_fills_holes() { + let (w, h) = (20, 10); + let mut v = Buf::new(w, h, 1); + let mut wg = vec![0.0; w * h]; + for y in 0..h { + for x in 0..w / 2 { + v.data[y * w + x] = 0.7; + wg[y * w + x] = 1.0; + } + } + push_pull(&mut v, &wg); + assert!(v.data.iter().all(|x| (x - 0.7).abs() < 1e-4)); + } +} diff --git a/crates/merge/src/pano/camera.rs b/crates/merge/src/pano/camera.rs new file mode 100644 index 000000000..1257b9529 --- /dev/null +++ b/crates/merge/src/pano/camera.rs @@ -0,0 +1,446 @@ +//! Panorama registration: pairwise matching → camera rotations and focal lengths. +//! +//! After M. Brown & D. G. Lowe, "Automatic Panoramic Image Stitching using Invariant Features" +//! (IJCV 2007): +//! - pairwise feature matches verified with a RANSAC homography; a pair is accepted when the +//! inliers explain enough of the matches (`nᵢ > 8 + 0.3·n_f`); +//! - the largest connected set of images is kept; +//! - focal length initialised from the homographies (Szeliski & Shum 1997 relations, derived here +//! from the orthonormality of `K₁⁻¹ H K₀`), rotations by chaining the pairwise rotations along a +//! maximum spanning tree (most inliers first); +//! - bundle adjustment of every camera's rotation and focal length (Levenberg–Marquardt on the +//! symmetric reprojection error, Huber-weighted, with a numeric Jacobian per match); +//! - automatic straightening: the world "up" is the direction most perpendicular to every +//! camera's x axis. + +use lightcraft_geom::{Homography, Point}; +use rayon::prelude::*; + +use crate::features::{self, Features}; +use crate::linalg::{self, M3}; +use crate::ransac::{self, Model}; + +/// A pinhole camera: `u = f · (R d)ₓᵧ / (R d)_z + c` for a world direction `d`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Camera { + /// Focal length (pixels of the image it belongs to). + pub f: f64, + /// World → camera rotation. + pub r: M3, + pub cx: f64, + pub cy: f64, +} + +impl Camera { + /// World direction of image pixel `(x, y)`. + #[inline] + pub fn ray(&self, x: f64, y: f64) -> [f64; 3] { + let d = [(x - self.cx) / self.f, (y - self.cy) / self.f, 1.0]; + linalg::apply3(&linalg::transpose3(&self.r), d) + } + /// Image pixel of world direction `d` (`None` behind the camera). + #[inline] + pub fn project(&self, d: [f64; 3]) -> Option<(f64, f64)> { + let p = linalg::apply3(&self.r, d); + (p[2] > 1e-9).then(|| (self.f * p[0] / p[2] + self.cx, self.f * p[1] / p[2] + self.cy)) + } +} + +/// Verified matches between two images (full-resolution pixel coordinates). +#[derive(Clone, Debug)] +pub struct PairMatch { + pub i: usize, + pub j: usize, + /// Maps image `i` pixels to image `j` pixels. + pub h: Homography, + pub pts: Vec<(Point, Point)>, + /// Coarsest pyramid level of the two features of each match (finer = more precise). + pub levels: Vec, +} + +/// Match every pair of images. `feats[k]` are in work coordinates scaled by `scales[k]`. +pub fn match_pairs(feats: &[Features], scales: &[f64]) -> Vec { + let n = feats.len(); + let pairs: Vec<(usize, usize)> = (0..n).flat_map(|i| (i + 1..n).map(move |j| (i, j))).collect(); + let mut out: Vec = pairs + .par_iter() + .filter_map(|&(i, j)| { + let m = features::match_features(&feats[i], &feats[j], 0.8); + if m.len() < 12 { + return None; + } + let a: Vec = m.iter().map(|(p, _)| Point::new(feats[i].points[*p].x as f64, feats[i].points[*p].y as f64)).collect(); + let b: Vec = m.iter().map(|(_, q)| Point::new(feats[j].points[*q].x as f64, feats[j].points[*q].y as f64)).collect(); + let fit = ransac::ransac(Model::Homography, &a, &b, 3.0, 12, (i * 1000 + j) as u64)?; + // Brown & Lowe's probabilistic verification + if (fit.inliers.len() as f64) <= 8.0 + 0.3 * m.len() as f64 { + return None; + } + let (si, sj) = (scales[i], scales[j]); + let pts = fit.inliers.iter().map(|&k| (Point::new(a[k].x / si, a[k].y / si), Point::new(b[k].x / sj, b[k].y / sj))).collect(); + let levels = fit.inliers.iter().map(|&k| feats[i].points[m[k].0].level.max(feats[j].points[m[k].1].level)).collect(); + // conjugate to full resolution: H_full = S_j⁻¹ · H · S_i + let s_i = Homography([si, 0.0, 0.0, 0.0, si, 0.0, 0.0, 0.0, 1.0]); + let s_j_inv = Homography([1.0 / sj, 0.0, 0.0, 0.0, 1.0 / sj, 0.0, 0.0, 0.0, 1.0]); + Some(PairMatch { i, j, h: s_j_inv.mul(&fit.h).mul(&s_i), pts, levels }) + }) + .collect(); + out.sort_by_key(|p| (p.i, p.j)); + out +} + +/// The largest connected component of the match graph (sorted image indices). +pub fn largest_component(n: usize, pairs: &[PairMatch]) -> Vec { + let mut parent: Vec = (0..n).collect(); + fn find(p: &mut [usize], x: usize) -> usize { + let mut r = x; + while p[r] != r { + r = p[r]; + } + let mut c = x; + while p[c] != r { + let nx = p[c]; + p[c] = r; + c = nx; + } + r + } + for m in pairs { + let (a, b) = (find(&mut parent, m.i), find(&mut parent, m.j)); + if a != b { + parent[a] = b; + } + } + let mut groups: std::collections::BTreeMap> = Default::default(); + for i in 0..n { + let r = find(&mut parent, i); + groups.entry(r).or_default().push(i); + } + groups.into_values().max_by_key(|g| g.len()).unwrap_or_default() +} + +/// Focal length candidates (f₀, f₁) from a homography between centred image coordinates. +pub fn focals_from_homography(h: &Homography) -> (Option, Option) { + let m = h.0; + let (h00, h01, h02, h10, h11, h12, h20, h21) = (m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7]); + let pick = |n1: f64, d1: f64, n2: f64, d2: f64| -> Option { + let a = (d1.abs() > 1e-12).then(|| n1 / d1).filter(|v| *v > 0.0); + let b = (d2.abs() > 1e-12).then(|| n2 / d2).filter(|v| *v > 0.0); + let v = match (a, b) { + (Some(a), Some(b)) => { + if d1.abs() > d2.abs() { + a + } else { + b + } + } + (Some(a), None) => a, + (None, Some(b)) => b, + _ => return None, + }; + Some(v.sqrt()) + }; + // rows of K₁⁻¹HK₀ orthogonal / equal length → f₀; columns → f₁ + let f0 = pick(-h02 * h12, h00 * h10 + h01 * h11, h12 * h12 - h02 * h02, h00 * h00 + h01 * h01 - h10 * h10 - h11 * h11); + let f1 = pick(-(h00 * h01 + h10 * h11), h20 * h21, h01 * h01 + h11 * h11 - h00 * h00 - h10 * h10, h20 * h20 - h21 * h21); + (f0, f1) +} + +fn centred(h: &Homography, ci: (f64, f64), cj: (f64, f64)) -> Homography { + let ti = Homography([1.0, 0.0, ci.0, 0.0, 1.0, ci.1, 0.0, 0.0, 1.0]); + let tj_inv = Homography([1.0, 0.0, -cj.0, 0.0, 1.0, -cj.1, 0.0, 0.0, 1.0]); + tj_inv.mul(h).mul(&ti) +} + +/// Initial cameras for `ids` (a connected set): shared focal `f0`, rotations chained along a +/// maximum spanning tree rooted at the best-connected image. +pub fn initial_cameras(ids: &[usize], sizes: &[(usize, usize)], pairs: &[PairMatch], f0: f64) -> Vec> { + let n = sizes.len(); + let mut cams: Vec> = vec![None; n]; + let deg = |k: usize| pairs.iter().filter(|p| p.i == k || p.j == k).map(|p| p.pts.len()).sum::(); + let Some(&root) = ids.iter().max_by_key(|&&k| (deg(k), usize::MAX - k)) else { return cams }; + let c = |k: usize| (sizes[k].0 as f64 / 2.0, sizes[k].1 as f64 / 2.0); + cams[root] = Some(Camera { f: f0, r: linalg::I3, cx: c(root).0, cy: c(root).1 }); + let mut edges: Vec<&PairMatch> = pairs.iter().filter(|p| ids.contains(&p.i) && ids.contains(&p.j)).collect(); + edges.sort_by_key(|p| std::cmp::Reverse(p.pts.len())); + loop { + let mut grew = false; + for e in &edges { + let (known, new, h) = match (cams[e.i].is_some(), cams[e.j].is_some()) { + (true, false) => (e.i, e.j, e.h), + (false, true) => ( + e.j, + e.i, + match e.h.inverse() { + Some(h) => h, + None => continue, + }, + ), + _ => continue, + }; + // h maps `known` → `new`; R_new R_knownᵀ ≈ K_new⁻¹ H K_known + let hc = centred(&h, c(known), c(new)); + let k = [[f0, 0.0, 0.0], [0.0, f0, 0.0], [0.0, 0.0, 1.0]]; + let kinv = [[1.0 / f0, 0.0, 0.0], [0.0, 1.0 / f0, 0.0], [0.0, 0.0, 1.0]]; + let hm = [[hc.0[0], hc.0[1], hc.0[2]], [hc.0[3], hc.0[4], hc.0[5]], [hc.0[6], hc.0[7], hc.0[8]]]; + let rel = linalg::mul3(&linalg::mul3(&kinv, &hm), &k); + let d = linalg::det3(&rel); + if !d.is_finite() || d.abs() < 1e-12 { + continue; + } + let s = d.signum() * d.abs().cbrt(); + let rel = rel.map(|r| r.map(|v| v / s)); + let rel = linalg::orthonormalize(&rel); + let rk = cams[known].expect("known").r; + cams[new] = Some(Camera { f: f0, r: linalg::mul3(&rel, &rk), cx: c(new).0, cy: c(new).1 }); + grew = true; + } + if !grew { + break; + } + } + cams +} + +/// Bundle adjustment result. +#[derive(Clone, Debug)] +pub struct Adjusted { + pub cameras: Vec>, + /// RMS reprojection error (pixels) before and after. + pub rms_before: f64, + pub rms: f64, + pub iterations: usize, +} + +struct Obs { + i: usize, + j: usize, + a: Point, + b: Point, +} + +/// Residuals of one match: image-j point projected into i, and image-i point into j. +fn residual(ci: &Camera, cj: &Camera, a: Point, b: Point) -> [f64; 4] { + let big = 1e3; + let p = ci.project(cj.ray(b.x, b.y)); + let q = cj.project(ci.ray(a.x, a.y)); + let (r0, r1) = p.map_or((big, big), |(x, y)| (x - a.x, y - a.y)); + let (r2, r3) = q.map_or((big, big), |(x, y)| (x - b.x, y - b.y)); + [r0, r1, r2, r3] +} + +fn perturb(c: &Camera, k: usize, eps: f64) -> Camera { + let mut c = *c; + if k < 3 { + let mut w = [0.0; 3]; + w[k] = eps; + c.r = linalg::mul3(&linalg::rodrigues(w), &c.r); + } else { + c.f += eps; + } + c +} + +/// Levenberg–Marquardt over (rotation increment, focal) of every camera except the anchor's +/// rotation (gauge freedom). At most `max_per_pair` matches per pair are used. +pub fn bundle_adjust(cams: &[Option], pairs: &[PairMatch], anchor: usize, max_per_pair: usize) -> Adjusted { + let n = cams.len(); + let idx: Vec> = { + let mut k = 0; + cams.iter() + .map(|c| { + c.map(|_| { + k += 1; + k - 1 + }) + }) + .collect() + }; + let m = idx.iter().flatten().count(); + let np = 4 * m; + let mut obs = Vec::new(); + for p in pairs { + if cams[p.i].is_none() || cams[p.j].is_none() { + continue; + } + // prefer the precisely located (fine-level) matches when there are enough of them + let fine: Vec = (0..p.pts.len()).filter(|&k| p.levels.get(k).is_none_or(|l| *l <= 1)).collect(); + let use_idx: Vec = if fine.len() >= 16 { fine } else { (0..p.pts.len()).collect() }; + let step = (use_idx.len() / max_per_pair.max(1)).max(1); + for &k in use_idx.iter().step_by(step) { + let (a, b) = p.pts[k]; + obs.push(Obs { i: p.i, j: p.j, a, b }); + } + } + let mut cur: Vec> = cams.to_vec(); + let sigma = 2.0f64; + let cost = |cs: &[Option]| -> (f64, f64) { + let (mut c, mut sq) = (0.0, 0.0); + for o in &obs { + let r = residual(cs[o.i].as_ref().expect("cam"), cs[o.j].as_ref().expect("cam"), o.a, o.b); + for v in r { + let a = v.abs(); + sq += v * v; + c += if a <= sigma { 0.5 * v * v } else { sigma * (a - 0.5 * sigma) }; + } + } + (c, (sq / (obs.len().max(1) * 4) as f64).sqrt()) + }; + let (mut c0, rms_before) = cost(&cur); + let mut lambda = 1e-3; + let mut iterations = 0; + let _ = n; + for _ in 0..100 { + iterations += 1; + // normal equations (Huber IRLS weights) + let (jtj, jtr) = obs + .par_iter() + .fold( + || (vec![0.0f64; np * np], vec![0.0f64; np]), + |(mut jtj, mut jtr), o| { + let (ci, cj) = (cur[o.i].as_ref().expect("cam"), cur[o.j].as_ref().expect("cam")); + let r = residual(ci, cj, o.a, o.b); + let mut cols: Vec<(usize, [f64; 4])> = Vec::with_capacity(8); + for (cam, which) in [(o.i, 0), (o.j, 1)] { + let base = 4 * idx[cam].expect("idx"); + for k in 0..4 { + if k < 3 && cam == anchor { + continue; + } + let c = if which == 0 { ci } else { cj }; + let eps = if k < 3 { 1e-6 } else { 1e-4 * c.f }; + let pc = perturb(c, k, eps); + let rr = if which == 0 { residual(&pc, cj, o.a, o.b) } else { residual(ci, &pc, o.a, o.b) }; + let mut d = [0.0; 4]; + for t in 0..4 { + d[t] = (rr[t] - r[t]) / eps; + } + cols.push((base + k, d)); + } + } + for t in 0..4 { + let a = r[t].abs(); + let w = if a <= sigma { 1.0 } else { sigma / a }; + for &(p, dp) in &cols { + jtr[p] += w * dp[t] * r[t]; + for &(q, dq) in &cols { + jtj[p * np + q] += w * dp[t] * dq[t]; + } + } + } + (jtj, jtr) + }, + ) + .reduce( + || (vec![0.0f64; np * np], vec![0.0f64; np]), + |(mut a, mut b), (c, d)| { + for (x, y) in a.iter_mut().zip(&c) { + *x += y; + } + for (x, y) in b.iter_mut().zip(&d) { + *x += y; + } + (a, b) + }, + ); + let mut improved = false; + for _ in 0..10 { + let mut a = jtj.clone(); + for p in 0..np { + let d = a[p * np + p]; + a[p * np + p] = d + lambda * d.max(1e-9) + 1e-12; + } + let Some(delta) = linalg::solve(a, jtr.iter().map(|v| -v).collect(), np) else { + lambda *= 10.0; + continue; + }; + let mut trial = cur.clone(); + for (cam, c) in trial.iter_mut().enumerate() { + let (Some(c), Some(b)) = (c.as_mut(), idx[cam]) else { continue }; + let b = 4 * b; + if cam != anchor { + c.r = linalg::orthonormalize(&linalg::mul3(&linalg::rodrigues([delta[b], delta[b + 1], delta[b + 2]]), &c.r)); + } + c.f = (c.f + delta[b + 3]).max(1.0); + } + let (c1, _) = cost(&trial); + if c1 < c0 { + let rel = (c0 - c1) / c0.max(1e-300); + cur = trial; + c0 = c1; + lambda = (lambda * 0.3).max(1e-9); + improved = rel > 1e-9; + break; + } + lambda *= 10.0; + } + if !improved { + break; + } + } + let (_, rms) = cost(&cur); + Adjusted { cameras: cur, rms_before, rms, iterations } +} + +/// Rotate the world so that it is level: "down" (+y) is the direction most perpendicular to every +/// camera's x axis, and "forward" (+z) the mean viewing direction. +pub fn straighten(cams: &mut [Option]) { + let rs: Vec = cams.iter().flatten().map(|c| c.r).collect(); + if rs.is_empty() { + return; + } + let mut cov = [0.0f64; 9]; + let (mut ysum, mut zsum, mut xsum) = ([0.0; 3], [0.0; 3], [0.0; 3]); + for r in &rs { + let (x, y, z) = (r[0], r[1], r[2]); + for a in 0..3 { + for b in 0..3 { + cov[a * 3 + b] += x[a] * x[b]; + } + ysum[a] += y[a]; + zsum[a] += z[a]; + xsum[a] += x[a]; + } + } + let (vals, vecs) = linalg::sym_eigen(&cov, 3); + let total = vals.iter().sum::().max(1e-12); + let mut down = [vecs[0][0], vecs[0][1], vecs[0][2]]; + if vals[1] < 1e-3 * total { + // all x axes (nearly) parallel (a vertical sweep): use the mean camera "down" made + // perpendicular to the common x axis + let xa = linalg::normalize(xsum); + let y = linalg::normalize(ysum); + let k = linalg::dot(y, xa); + down = linalg::normalize([y[0] - k * xa[0], y[1] - k * xa[1], y[2] - k * xa[2]]); + } + if linalg::dot(down, ysum) < 0.0 { + down = down.map(|v| -v); + } + let z = linalg::normalize(zsum); + let k = linalg::dot(z, down); + let fwd = linalg::normalize([z[0] - k * down[0], z[1] - k * down[1], z[2] - k * down[2]]); + let right = linalg::cross(down, fwd); + let w = [right, down, fwd]; + let wt = linalg::transpose3(&w); + for c in cams.iter_mut().flatten() { + c.r = linalg::mul3(&c.r, &wt); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focal_from_a_rotation_homography() { + let f = 900.0; + let r = linalg::rodrigues([0.05, 0.3, 0.02]); + let k = [[f, 0.0, 0.0], [0.0, f, 0.0], [0.0, 0.0, 1.0]]; + let kinv = [[1.0 / f, 0.0, 0.0], [0.0, 1.0 / f, 0.0], [0.0, 0.0, 1.0]]; + let h = linalg::mul3(&linalg::mul3(&k, &r), &kinv); + let hh = Homography([h[0][0], h[0][1], h[0][2], h[1][0], h[1][1], h[1][2], h[2][0], h[2][1], h[2][2]]); + let (a, b) = focals_from_homography(&hh); + assert!((a.unwrap() - f).abs() < 1.0, "{a:?}"); + assert!((b.unwrap() - f).abs() < 1.0, "{b:?}"); + } +} diff --git a/crates/merge/src/pano/finish.rs b/crates/merge/src/pano/finish.rs new file mode 100644 index 000000000..ac57a18d0 --- /dev/null +++ b/crates/merge/src/pano/finish.rs @@ -0,0 +1,210 @@ +//! Panorama finishing: boundary warp, auto crop, fill edges. +//! +//! - **Boundary warp** (0–100): a separable stretch that maps, per column, the smoothed top and +//! bottom edges of the valid area to the canvas edges, then per row the left and right edges; +//! `amount` blends between the identity and that stretch. (A simple, documented alternative to +//! content-preserving mesh warps.) +//! - **Auto crop**: the largest axis-aligned rectangle of fully valid pixels (maximal rectangle in a +//! binary matrix via a per-row histogram and a monotone stack, O(w·h)). +//! - **Fill edges**: transparent areas are filled by push–pull diffusion of the surrounding pixels +//! in log space (no patch synthesis). + +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; + +use super::blend::{Buf, push_pull}; + +fn edges(alpha: &Plane, vertical: bool) -> Option<(Vec, Vec)> { + let (w, h) = (alpha.width, alpha.height); + let (n, len) = if vertical { (w, h) } else { (h, w) }; + let at = |i: usize, k: usize| if vertical { alpha.get(i, k) } else { alpha.get(k, i) }; + let mut lo = vec![f64::NAN; n]; + let mut hi = vec![f64::NAN; n]; + for i in 0..n { + if let Some(a) = (0..len).find(|&k| at(i, k) >= 0.5) { + let b = (0..len).rev().find(|&k| at(i, k) >= 0.5).unwrap_or(a); + lo[i] = a as f64; + hi[i] = b as f64 + 1.0; + } + } + let known: Vec = (0..n).filter(|&i| lo[i].is_finite()).collect(); + if known.len() < 2 { + return None; + } + // fill gaps by linear interpolation / nearest at the ends + for i in 0..n { + if lo[i].is_finite() { + continue; + } + let p = known.iter().rev().find(|&&k| k < i).copied(); + let q = known.iter().find(|&&k| k > i).copied(); + let (a, b) = match (p, q) { + (Some(p), Some(q)) => { + let t = (i - p) as f64 / (q - p) as f64; + (lo[p] + (lo[q] - lo[p]) * t, hi[p] + (hi[q] - hi[p]) * t) + } + (Some(p), None) => (lo[p], hi[p]), + (None, Some(q)) => (lo[q], hi[q]), + _ => (0.0, len as f64), + }; + lo[i] = a; + hi[i] = b; + } + // smooth (box, radius n/40) so the stretch follows the overall shape, not every jag + let r = (n / 40).max(1) as isize; + let smooth = |v: &[f64]| -> Vec { + (0..n as isize) + .map(|i| { + let (a, b) = ((i - r).max(0) as usize, ((i + r) as usize).min(n - 1)); + v[a..=b].iter().sum::() / (b - a + 1) as f64 + }) + .collect() + }; + Some((smooth(&lo), smooth(&hi))) +} + +fn stretch(img: &Rgb32f, alpha: &Plane, amount: f64, vertical: bool) -> (Rgb32f, Plane) { + let Some((lo, hi)) = edges(alpha, vertical) else { return (img.clone(), alpha.clone()) }; + let (w, h) = (img.width, img.height); + let len = if vertical { h } else { w } as f64; + let mut out = Rgb32f::new(w, h); + let mut oa = Plane::new(w, h); + out.data.par_chunks_mut(w).zip(oa.data.par_chunks_mut(w)).enumerate().for_each(|(y, (row, arow))| { + for x in 0..w { + let (i, k) = if vertical { (x, y) } else { (y, x) }; + let t = (k as f64 + 0.5) / len; + let src = lo[i] + t * (hi[i] - lo[i]); + let s = (1.0 - amount) * (k as f64 + 0.5) + amount * src; + let (sx, sy) = if vertical { (x as f32 + 0.5, s as f32) } else { (s as f32, y as f32 + 0.5) }; + row[x] = img.sample_bilinear(sx, sy); + arow[x] = crate::features::bilinear(alpha, sx, sy); + } + }); + (out, oa) +} + +/// Boundary warp by `amount` (0..1). +pub fn boundary_warp(img: &Rgb32f, alpha: &Plane, amount: f64) -> (Rgb32f, Plane) { + if amount <= 0.0 { + return (img.clone(), alpha.clone()); + } + let amount = amount.min(1.0); + let (i1, a1) = stretch(img, alpha, amount, true); + stretch(&i1, &a1, amount, false) +} + +/// Largest axis-aligned rectangle where `alpha ≥ 0.999`: (x, y, w, h) in pixels. +pub fn auto_crop(alpha: &Plane) -> Option<(usize, usize, usize, usize)> { + let (w, h) = (alpha.width, alpha.height); + if w == 0 || h == 0 { + return None; + } + // work on a ≤ 1500 px grid with conservative (min) pooling + let f = w.max(h).div_ceil(1500).max(1); + let (gw, gh) = (w.div_ceil(f), h.div_ceil(f)); + let ok = |gx: usize, gy: usize| -> bool { + for y in gy * f..((gy + 1) * f).min(h) { + for x in gx * f..((gx + 1) * f).min(w) { + if alpha.get(x, y) < 0.999 { + return false; + } + } + } + true + }; + let grid: Vec = (0..gh).into_par_iter().flat_map_iter(|gy| (0..gw).map(move |gx| (gx, gy))).map(|(gx, gy)| ok(gx, gy)).collect(); + let mut heights = vec![0usize; gw]; + let mut best = (0usize, 0, 0, 0, 0); // area, x, y, w, h + for gy in 0..gh { + for gx in 0..gw { + heights[gx] = if grid[gy * gw + gx] { heights[gx] + 1 } else { 0 }; + } + let mut stack: Vec = Vec::new(); + for i in 0..=gw { + let cur = if i < gw { heights[i] } else { 0 }; + while let Some(&top) = stack.last() { + if heights[top] <= cur { + break; + } + stack.pop(); + let hgt = heights[top]; + let left = stack.last().map_or(0, |&s| s + 1); + let width = i - left; + let area = hgt * width; + if area > best.0 { + best = (area, left, gy + 1 - hgt, width, hgt); + } + } + stack.push(i); + } + } + if best.0 == 0 { + return None; + } + let (x, y) = (best.1 * f, best.2 * f); + Some((x, y, (best.3 * f).min(w - x), (best.4 * f).min(h - y))) +} + +/// Fill transparent pixels (alpha < 1) from their surroundings; returns the image with alpha 1. +pub fn fill_edges(img: &Rgb32f, alpha: &Plane) -> Rgb32f { + let (w, h) = (img.width, img.height); + let mut b = Buf::new(w, h, 3); + for (i, p) in img.data.iter().enumerate() { + for c in 0..3 { + b.data[i * 3 + c] = (p[c].max(1e-6)).ln(); + } + } + let wgt: Vec = alpha.data.iter().map(|a| if *a >= 0.999 { 1.0 } else { 0.0 }).collect(); + push_pull(&mut b, &wgt); + let mut out = img.clone(); + for (i, p) in out.data.iter_mut().enumerate() { + let a = alpha.data[i].clamp(0.0, 1.0); + if a < 0.999 { + let f = [0, 1, 2].map(|c| b.data[i * 3 + c].exp()); + // the warped/blended edge pixels are premultiplied-like: un-premultiply then mix + let v = if a > 1e-3 { p.map(|v| v / a) } else { [0.0; 3] }; + *p = [0, 1, 2].map(|c| v[c] * a + f[c] * (1.0 - a)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crop_finds_the_largest_rectangle() { + let mut a = Plane::new(100, 60); + for y in 10..50 { + for x in 5..90 { + a.set(x, y, 1.0); + } + } + for y in 0..60 { + a.set(40, y, 1.0); // a thin spike doesn't matter + } + assert_eq!(auto_crop(&a), Some((5, 10, 85, 40))); + } + + #[test] + fn warp_fills_a_curved_boundary() { + let (w, h) = (200, 100); + let mut a = Plane::new(w, h); + let img = Rgb32f::from_fn(w, h, |x, _| [(x + 1) as f32 / w as f32; 3]); + for x in 0..w { + let dip = (10.0 * (1.0 - ((x as f32 - 100.0) / 100.0).powi(2))) as usize; + for y in dip..h - dip { + a.set(x, y, 1.0); + } + } + let before = a.data.iter().filter(|v| **v >= 0.5).count(); + let (_, a2) = boundary_warp(&img, &a, 1.0); + let after = a2.data.iter().filter(|v| **v >= 0.5).count(); + assert!(after as f64 > 0.97 * (w * h) as f64 && after > before, "{before} → {after}"); + let (_, a0) = boundary_warp(&img, &a, 0.0); + assert_eq!(a0, a); + let filled = fill_edges(&img, &a); + assert!(filled.data.iter().all(|p| p[0] > 0.0)); + } +} diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs new file mode 100644 index 000000000..d81abda9f --- /dev/null +++ b/crates/merge/src/pano/mod.rs @@ -0,0 +1,461 @@ +//! Panorama stitching. +//! +//! 1. Features ([`crate::features`]) on each frame at ≤ 1000 px, pairwise matching and RANSAC +//! homographies, largest connected set ([`camera`]). +//! 2. Camera rotations + focal lengths: initialised from the homographies, bundle-adjusted, +//! straightened ([`camera`]). +//! 3. Projection: spherical, cylindrical or perspective (rectilinear), or chosen automatically +//! from the field of view ([`choose_projection`]). +//! 4. Exposure compensation: EXIF exposure, then per-image gains solved from the mean intensities +//! of every overlap (Brown & Lowe's gain compensation, a small linear least-squares problem +//! with a prior pulling gains towards 1). +//! 5. Seams: each canvas pixel belongs to the image whose centre-weighted feather weight is +//! highest there (Voronoi-like seams away from image borders); multi-band blending in log space +//! ([`blend`]). +//! 6. Boundary warp, auto crop, fill edges ([`finish`]). + +pub mod blend; +pub mod camera; +pub mod finish; +pub mod project; + +use lightcraft_raster::{Plane, Rgb32f}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; + +pub use project::Projection; + +use crate::frame::{Frame, FrameColor}; +use crate::linalg; +use crate::{MergeError, Progress, Result, check}; +use blend::{Blender, Buf, push_pull}; +use camera::Camera; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PanoOptions { + pub projection: Projection, + /// 0..100. + pub boundary_warp: f64, + pub auto_crop: bool, + pub fill_edges: bool, + /// Output size limit (megapixels); the panorama is scaled down to fit. + pub max_megapixels: f64, +} + +impl Default for PanoOptions { + fn default() -> Self { + PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: 40.0 } + } +} + +/// The stitched panorama. +#[derive(Clone, Debug)] +pub struct PanoResult { + /// Scene-linear pixels (same units as the frames, after exposure compensation). + pub image: Rgb32f, + /// Coverage (1 = image data, 0 = empty canvas). + pub alpha: Plane, + /// Auto-crop rectangle (normalised x, y, w, h), when requested. + pub crop: Option<[f64; 4]>, + /// The projection used (never `Auto`). + pub projection: Projection, + /// Indices (into the inputs) of the frames in the panorama; the others didn't connect. + pub used: Vec, + /// Cameras of the inputs (`None` = not used), in their full-resolution pixels, straightened. + pub cameras: Vec>, + /// Per-input exposure gains applied. + pub gains: Vec, + /// Bundle adjustment RMS reprojection error (pixels) before/after. + pub rms_before: f64, + pub rms: f64, + /// Horizontal and vertical field of view (degrees). + pub fov: (f64, f64), + /// Output focal length and the surface coordinates of the canvas's top-left corner: canvas + /// pixel (x, y) (before boundary warp) shows surface point (x + 0.5 + origin.0, y + 0.5 + origin.1). + pub focal: f64, + pub origin: (f64, f64), + pub color: FrameColor, + pub metadata: lightcraft_meta::Metadata, + pub raw: bool, + pub baseline_exposure: f64, +} + +/// Field of view (degrees) of the cameras' union, measured in the spherical projection. +fn fov(frames: &[Frame], cams: &[Option]) -> (f64, f64) { + let (mut tmin, mut tmax, mut pmin, mut pmax) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN); + for (f, c) in frames.iter().zip(cams) { + let Some(c) = c else { continue }; + for (x, y) in border(f.width(), f.height(), 16) { + if let Some((t, p)) = Projection::Spherical.forward(c.ray(x, y), 1.0) { + tmin = tmin.min(t); + tmax = tmax.max(t); + pmin = pmin.min(p); + pmax = pmax.max(p); + } + } + } + ((tmax - tmin).to_degrees(), (pmax - pmin).to_degrees()) +} + +/// Points along an image's border. +fn border(w: usize, h: usize, per_edge: usize) -> Vec<(f64, f64)> { + let (w, h) = (w as f64, h as f64); + let mut v = Vec::new(); + for k in 0..=per_edge { + let t = k as f64 / per_edge as f64; + v.extend([(t * w, 0.0), (t * w, h), (0.0, t * h), (w, t * h)]); + } + v +} + +/// Surface bounding box of every used image in `proj` (output focal `f`). +fn bounds(frames: &[Frame], cams: &[Option], proj: Projection, f: f64) -> Option<(f64, f64, f64, f64)> { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (fr, c) in frames.iter().zip(cams) { + let Some(c) = c else { continue }; + for (x, y) in border(fr.width(), fr.height(), 24) { + let (u, v) = proj.forward(c.ray(x, y), f)?; + x0 = x0.min(u); + y0 = y0.min(v); + x1 = x1.max(u); + y1 = y1.max(v); + } + } + (x1 > x0 && y1 > y0).then_some((x0, y0, x1, y1)) +} + +/// Auto projection: perspective when the field of view is moderate and the rectilinear canvas +/// stays compact; cylindrical for wide but not tall panoramas; spherical otherwise. +pub fn choose_projection(frames: &[Frame], cams: &[Option]) -> Projection { + let (h, v) = fov(frames, cams); + let area: f64 = frames.iter().zip(cams).filter(|(_, c)| c.is_some()).map(|(f, _)| (f.width() * f.height()) as f64).sum(); + let fmed = median(cams.iter().flatten().map(|c| c.f).collect()); + if h < 110.0 + && v < 110.0 + && let Some((x0, y0, x1, y1)) = bounds(frames, cams, Projection::Perspective, fmed) + && (x1 - x0) * (y1 - y0) < 2.5 * area + { + return Projection::Perspective; + } + if v < 90.0 { Projection::Cylindrical } else { Projection::Spherical } +} + +fn median(mut v: Vec) -> f64 { + if v.is_empty() { + return 1.0; + } + v.sort_by(|a, b| a.total_cmp(b)); + v[v.len() / 2] +} + +/// One image resampled onto its canvas tile. +struct Tile { + x0: usize, + y0: usize, + w: usize, + h: usize, + rgb: Vec<[f32; 3]>, + /// Feather weight (0 = outside the image). + wgt: Vec, +} + +/// Register the frames: cameras for the connected set. +pub fn register(frames: &[Frame], progress: &Progress) -> Result<(Vec>, f64, f64)> { + let n = frames.len(); + let work: Vec<(crate::features::Features, f64)> = frames + .par_iter() + .map(|f| { + let med = crate::align::median_luma(&f.image, f.clip); + let (p, s) = crate::align::work_image(&f.image, 0.18 / med.max(1e-6), 1000); + (crate::features::detect(&p, 1200), s) + }) + .collect(); + check(progress, 0.2, "Matching")?; + let feats: Vec<_> = work.iter().map(|(f, _)| f.clone()).collect(); + let scales: Vec = work.iter().map(|(_, s)| *s).collect(); + let pairs = camera::match_pairs(&feats, &scales); + let ids = camera::largest_component(n, &pairs); + if ids.len() < 2 { + return Err(MergeError::NoOverlap("the photos don't overlap enough to be stitched".into())); + } + check(progress, 0.3, "Aligning")?; + // focal length: EXIF 35 mm equivalent, else the homographies, else ~50° horizontal + let sizes: Vec<(usize, usize)> = frames.iter().map(|f| (f.width(), f.height())).collect(); + let mut fs = Vec::new(); + for p in &pairs { + if !(ids.contains(&p.i) && ids.contains(&p.j)) { + continue; + } + let ci = (sizes[p.i].0 as f64 / 2.0, sizes[p.i].1 as f64 / 2.0); + let cj = (sizes[p.j].0 as f64 / 2.0, sizes[p.j].1 as f64 / 2.0); + let ti = lightcraft_geom::Homography([1.0, 0.0, ci.0, 0.0, 1.0, ci.1, 0.0, 0.0, 1.0]); + let tj = lightcraft_geom::Homography([1.0, 0.0, -cj.0, 0.0, 1.0, -cj.1, 0.0, 0.0, 1.0]); + let hc = tj.mul(&p.h).mul(&ti); + if let (Some(a), Some(b)) = camera::focals_from_homography(&hc) { + fs.push((a * b).sqrt()); + } + } + let long = sizes[ids[0]].0.max(sizes[ids[0]].1) as f64; + let exif_f = frames[ids[0]].metadata.focal_length_35mm.filter(|v| *v > 1.0).map(|f35| f35 / 36.0 * long); + let hom_f = (!fs.is_empty()).then(|| median(fs.clone())).filter(|f| *f > 0.1 * long && *f < 20.0 * long); + let f0 = exif_f.or(hom_f).unwrap_or(long * 1.07); + let cams = camera::initial_cameras(&ids, &sizes, &pairs, f0); + let anchor = ids.iter().copied().find(|&i| cams[i].is_some()).unwrap_or(ids[0]); + check(progress, 0.35, "Aligning")?; + let adj = camera::bundle_adjust(&cams, &pairs, anchor, 60); + let mut cams = adj.cameras; + camera::straighten(&mut cams); + Ok((cams, adj.rms_before, adj.rms)) +} + +/// Stitch `frames` (oriented, linear) into a panorama. +pub fn stitch(frames: Vec, opts: &PanoOptions, progress: &Progress) -> Result { + let n = frames.len(); + if n < 2 { + return Err(MergeError::TooFew(n, 2)); + } + check(progress, 0.01, "Finding features")?; + let (cams, rms_before, rms) = register(&frames, progress)?; + let used: Vec = (0..n).filter(|&i| cams[i].is_some()).collect(); + let fov = fov(&frames, &cams); + let projection = match opts.projection { + Projection::Auto => choose_projection(&frames, &cams), + p => p, + }; + let fmed = median(used.iter().map(|&i| cams[i].expect("used").f).collect()); + let Some((bx0, by0, bx1, by1)) = bounds(&frames, &cams, projection, fmed) else { + return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0))); + }; + let area: f64 = used.iter().map(|&i| (frames[i].width() * frames[i].height()) as f64).sum(); + if projection == Projection::Perspective && (bx1 - bx0) * (by1 - by0) > 12.0 * area { + return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0))); + } + // output scale: native resolution, limited to max_megapixels + let max_px = (opts.max_megapixels.max(0.1) * 1e6).min(400e6); + let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(1.0); + let f_out = fmed * s; + let (ox, oy) = (bx0 * s, by0 * s); + let (cw, ch) = (((bx1 - bx0) * s).ceil() as usize + 1, ((by1 - by0) * s).ceil() as usize + 1); + let levels = ((cw.min(ch) as f64).log2() - 3.0).floor().clamp(1.0, 7.0) as usize; + let unit = 1usize << levels; + let (pw, ph) = (cw.div_ceil(unit) * unit, ch.div_ceil(unit) * unit); + check(progress, 0.45, "Projecting")?; + + // exposure from EXIF relative to the first used frame + let ev0 = frames[used[0]].exposure; + let mut gains: Vec = (0..n) + .map(|i| match (frames[i].exposure, ev0) { + (Some(e), Some(e0)) => 2f64.powf(e0 - e), + _ => 1.0, + }) + .collect(); + + // resample every used frame onto its tile + let tiles: Vec> = (0..n) + .into_par_iter() + .map(|i| { + let c = cams[i]?; + let fr = &frames[i]; + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in border(fr.width(), fr.height(), 32) { + let (u, v) = projection.forward(c.ray(x, y), f_out)?; + x0 = x0.min(u - ox); + y0 = y0.min(v - oy); + x1 = x1.max(u - ox); + y1 = y1.max(v - oy); + } + let pad = 2.0 * unit as f64; + let tx0 = (((x0 - pad).max(0.0) as usize) / unit) * unit; + let ty0 = (((y0 - pad).max(0.0) as usize) / unit) * unit; + let tx1 = (((x1 + pad).max(0.0) as usize).min(pw)).div_ceil(unit) * unit; + let ty1 = (((y1 + pad).max(0.0) as usize).min(ph)).div_ceil(unit) * unit; + let (tw, th) = (tx1.min(pw).saturating_sub(tx0), ty1.min(ph).saturating_sub(ty0)); + if tw == 0 || th == 0 { + return None; + } + let (fw, fh) = (fr.width() as f64, fr.height() as f64); + let mut rgb = vec![[0f32; 3]; tw * th]; + let mut wgt = vec![0f32; tw * th]; + rgb.par_chunks_mut(tw).zip(wgt.par_chunks_mut(tw)).enumerate().for_each(|(ty, (row, wrow))| { + let v = (ty0 + ty) as f64 + 0.5 + oy; + for tx in 0..tw { + let u = (tx0 + tx) as f64 + 0.5 + ox; + let d = projection.inverse(u, v, f_out); + let Some((px, py)) = c.project(d) else { continue }; + if px < 0.0 || py < 0.0 || px > fw || py > fh { + continue; + } + let wx = 1.0 - (2.0 * px / fw - 1.0).abs(); + let wy = 1.0 - (2.0 * py / fh - 1.0).abs(); + let w = (wx * wy).max(1e-6) as f32; + row[tx] = fr.image.sample_bilinear(px as f32, py as f32); + wrow[tx] = w; + } + }); + Some(Tile { x0: tx0, y0: ty0, w: tw, h: th, rgb, wgt }) + }) + .collect(); + check(progress, 0.6, "Exposure compensation")?; + compensate_gains(&tiles, &mut gains, &frames); + + // seams: label = max feather weight + let mut best = vec![0f32; pw * ph]; + let mut label = vec![u16::MAX; pw * ph]; + for (i, t) in tiles.iter().enumerate() { + let Some(t) = t else { continue }; + for ty in 0..t.h { + let row = (t.y0 + ty) * pw + t.x0; + for tx in 0..t.w { + let w = t.wgt[ty * t.w + tx]; + if w > best[row + tx] { + best[row + tx] = w; + label[row + tx] = i as u16; + } + } + } + } + check(progress, 0.7, "Blending")?; + let mut blender = Blender::new(pw, ph, levels); + for (i, t) in tiles.into_iter().enumerate() { + let Some(t) = t else { continue }; + let g = gains[i] as f32; + let mut vals = Buf::new(t.w, t.h, 3); + let mut mask = Buf::new(t.w, t.h, 1); + for ty in 0..t.h { + for tx in 0..t.w { + let k = ty * t.w + tx; + if t.wgt[k] > 0.0 { + for c in 0..3 { + vals.data[k * 3 + c] = (t.rgb[k][c] * g).max(1e-6).ln(); + } + } + if label[(t.y0 + ty) * pw + t.x0 + tx] == i as u16 { + mask.data[k] = 1.0; + } + } + } + push_pull(&mut vals, &t.wgt); + blender.add(t.x0, t.y0, vals, mask); + check(progress, 0.7 + 0.2 * (i + 1) as f32 / n as f32, "Blending")?; + } + let out = blender.finish(); + let mut image = Rgb32f::new(cw, ch); + let mut alpha = Plane::new(cw, ch); + for y in 0..ch { + for x in 0..cw { + let k = y * pw + x; + if label[k] != u16::MAX { + image.data[y * cw + x] = [0, 1, 2].map(|c| out.data[k * 3 + c].exp()); + alpha.data[y * cw + x] = 1.0; + } + } + } + check(progress, 0.92, "Finishing")?; + let (mut image, alpha) = finish::boundary_warp(&image, &alpha, opts.boundary_warp / 100.0); + let crop = if opts.auto_crop { + finish::auto_crop(&alpha).map(|(x, y, w, h)| [x as f64 / cw as f64, y as f64 / ch as f64, w as f64 / cw as f64, h as f64 / ch as f64]) + } else { + None + }; + if opts.fill_edges { + image = finish::fill_edges(&image, &alpha); + } + let r = &frames[used[0]]; + Ok(PanoResult { + image, + alpha, + crop, + projection, + used, + cameras: cams, + gains, + rms_before, + rms, + fov, + focal: f_out, + origin: (ox, oy), + color: r.color.clone(), + metadata: r.metadata.clone(), + raw: r.raw, + baseline_exposure: r.baseline_exposure, + }) +} + +/// Gain compensation (Brown & Lowe §6): minimise +/// Σᵢⱼ Nᵢⱼ [ (gᵢ Īᵢⱼ − gⱼ Īⱼᵢ)² / σN² + (1 − gᵢ/g⁰ᵢ)² / σg² ] over the overlaps, with intensities +/// relative to the mean overlap level (σN = 0.1; σg = 1, a weak prior that only fixes the overall +/// level) and `g⁰` the EXIF-based gains. +fn compensate_gains(tiles: &[Option], gains: &mut [f64], frames: &[Frame]) { + let n = tiles.len(); + let lum = |p: [f32; 3]| 0.25 * p[0] as f64 + 0.6 * p[1] as f64 + 0.15 * p[2] as f64; + let mut nij = vec![0f64; n * n]; + let mut iij = vec![0f64; n * n]; + for i in 0..n { + let Some(a) = &tiles[i] else { continue }; + for j in 0..n { + let Some(b) = tiles[j].as_ref().filter(|_| i != j) else { continue }; + let (x0, y0) = (a.x0.max(b.x0), a.y0.max(b.y0)); + let (x1, y1) = ((a.x0 + a.w).min(b.x0 + b.w), (a.y0 + a.h).min(b.y0 + b.h)); + let (mut cnt, mut sum) = (0usize, 0f64); + for y in (y0..y1).step_by(2) { + for x in (x0..x1).step_by(2) { + let ka = (y - a.y0) * a.w + x - a.x0; + let kb = (y - b.y0) * b.w + x - b.x0; + if a.wgt[ka] > 0.0 && b.wgt[kb] > 0.0 { + let pa = a.rgb[ka]; + if pa.iter().any(|v| *v >= frames[i].clip * 0.95) || b.rgb[kb].iter().any(|v| *v >= frames[j].clip * 0.95) { + continue; + } + cnt += 1; + sum += lum(pa); + } + } + } + if cnt > 50 { + nij[i * n + j] = cnt as f64; + iij[i * n + j] = sum / cnt as f64; + } + } + } + let total: f64 = nij.iter().sum(); + if total <= 0.0 { + return; + } + let mean = (0..n * n).map(|k| nij[k] * iij[k] * gains[k / n]).sum::() / total; + if mean <= 0.0 { + return; + } + let (sn, sg) = (0.1f64, 1.0f64); + let g0: Vec = gains.to_vec(); + let mut a = vec![0f64; n * n]; + let mut b = vec![0f64; n]; + for i in 0..n { + if tiles[i].is_none() { + a[i * n + i] = 1.0; + b[i] = g0[i]; + continue; + } + for j in 0..n { + let nn = nij[i * n + j]; + if nn <= 0.0 || nij[j * n + i] <= 0.0 { + continue; + } + let (ii, ij) = (iij[i * n + j] / mean, iij[j * n + i] / mean); + // d/dgᵢ of Nᵢⱼ (gᵢ Iᵢⱼ − gⱼ Iⱼᵢ)²/σN² (both orderings of the pair are in the sum) + a[i * n + i] += 2.0 * nn * ii * ii / (sn * sn); + a[i * n + j] -= 2.0 * nn * ii * ij / (sn * sn); + a[i * n + i] += 2.0 * nn / (sg * sg * g0[i] * g0[i]); + b[i] += 2.0 * nn / (sg * sg * g0[i]); + } + if a[i * n + i] == 0.0 { + a[i * n + i] = 1.0; + b[i] = g0[i]; + } + } + if let Some(g) = linalg::solve(a, b, n) + && g.iter().all(|v| v.is_finite() && *v > 0.0) + { + gains.copy_from_slice(&g); + } +} diff --git a/crates/merge/src/pano/project.rs b/crates/merge/src/pano/project.rs new file mode 100644 index 000000000..0407f4724 --- /dev/null +++ b/crates/merge/src/pano/project.rs @@ -0,0 +1,83 @@ +//! Panorama projections. World directions use camera-style axes: x right, y down, z forward. +//! Surface coordinates are in units of the output focal length `f` (pixels). + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Projection { + /// Pick from the field of view (see [`crate::pano::choose_projection`]). + #[default] + Auto, + /// Longitude/latitude (equirectangular): any field of view; straight lines bend. + Spherical, + /// Longitude/height on a cylinder: wide horizontal panoramas; verticals stay straight. + Cylindrical, + /// Rectilinear (gnomonic): straight lines stay straight; fields of view < ~120°. + Perspective, +} + +impl Projection { + /// Project a world direction to surface coordinates (`None` when not representable). + #[inline] + pub fn forward(self, d: [f64; 3], f: f64) -> Option<(f64, f64)> { + match self { + Projection::Perspective => { + if d[2] <= 1e-6 { + return None; + } + Some((f * d[0] / d[2], f * d[1] / d[2])) + } + Projection::Cylindrical => { + let r = (d[0] * d[0] + d[2] * d[2]).sqrt(); + if r < 1e-12 { + return None; + } + Some((f * d[0].atan2(d[2]), f * d[1] / r)) + } + Projection::Spherical | Projection::Auto => { + let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + if n < 1e-12 { + return None; + } + Some((f * d[0].atan2(d[2]), f * (d[1] / n).clamp(-1.0, 1.0).asin())) + } + } + } + + /// The world direction (not normalised) of surface coordinates. + #[inline] + pub fn inverse(self, x: f64, y: f64, f: f64) -> [f64; 3] { + match self { + Projection::Perspective => [x / f, y / f, 1.0], + Projection::Cylindrical => { + let t = x / f; + [t.sin(), y / f, t.cos()] + } + Projection::Spherical | Projection::Auto => { + let (t, p) = (x / f, y / f); + [t.sin() * p.cos(), p.sin(), t.cos() * p.cos()] + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projections_round_trip() { + for p in [Projection::Perspective, Projection::Cylindrical, Projection::Spherical] { + for d in [[0.1, -0.2, 1.0], [-0.5, 0.3, 0.8], [0.0, 0.0, 1.0]] { + let (x, y) = p.forward(d, 1000.0).unwrap(); + let back = p.inverse(x, y, 1000.0); + let (n1, n2) = (crate::linalg::normalize(d), crate::linalg::normalize(back)); + for c in 0..3 { + assert!((n1[c] - n2[c]).abs() < 1e-9, "{p:?} {d:?} {back:?}"); + } + } + } + assert!(Projection::Perspective.forward([1.0, 0.0, -0.1], 1.0).is_none()); + } +} diff --git a/crates/merge/src/ransac.rs b/crates/merge/src/ransac.rs new file mode 100644 index 000000000..b0937643e --- /dev/null +++ b/crates/merge/src/ransac.rs @@ -0,0 +1,291 @@ +//! Robust 2-D motion models: normalised DLT homographies (Hartley & Zisserman, "Multiple View +//! Geometry", §4.4) and RANSAC (Fischler & Bolles 1981) with the adaptive iteration count. +//! Deterministic: the sampler is a fixed-seed xorshift, so results are reproducible. + +use lightcraft_geom::{Homography, Point}; + +use crate::linalg::sym_eigen; + +/// The kind of motion to fit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Model { + Translation, + /// Rotation + uniform scale + translation (4 dof). + Similarity, + Homography, +} + +impl Model { + fn min_samples(self) -> usize { + match self { + Model::Translation => 1, + Model::Similarity => 2, + Model::Homography => 4, + } + } +} + +#[derive(Clone, Debug)] +pub struct Fit { + /// Maps points of the first set to the second. + pub h: Homography, + pub inliers: Vec, + /// RMS transfer error of the inliers (pixels). + pub rms: f64, +} + +/// Small deterministic PRNG (xorshift64*). +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Rng { + Rng(seed.max(1)) + } + pub fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + pub fn below(&mut self, n: usize) -> usize { + (self.next_u64() % n.max(1) as u64) as usize + } + /// Uniform in [0, 1). + pub fn unit(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } +} + +/// Least-squares fit of `model` to all pairs (`a[i]` → `b[i]`). +pub fn fit_model(model: Model, a: &[Point], b: &[Point]) -> Option { + let n = a.len().min(b.len()); + if n < model.min_samples() { + return None; + } + match model { + Model::Translation => { + let (mut dx, mut dy) = (0.0, 0.0); + for i in 0..n { + dx += b[i].x - a[i].x; + dy += b[i].y - a[i].y; + } + Some(Homography([1.0, 0.0, dx / n as f64, 0.0, 1.0, dy / n as f64, 0.0, 0.0, 1.0])) + } + Model::Similarity => { + // closed form (Umeyama without reflection): b ≈ s R a + t + let ca = centroid(&a[..n]); + let cb = centroid(&b[..n]); + let (mut sxx, mut sxy, mut var) = (0.0, 0.0, 0.0); + for i in 0..n { + let (ax, ay) = (a[i].x - ca.x, a[i].y - ca.y); + let (bx, by) = (b[i].x - cb.x, b[i].y - cb.y); + sxx += ax * bx + ay * by; + sxy += ax * by - ay * bx; + var += ax * ax + ay * ay; + } + if var < 1e-12 { + return None; + } + let (c, s) = (sxx / var, sxy / var); + let tx = cb.x - (c * ca.x - s * ca.y); + let ty = cb.y - (s * ca.x + c * ca.y); + Some(Homography([c, -s, tx, s, c, ty, 0.0, 0.0, 1.0])) + } + Model::Homography => dlt(&a[..n], &b[..n]), + } +} + +fn centroid(p: &[Point]) -> Point { + let n = p.len().max(1) as f64; + Point::new(p.iter().map(|q| q.x).sum::() / n, p.iter().map(|q| q.y).sum::() / n) +} + +/// Hartley normalisation: translate to the centroid, scale to mean distance √2. +fn normaliser(p: &[Point]) -> [f64; 3] { + let c = centroid(p); + let md = p.iter().map(|q| ((q.x - c.x).powi(2) + (q.y - c.y).powi(2)).sqrt()).sum::() / p.len().max(1) as f64; + let s = if md > 1e-12 { std::f64::consts::SQRT_2 / md } else { 1.0 }; + [s, -s * c.x, -s * c.y] +} + +/// Normalised direct linear transform (≥ 4 points). +pub fn dlt(a: &[Point], b: &[Point]) -> Option { + let n = a.len(); + if n < 4 { + return None; + } + let ta = normaliser(a); + let tb = normaliser(b); + let mut ata = [0.0f64; 81]; + for i in 0..n { + let (x, y) = (a[i].x * ta[0] + ta[1], a[i].y * ta[0] + ta[2]); + let (u, v) = (b[i].x * tb[0] + tb[1], b[i].y * tb[0] + tb[2]); + let r1 = [-x, -y, -1.0, 0.0, 0.0, 0.0, u * x, u * y, u]; + let r2 = [0.0, 0.0, 0.0, -x, -y, -1.0, v * x, v * y, v]; + for r in [r1, r2] { + for p in 0..9 { + if r[p] == 0.0 { + continue; + } + for q in 0..9 { + ata[p * 9 + q] += r[p] * r[q]; + } + } + } + } + let (_, vecs) = sym_eigen(&ata, 9); + let h = &vecs[0]; + let hn = Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8]]); + // denormalise: H = Tb⁻¹ · Hn · Ta + let ta_m = Homography([ta[0], 0.0, ta[1], 0.0, ta[0], ta[2], 0.0, 0.0, 1.0]); + let tb_inv = Homography([1.0 / tb[0], 0.0, -tb[1] / tb[0], 0.0, 1.0 / tb[0], -tb[2] / tb[0], 0.0, 0.0, 1.0]); + let h = tb_inv.mul(&hn).mul(&ta_m); + let s = h.0[8]; + if s.abs() < 1e-14 || h.0.iter().any(|v| !v.is_finite()) { + return None; + } + Some(Homography(h.0.map(|v| v / s))) +} + +pub fn transfer_error2(h: &Homography, a: Point, b: Point) -> f64 { + let p = h.apply(a); + (p.x - b.x).powi(2) + (p.y - b.y).powi(2) +} + +/// RANSAC fit of `model` mapping `a` → `b`, inlier threshold `thresh` pixels. Refits on the +/// inliers (twice) and returns `None` when fewer than `min_inliers` agree. +pub fn ransac(model: Model, a: &[Point], b: &[Point], thresh: f64, min_inliers: usize, seed: u64) -> Option { + let n = a.len().min(b.len()); + let k = model.min_samples(); + if n < k.max(min_inliers) { + return None; + } + let t2 = thresh * thresh; + let mut rng = Rng::new(seed ^ 0x9e37_79b9_7f4a_7c15); + let mut best: Vec = Vec::new(); + let mut iters = 2000usize; + let mut it = 0; + let mut sa = Vec::with_capacity(k); + let mut sb = Vec::with_capacity(k); + while it < iters { + it += 1; + sa.clear(); + sb.clear(); + let mut idx = [usize::MAX; 4]; + let mut ok = true; + for s in 0..k { + let mut tries = 0; + let i = loop { + let i = rng.below(n); + if !idx[..s].contains(&i) { + break i; + } + tries += 1; + if tries > 20 { + ok = false; + break i; + } + }; + idx[s] = i; + sa.push(a[i]); + sb.push(b[i]); + } + if !ok { + continue; + } + let h = match model { + Model::Homography => { + let src = [sa[0], sa[1], sa[2], sa[3]]; + let dst = [sb[0], sb[1], sb[2], sb[3]]; + if degenerate(&src) || degenerate(&dst) { + continue; + } + match Homography::from_quads(&src, &dst) { + Some(h) => h, + None => continue, + } + } + _ => match fit_model(model, &sa, &sb) { + Some(h) => h, + None => continue, + }, + }; + let inl: Vec = (0..n).filter(|&i| transfer_error2(&h, a[i], b[i]) < t2).collect(); + if inl.len() > best.len() { + best = inl; + let w = best.len() as f64 / n as f64; + let p_fail = 1.0 - w.powi(k as i32); + if p_fail <= 1e-12 { + iters = it; + } else { + let need = ((1e-3f64).ln() / p_fail.ln()).ceil(); + iters = iters.min(need.max(16.0) as usize); + } + } + } + if best.len() < min_inliers.max(k) { + return None; + } + let mut h = None; + for _ in 0..3 { + let ia: Vec = best.iter().map(|&i| a[i]).collect(); + let ib: Vec = best.iter().map(|&i| b[i]).collect(); + let Some(hh) = fit_model(model, &ia, &ib) else { break }; + let inl: Vec = (0..n).filter(|&i| transfer_error2(&hh, a[i], b[i]) < t2).collect(); + h = Some(hh); + if inl.len() < best.len() || inl == best { + break; + } + best = inl; + } + let h = h?; + if best.len() < min_inliers.max(k) { + return None; + } + let rms = (best.iter().map(|&i| transfer_error2(&h, a[i], b[i])).sum::() / best.len() as f64).sqrt(); + Some(Fit { h, inliers: best, rms }) +} + +/// Three (nearly) collinear points among the four. +fn degenerate(p: &[Point; 4]) -> bool { + for (i, j, k) in [(0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3)] { + let area = ((p[j].x - p[i].x) * (p[k].y - p[i].y) - (p[j].y - p[i].y) * (p[k].x - p[i].x)).abs(); + let scale = p[i].dist(p[j]).max(p[i].dist(p[k])).max(1e-9); + if area < 1e-3 * scale * scale { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ransac_recovers_a_homography_with_outliers() { + let truth = Homography([1.02, 0.03, 12.0, -0.02, 0.99, -7.0, 1e-5, -2e-5, 1.0]); + let mut rng = Rng::new(5); + let mut a = Vec::new(); + let mut b = Vec::new(); + for i in 0..200 { + let p = Point::new(rng.unit() * 800.0, rng.unit() * 600.0); + let q = if i % 4 == 0 { Point::new(rng.unit() * 800.0, rng.unit() * 600.0) } else { truth.apply(p) }; + let q = Point::new(q.x + (rng.unit() - 0.5) * 0.5, q.y + (rng.unit() - 0.5) * 0.5); + a.push(p); + b.push(q); + } + let f = ransac(Model::Homography, &a, &b, 2.0, 20, 1).unwrap(); + assert!(f.inliers.len() >= 145, "{}", f.inliers.len()); + assert!(f.rms < 0.5); + for p in [Point::new(0.0, 0.0), Point::new(800.0, 600.0), Point::new(400.0, 100.0)] { + assert!(f.h.apply(p).dist(truth.apply(p)) < 0.5); + } + let t = ransac(Model::Translation, &a, &a.iter().map(|p| Point::new(p.x + 3.0, p.y - 1.0)).collect::>(), 1.0, 10, 2).unwrap(); + assert!((t.h.0[2] - 3.0).abs() < 1e-9 && (t.h.0[5] + 1.0).abs() < 1e-9); + let s = fit_model(Model::Similarity, &a[..10], &a[..10].iter().map(|p| Point::new(-p.y * 2.0 + 5.0, p.x * 2.0)).collect::>()).unwrap(); + assert!((s.0[0]).abs() < 1e-9 && (s.0[1] + 2.0).abs() < 1e-9 && (s.0[3] - 2.0).abs() < 1e-9); + } +} diff --git a/crates/merge/src/synth.rs b/crates/merge/src/synth.rs new file mode 100644 index 000000000..1d8fd8aa2 --- /dev/null +++ b/crates/merge/src/synth.rs @@ -0,0 +1,81 @@ +//! Synthetic merge inputs from procedural scenes (tests, demos, UI checks — no media files): +//! exposure brackets as linear DNGs and overlapping panorama views as PNGs. + +use lightcraft_geom::Orientation; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; + +use crate::frame::FrameColor; +use crate::linalg::{self, M3}; +use crate::output::{DngSamples, write_linear_dng}; + +fn scene(kind: lightcraft_scenes::Kind) -> lightcraft_scenes::Scene { + lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == kind).expect("scene kind in the demo library") +} + +fn srgb_color() -> FrameColor { + FrameColor::Linear { to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()) } +} + +/// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each +/// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels. +pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec> { + let m = 12usize; + let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m); + let mut l: Vec = truth.data.iter().map(|p| p[1]).collect(); + l.sort_by(|a, b| a.total_cmp(b)); + let s = 0.18 / l[l.len() / 2].max(1e-6); + let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32(); + evs.iter() + .enumerate() + .map(|(i, ev)| { + let k = 2f32.powf(*ev as f32) * s; + let (dx, dy) = ((i as f32 * 2.3) % 5.0 - 2.0, (i as f32 * 1.7) % 4.0 - 1.5); + let img = Rgb32f::from_fn(w, h, |x, y| { + let p = truth.sample_bilinear(x as f32 + 0.5 + m as f32 - dx, y as f32 + 0.5 + m as f32 - dy); + let q = [0, 1, 2].map(|r| to_srgb[r][0] * p[0] + to_srgb[r][1] * p[1] + to_srgb[r][2] * p[2]); + q.map(|v| (v * k).clamp(0.0, 1.0)) + }); + let meta = Metadata { + make: Some("LightCraft".into()), + model: Some("Synthetic Bracket".into()), + exposure_time: Some(2f64.powf(*ev) / 125.0), + f_number: Some(8.0), + iso: Some(100), + ..Default::default() + }; + write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16).expect("encode") + }) + .collect() +} + +/// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used +/// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB. +pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec { + let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840); + let mut l: Vec = env.data.iter().map(|p| p[1]).collect(); + l.sort_by(|a, b| a.total_cmp(b)); + let s = 0.25 / l[l.len() / 2].max(1e-6); + let (theta, phi) = (200.0f64, 70.0f64); + yaws.iter() + .enumerate() + .map(|(i, yaw)| { + let wob = (i as f64 * 1.3).sin(); + let ry = linalg::rodrigues([0.0, yaw.to_radians(), 0.0]); + let rx = linalg::rodrigues([(wob * 1.5).to_radians(), 0.0, 0.0]); + let rz = linalg::rodrigues([0.0, 0.0, (wob * 0.8).to_radians()]); + let c2w: M3 = linalg::mul3(&linalg::mul3(&ry, &rx), &rz); + Rgb32f::from_fn(w, h, |x, y| { + let d = linalg::apply3(&c2w, [(x as f64 + 0.5 - w as f64 / 2.0) / f, (y as f64 + 0.5 - h as f64 / 2.0) / f, 1.0]); + let t = d[0].atan2(d[2]).to_degrees(); + let p = (d[1] / linalg::norm(d)).asin().to_degrees(); + if t.abs() > theta / 2.0 || p.abs() > phi / 2.0 { + return [0.0; 3]; + } + let px = (t / theta + 0.5) * env.width as f64; + let py = (p / phi + 0.5) * env.height as f64; + env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0)) + }) + }) + .collect() +} diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs new file mode 100644 index 000000000..68b9ba4b9 --- /dev/null +++ b/crates/merge/tests/hdr.rs @@ -0,0 +1,163 @@ +//! HDR merge on synthetic brackets: a procedural scene (scene-linear truth with real highlight +//! headroom) is "photographed" at several exposures with clipping, sensor noise, 14-bit +//! quantisation and small handheld shifts; the merge must recover the radiance. + +use lightcraft_merge::frame::Frame; +use lightcraft_merge::hdr::{Deghost, HdrOptions, merge_hdr}; +use lightcraft_merge::no_progress; +use lightcraft_raster::Rgb32f; + +const W: usize = 640; +const H: usize = 420; +const M: usize = 16; + +struct Rng(u64); +impl Rng { + fn unit(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + fn gauss(&mut self) -> f32 { + let (a, b) = (self.unit().max(1e-12), self.unit()); + ((-2.0 * a.ln()).sqrt() * (std::f64::consts::TAU * b).cos()) as f32 + } +} + +/// Scene radiance (with margin), scaled so the median is ≈ 0.18 at exposure 1. +fn truth() -> Rgb32f { + let scene = lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == lightcraft_scenes::Kind::OceanSunset).unwrap(); + let img = scene.render(W + 2 * M, H + 2 * M); + let mut l: Vec = img.data.iter().map(|p| p[1]).collect(); + l.sort_by(|a, b| a.total_cmp(b)); + let s = 0.18 / l[l.len() / 2].max(1e-6); + img.map(|p| p.map(|v| v * s)) +} + +/// A disk of radiance `val` centred at (cx, cy), radius r (truth coordinates). +fn with_object(t: &Rgb32f, cx: f32, cy: f32, r: f32, val: [f32; 3]) -> Rgb32f { + Rgb32f::from_fn(t.width, t.height, |x, y| { + let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + if d < r { val } else { t.get(x, y) } + }) +} + +/// Photograph `t` at exposure `k`, content shifted by (dx, dy). +fn shoot(t: &Rgb32f, k: f32, dx: f32, dy: f32, seed: u64) -> Frame { + let mut rng = Rng(seed * 0x9e37_79b9 + 7); + let mut img = Rgb32f::from_fn(W, H, |x, y| t.sample_bilinear(x as f32 + 0.5 + M as f32 - dx, y as f32 + 0.5 + M as f32 - dy).map(|v| v * k)); + // shot + read noise, clipping at the white level, 14-bit quantisation + for p in img.data.iter_mut() { + for c in 0..3 { + let s = p[c].max(0.0); + let sigma = (1e-4 * s + 4e-8).sqrt(); + p[c] = ((s + sigma * rng.gauss()).clamp(0.0, 1.0) * 16383.0).round() / 16383.0; + } + } + let mut f = Frame::from_linear_rec2020(img, Some((k as f64).log2())); + f.raw = true; + f.clip = 1.0; + f +} + +fn log_err(a: [f32; 3], b: [f32; 3]) -> f32 { + (0..3).map(|c| ((a[c] + 0.003).ln() - (b[c] + 0.003).ln()).abs()).fold(0.0, f32::max) +} + +fn pct(mut v: Vec, p: f32) -> f32 { + v.sort_by(|a, b| a.total_cmp(b)); + v[((v.len() - 1) as f32 * p) as usize] +} + +#[test] +fn bracket_merge_recovers_radiance_and_alignment() { + let t = truth(); + let shifts = [(3.25f32, -2.0f32), (0.0, 0.0), (-4.0, 1.5)]; + let ks = [0.25f32, 1.0, 4.0]; + let frames: Vec = (0..3).map(|i| shoot(&t, ks[i], shifts[i].0, shifts[i].1, i as u64 + 1)).collect(); + let t0 = std::time::Instant::now(); + let r = merge_hdr(frames, &HdrOptions { align: true, deghost: Deghost::None }, &no_progress).unwrap(); + eprintln!("merge {W}x{H}: {:?}", t0.elapsed()); + assert_eq!(r.reference, 1); + // exposure estimates within 0.02 EV of the truth + for (i, k) in ks.iter().enumerate() { + assert!((r.ev[i] - (*k as f64).log2()).abs() < 0.02, "ev {:?}", r.ev); + } + // alignment: the reference → frame mapping is the content shift + for i in [0, 2] { + let a = &r.alignments[i]; + for (x, y) in [(50.0, 50.0), (W as f64 - 50.0, H as f64 - 50.0), (320.0, 200.0)] { + let p = a.h.apply(lightcraft_geom::Point::new(x, y)); + let err = ((p.x - x - shifts[i].0 as f64).powi(2) + (p.y - y - shifts[i].1 as f64).powi(2)).sqrt(); + assert!(err < 0.3, "frame {i} ({}, {} inliers, rms {:.3}): alignment error {err:.3} px", a.model, a.inliers, a.rms); + } + } + // radiance vs truth (reference geometry), away from the borders, inside the bracket's range + let mut errs = Vec::new(); + let mut recovered = Vec::new(); + let mut bias = 0f64; + for y in 12..H - 12 { + for x in 12..W - 12 { + let want = t.get(x + M, y + M); + let m = want[0].max(want[1]).max(want[2]); + if !(0.002..3.5).contains(&m) { + continue; + } + let e = log_err(r.radiance.get(x, y), want); + let g = r.radiance.get(x, y)[1]; + bias += ((g + 0.003).ln() - (want[1] + 0.003).ln()) as f64; + errs.push(e); + if m > 1.2 { + recovered.push(e); + } + } + } + let n_err = errs.len(); + eprintln!("mean signed log error (green): {:.4}", bias / n_err as f64); + let (med, p95) = (pct(errs.clone(), 0.5), pct(errs, 0.95)); + eprintln!("log error: median {med:.4}, p95 {p95:.4}; highlights recovered: {} px", recovered.len()); + // noise-limited (the error is the worst of three channels); no bias + assert!(med < 0.03 && p95 < 0.1, "median {med}, p95 {p95}"); + assert!((bias / n_err as f64).abs() < 0.01); + assert!(recovered.len() > 200, "the scene should have highlights above the reference's clip"); + let rmed = pct(recovered, 0.5); + assert!(rmed < 0.05, "recovered highlights: median log error {rmed}"); +} + +#[test] +fn deghost_removes_a_moving_object() { + let t = truth(); + let ks = [0.25f32, 1.0, 4.0]; + // a dark object at a different place in each frame (truth coordinates) + let pos = [(200.0f32, 260.0f32), (330.0, 260.0), (460.0, 260.0)]; + let frames = |_: ()| -> Vec { + (0..3).map(|i| shoot(&with_object(&t, pos[i].0, pos[i].1, 28.0, [0.01, 0.012, 0.02]), ks[i], 0.0, 0.0, 10 + i as u64)).collect() + }; + let ref_truth = with_object(&t, pos[1].0, pos[1].1, 28.0, [0.01, 0.012, 0.02]); + let err_at = |img: &Rgb32f, cx: f32, cy: f32| -> f32 { + let mut v = Vec::new(); + for y in (cy - 20.0) as usize..(cy + 20.0) as usize { + for x in (cx - 20.0) as usize..(cx + 20.0) as usize { + let (tx, ty) = (x - M, y - M); + v.push(log_err(img.get(tx, ty), ref_truth.get(x, y))); + } + } + v.iter().sum::() / v.len() as f32 + }; + let none = merge_hdr(frames(()), &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + let high = merge_hdr(frames(()), &HdrOptions { align: false, deghost: Deghost::High }, &no_progress).unwrap(); + let med = merge_hdr(frames(()), &HdrOptions { align: false, deghost: Deghost::Medium }, &no_progress).unwrap(); + for (cx, cy) in [pos[0], pos[2]] { + let (en, eh, em) = (err_at(&none.radiance, cx, cy), err_at(&high.radiance, cx, cy), err_at(&med.radiance, cx, cy)); + eprintln!("ghost at ({cx},{cy}): none {en:.3}, medium {em:.3}, high {eh:.3}"); + assert!(en > 0.15, "without deghosting the object leaves a ghost ({en})"); + assert!(eh < 0.08 && em < 0.12, "deghosting removes it (high {eh}, medium {em})"); + // the overlay marks the ghost + let g = high.ghost.get((cx - M as f32) as usize, (cy - M as f32) as usize); + assert!(g > 0.5, "overlay at the ghost: {g}"); + } + assert!(none.ghost.data.is_empty()); + let mean = high.ghost.data.iter().sum::() / high.ghost.data.len() as f32; + assert!(mean < 0.08, "the overlay is local (mean {mean})"); +} diff --git a/crates/merge/tests/pano.rs b/crates/merge/tests/pano.rs new file mode 100644 index 000000000..402037ca0 --- /dev/null +++ b/crates/merge/tests/pano.rs @@ -0,0 +1,247 @@ +//! Panorama stitching on synthetic input: a procedural scene is used as a spherical environment +//! (longitude/latitude map) and photographed by pinhole cameras with known rotations and focal +//! length. The stitch must recover the geometry and reproduce the environment without seams. + +use lightcraft_merge::frame::Frame; +use lightcraft_merge::linalg::{self, M3}; +use lightcraft_merge::no_progress; +use lightcraft_merge::pano::{PanoOptions, Projection, stitch}; +use lightcraft_raster::Rgb32f; + +const THETA: f64 = 200.0; // degrees of longitude covered by the environment map +const PHI: f64 = 70.0; + +fn env() -> Rgb32f { + let scene = lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == lightcraft_scenes::Kind::Canyon).unwrap(); + let img = scene.render(2400, 840); + let mut l: Vec = img.data.iter().map(|p| p[1]).collect(); + l.sort_by(|a, b| a.total_cmp(b)); + let s = 0.25 / l[l.len() / 2].max(1e-6); + img.map(|p| p.map(|v| (v * s).min(0.98))) +} + +fn sample_env(e: &Rgb32f, d: [f64; 3]) -> Option<[f32; 3]> { + let n = linalg::norm(d); + let t = d[0].atan2(d[2]).to_degrees(); + let p = (d[1] / n).asin().to_degrees(); + if t.abs() > THETA / 2.0 || p.abs() > PHI / 2.0 { + return None; + } + let x = (t / THETA + 0.5) * e.width as f64; + let y = (p / PHI + 0.5) * e.height as f64; + Some(e.sample_bilinear(x as f32, y as f32)) +} + +fn rot(yaw: f64, pitch: f64, roll: f64) -> M3 { + // camera-to-world = Ry(yaw) · Rx(pitch) · Rz(roll); world→camera is its transpose + let ry = linalg::rodrigues([0.0, yaw.to_radians(), 0.0]); + let rx = linalg::rodrigues([pitch.to_radians(), 0.0, 0.0]); + let rz = linalg::rodrigues([0.0, 0.0, roll.to_radians()]); + linalg::transpose3(&linalg::mul3(&linalg::mul3(&ry, &rx), &rz)) +} + +fn shoot(e: &Rgb32f, r: &M3, f: f64, w: usize, h: usize, gain: f32) -> Frame { + let rt = linalg::transpose3(r); + let img = Rgb32f::from_fn(w, h, |x, y| { + let d = [(x as f64 + 0.5 - w as f64 / 2.0) / f, (y as f64 + 0.5 - h as f64 / 2.0) / f, 1.0]; + sample_env(e, linalg::apply3(&rt, d)).map(|p| p.map(|v| v * gain)).unwrap_or([0.0; 3]) + }); + Frame::from_linear_rec2020(img, None) +} + +fn angle_between(a: &M3, b: &M3) -> f64 { + let d = linalg::mul3(a, &linalg::transpose3(b)); + linalg::norm(linalg::rotation_vector(&d)).to_degrees() +} + +struct Shot { + yaw: f64, + pitch: f64, + roll: f64, + gain: f32, +} + +fn run(shots: &[Shot], f: f64, opts: &PanoOptions) -> (lightcraft_merge::pano::PanoResult, Vec, Rgb32f) { + let e = env(); + let rs: Vec = shots.iter().map(|s| rot(s.yaw, s.pitch, s.roll)).collect(); + let frames: Vec = shots.iter().zip(&rs).map(|(s, r)| shoot(&e, r, f, 800, 600, s.gain)).collect(); + let t0 = std::time::Instant::now(); + let res = stitch(frames, opts, &no_progress).unwrap(); + eprintln!( + "stitch {} frames → {}×{} ({:?}) in {:?}; BA rms {:.3} → {:.3} px; fov {:.0}°×{:.0}°", + shots.len(), + res.image.width, + res.image.height, + res.projection, + t0.elapsed(), + res.rms_before, + res.rms, + res.fov.0, + res.fov.1 + ); + (res, rs, e) +} + +/// Check the recovered geometry and compare the panorama with the environment rendered through the +/// recovered projection; returns (median, p99) of the log error over covered pixels. +fn check(res: &lightcraft_merge::pano::PanoResult, rs: &[M3], e: &Rgb32f, f: f64) -> (f32, f32, f32) { + let cams: Vec<_> = res.cameras.iter().map(|c| c.expect("all frames used")).collect(); + for c in &cams { + assert!((c.f - f).abs() / f < 0.01, "focal {} vs {f}", c.f); + } + for i in 0..cams.len() { + for j in i + 1..cams.len() { + let truth = linalg::mul3(&rs[i], &linalg::transpose3(&rs[j])); + let got = linalg::mul3(&cams[i].r, &linalg::transpose3(&cams[j].r)); + let err = angle_between(&truth, &got); + assert!(err < 0.1, "relative rotation {i}-{j} off by {err:.3}°"); + } + } + // world alignment: recovered world → environment world + let mut acc = [[0.0; 3]; 3]; + for (c, r) in cams.iter().zip(rs) { + let m = linalg::mul3(&linalg::transpose3(r), &c.r); + for a in 0..3 { + for b in 0..3 { + acc[a][b] += m[a][b]; + } + } + } + let wmap = linalg::orthonormalize(&acc); + let (w, h) = (res.image.width, res.image.height); + let scale = res.focal; + let (bx, by) = res.origin; + // the environment rendered through the recovered geometry (alpha 0 where it has no data) + let mut truth = Rgb32f::new(w, h); + let mut talpha = lightcraft_raster::Plane::new(w, h); + for y in 0..h { + for x in 0..w { + let d = res.projection.inverse(x as f64 + 0.5 + bx, y as f64 + 0.5 + by, scale); + if let Some(t) = sample_env(e, linalg::apply3(&wmap, d)) + && res.alpha.get(x, y) >= 1.0 + { + truth.set(x, y, t); + talpha.set(x, y, 1.0); + } + } + } + // global level (exposure compensation fixes relative, not absolute, exposure) + let mut lr: Vec = + (0..w * h).filter(|&k| talpha.data[k] > 0.0).map(|k| (res.image.data[k][1] + 0.01).ln() - (truth.data[k][1] + 0.01).ln()).collect(); + lr.sort_by(|a, b| a.total_cmp(b)); + let offset = lr[lr.len() / 2]; + let mut errs = Vec::new(); + let mut emap = Rgb32f::new(w, h); + for k in 0..w * h { + if talpha.data[k] == 0.0 { + continue; + } + let (g, t) = (res.image.data[k], truth.data[k]); + let er = (0..3).map(|c| ((g[c] + 0.01).ln() - offset - (t[c] + 0.01).ln()).abs()).fold(0.0, f32::max); + errs.push(er); + emap.data[k] = [er * 5.0, 1.0 - er * 5.0, 0.0]; + } + if let Ok(dir) = std::env::var("LC_MERGE_DUMP") { + let tag = format!("{:?}-{}", res.projection, w); + ppm(&format!("{dir}/{tag}.ppm"), &res.image); + ppm(&format!("{dir}/{tag}-err.ppm"), &emap); + } + // seams: the column-to-column change of mean log luminance must follow the truth's + let steps = column_steps(&res.image, &talpha); + let tsteps = column_steps(&truth, &talpha); + // summed over 5 columns: resampling legitimately spreads a sharp scene edge over neighbours + let win = |v: &[Option], x: usize| -> Option { (x..x + 5).map(|k| v.get(k).copied().flatten()).sum() }; + let seam = (0..steps.len().saturating_sub(5)).filter_map(|x| Some((win(&steps, x)? - win(&tsteps, x)?).abs())).fold(0.0, f32::max); + errs.sort_by(|a, b| a.total_cmp(b)); + (errs[errs.len() / 2], errs[errs.len() * 99 / 100], seam) +} + +fn ppm(path: &str, img: &Rgb32f) { + let mut b = format!("P6 {} {} 255\n", img.width, img.height).into_bytes(); + for p in &img.data { + for c in 0..3 { + b.push((p[c].clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0) as u8); + } + } + std::fs::write(path, b).unwrap(); +} + +/// Per column: mean change of log luminance to the next column (where both are covered). +fn column_steps(img: &Rgb32f, alpha: &lightcraft_raster::Plane) -> Vec> { + let (w, h) = (img.width, img.height); + (0..w - 1) + .map(|x| { + let mut s = 0.0; + let mut n = 0; + for y in 0..h { + if alpha.get(x, y) >= 1.0 && alpha.get(x + 1, y) >= 1.0 { + s += (img.get(x + 1, y)[1] + 0.01).ln() - (img.get(x, y)[1] + 0.01).ln(); + n += 1; + } + } + (n > h / 3).then(|| s / n as f32) + }) + .collect() +} + +#[test] +fn spherical_panorama_recovers_geometry_without_seams() { + let shots = [ + Shot { yaw: -60.0, pitch: 1.5, roll: 0.8, gain: 1.0 }, + Shot { yaw: -30.0, pitch: -1.0, roll: -0.5, gain: 1.0 }, + Shot { yaw: 0.0, pitch: 0.5, roll: 1.0, gain: 1.0 }, + Shot { yaw: 31.0, pitch: -1.5, roll: 0.0, gain: 1.3 }, // auto exposure drifted + Shot { yaw: 61.0, pitch: 1.0, roll: -1.0, gain: 1.0 }, + ]; + let f = 700.0; + let opts = PanoOptions { projection: Projection::Spherical, max_megapixels: 100.0, ..Default::default() }; + let (res, rs, e) = run(&shots, f, &opts); + assert_eq!(res.used.len(), 5); + assert!(res.rms < 0.5, "BA rms {}", res.rms); + assert!((res.fov.0 - 182.0).abs() < 8.0, "fov {:?}", res.fov); + // exposure compensation undid the 1.3× drift (relative to its neighbours) + let g = &res.gains; + let rel = g[3] / ((g[2] + g[4]) / 2.0); + assert!((rel - 1.0 / 1.3).abs() < 0.03, "gains {g:?}"); + let (med, p99, seam) = check(&res, &rs, &e, f); + eprintln!("log error vs environment: median {med:.4}, p99 {p99:.4}, seam step {seam:.4}"); + assert!(med < 0.02 && p99 < 0.15, "median {med}, p99 {p99}"); + // (an unblended 1.3× exposure seam would be a step of ln 1.3 ≈ 0.26) + assert!(seam < 0.05, "a column step of {seam} beyond the scene's suggests a seam"); +} + +#[test] +fn auto_projection_and_finishing() { + let shots = [ + Shot { yaw: -22.0, pitch: 0.5, roll: 0.3, gain: 1.0 }, + Shot { yaw: 0.0, pitch: -0.4, roll: 0.0, gain: 1.0 }, + Shot { yaw: 21.0, pitch: 0.8, roll: -0.4, gain: 1.0 }, + ]; + let f = 800.0; + let (res, rs, e) = run(&shots, f, &PanoOptions { projection: Projection::Auto, auto_crop: true, max_megapixels: 100.0, ..Default::default() }); + assert_eq!(res.projection, Projection::Perspective, "a ~90° set is shown rectilinear"); + let (med, p99, seam) = check(&res, &rs, &e, f); + eprintln!("perspective: median {med:.4}, p99 {p99:.4}, seam step {seam:.4}"); + assert!(med < 0.02 && p99 < 0.1 && seam < 0.05); + let c = res.crop.expect("auto crop"); + assert!(c[2] * c[3] > 0.6, "crop {c:?}"); + let (w, h) = (res.image.width as f64, res.image.height as f64); + for y in (c[1] * h) as usize..((c[1] + c[3]) * h) as usize { + for x in (c[0] * w) as usize..((c[0] + c[2]) * w) as usize { + assert!(res.alpha.get(x, y) >= 0.999, "crop must be inside the image data"); + } + } + // boundary warp fills the canvas; cylindrical also works + let (warped, _, _) = + run(&shots, f, &PanoOptions { projection: Projection::Cylindrical, boundary_warp: 100.0, fill_edges: true, ..Default::default() }); + let cover = warped.alpha.data.iter().filter(|a| **a >= 0.5).count() as f64 / warped.alpha.data.len() as f64; + assert!(cover > 0.97, "boundary warp coverage {cover}"); + assert!(warped.image.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); +} + +#[test] +fn unrelated_photos_do_not_stitch() { + let a = lightcraft_scenes::demo_library(); + let frames: Vec = a.iter().take(2).map(|s| Frame::from_linear_rec2020(s.render(400, 300), None)).collect(); + assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err()); +} diff --git a/crates/pipeline/src/colorops.rs b/crates/pipeline/src/colorops.rs index 5d378d19a..987afa285 100644 --- a/crates/pipeline/src/colorops.rs +++ b/crates/pipeline/src/colorops.rs @@ -1,12 +1,12 @@ //! Colour tools in OkLCh on display-linear Rec.2020 values: vibrance, saturation, the 8-band colour -//! mixer, B&W mix, and 3-way colour grading. +//! mixer, B&W mix, and 3-way colour grading; plus the camera-calibration matrix (scene linear). use std::f32::consts::{PI, TAU}; use std::sync::OnceLock; use lightcraft_color::perceptual::{hsv_to_rgb, lab_to_lch, lch_to_lab, oklab_from_2020, oklab_to_2020}; -use lightcraft_color::{REC2020, SRGB}; -use lightcraft_develop::{DevelopSettings, MIXER_HUES}; +use lightcraft_color::{Mat3, REC2020, SRGB}; +use lightcraft_develop::{Calibration, DevelopSettings, MIXER_HUES, PointColor}; /// OkLCh hue angle (radians) of a pure sRGB colour with HSV hue `deg`. pub fn oklch_hue_of_srgb_hue(deg: f64) -> f32 { @@ -56,6 +56,78 @@ pub struct WheelK { pub lum: f32, } +/// Number of `f32`s per Point Color sample in [`PointK::words`] (the GPU parameter layout). +pub const POINT_WORDS: usize = 10; + +/// A Point Color sample resolved for the per-pixel stage: the sample (OkLab lightness, chroma, hue +/// in radians), the adjustment (hue rotation in radians, chroma scale − 1, lightness shift, +/// variance factor − 1) and the half-widths of the range box (hue radians, chroma, lightness). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointK { + pub l: f32, + pub c: f32, + pub h: f32, + pub dh: f32, + pub sat: f32, + pub dl: f32, + pub var: f32, + pub wh: f32, + pub wc: f32, + pub wl: f32, +} + +impl PointK { + pub fn new(p: &PointColor) -> PointK { + let k = (p.range.clamp(0.0, 100.0) / 50.0).max(0.05) as f32; + let f = |v: f64| (v.clamp(0.0, 100.0) / 100.0) as f32; + PointK { + l: p.lum as f32, + c: p.chroma.max(0.0) as f32, + h: (p.hue as f32).to_radians(), + dh: (p.hue_shift.clamp(-100.0, 100.0) / 100.0) as f32 * 0.5, + sat: (p.sat_shift.clamp(-100.0, 100.0) / 100.0) as f32, + dl: (p.lum_shift.clamp(-100.0, 100.0) / 100.0) as f32 * 0.2, + var: (p.variance.clamp(-100.0, 100.0) / 100.0) as f32 * 0.8, + wh: (0.1 + 0.6 * f(p.hue_range)) * k, + wc: (0.02 + 0.12 * f(p.sat_range)) * k, + wl: (0.05 + 0.4 * f(p.lum_range)) * k, + } + } + + pub fn words(&self) -> [f32; POINT_WORDS] { + [self.l, self.c, self.h, self.dh, self.sat, self.dl, self.var, self.wh, self.wc, self.wl] + } + + /// How much an OkLCh colour belongs to this sample's range (0..1, soft edges). + #[inline] + pub fn weight(&self, l: f32, c: f32, h: f32) -> f32 { + // hue is meaningless for near-neutral colours: a chromatic sample ignores them, a + // near-neutral sample selects by chroma and lightness only + let wh = if self.c < 0.02 { 1.0 } else { (1.0 - smooth(0.5 * self.wh, self.wh, wrap(h - self.h).abs())) * smooth(0.005, 0.025, c) }; + if wh <= 0.0 { + return 0.0; + } + let wc = 1.0 - smooth(0.5 * self.wc, self.wc, (c - self.c).abs()); + let wl = 1.0 - smooth(0.5 * self.wl, self.wl, (l - self.l).abs()); + wh * wc * wl + } + + /// Apply this sample's adjustment to an OkLCh colour. + #[inline] + pub fn apply(&self, lch: [f32; 3]) -> [f32; 3] { + let [mut l, mut c, mut h] = lch; + let w = self.weight(l, c, h); + if w <= 0.0 { + return lch; + } + h += w * (self.var * wrap(h - self.h) + self.dh); + c += w * self.var * (c - self.c); + c = (c * (1.0 + w * self.sat)).max(0.0); + l += w * (self.var * (l - self.l) + self.dl); + [l, c, h] + } +} + /// The colour tools' parameters, resolved once per render (fields are read by the GPU kernel). #[derive(Clone, Debug)] pub struct ColorOps { @@ -65,6 +137,8 @@ pub struct ColorOps { pub sat: [f32; 8], pub lum: [f32; 8], pub mixer: bool, + /// Point Color samples that change something (applied after the mixer). + pub points: Vec, pub bw: Option<[f32; 8]>, /// Wheels (shadows, midtones, highlights, global), blending, balance. pub grading: Option<([WheelK; 4], f32, f32)>, @@ -89,6 +163,11 @@ impl ColorOps { sat: bands.map(|b| (b.sat / 100.0) as f32), lum: bands.map(|b| (b.lum / 100.0) as f32 * 0.18), mixer: !s.mixer.is_neutral(), + points: if crate::is_bw(s) { + Vec::new() + } else { + s.point_colors.iter().take(lightcraft_develop::MAX_POINT_COLORS).filter(|p| !p.is_neutral()).map(PointK::new).collect() + }, bw: crate::is_bw(s).then(|| s.bw_mix.bands().map(|v| (v / 100.0) as f32)), grading: (!g.is_neutral()).then(|| { ( @@ -102,7 +181,7 @@ impl ColorOps { } pub fn is_identity(&self) -> bool { - self.vibrance == 0.0 && self.saturation == 0.0 && !self.mixer && self.bw.is_none() && self.grading.is_none() + self.vibrance == 0.0 && self.saturation == 0.0 && !self.mixer && self.points.is_empty() && self.bw.is_none() && self.grading.is_none() } /// `local_sat` (−1..1) and `local_hue` (radians) come from masks. @@ -126,6 +205,9 @@ impl ColorOps { c *= (1.0 + ds).max(0.0); l += dl * chroma_w * l.max(0.05).sqrt(); } + for p in &self.points { + [l, c, h] = p.apply([l, c, h]); + } if self.vibrance != 0.0 { let low = 1.0 - (c / 0.22).clamp(0.0, 1.0); let skin = if self.vibrance > 0.0 { 1.0 - 0.6 * (-(wrap(h - self.skin) / 0.35).powi(2)).exp() } else { 1.0 }; @@ -158,6 +240,57 @@ impl ColorOps { } } +/// Hue rotation (OkLCh radians) of a calibration primary at ±100. +pub const CALIB_HUE: f32 = 0.5; +/// Chroma scale of a calibration primary at ±100 (`1 ± CALIB_SAT`). +pub const CALIB_SAT: f32 = 0.6; + +/// The calibration panel's primaries as a white-preserving 3×3 matrix on linear Rec.2020 (row-major): +/// each primary is rotated in OkLCh hue and scaled in chroma at constant OkLab lightness, then the +/// columns are rescaled so that neutral (1, 1, 1) maps to itself. `None` when neutral. +pub fn calibration_matrix(c: &Calibration) -> Option<[[f32; 3]; 3]> { + let prim = c.primaries(); + if prim.iter().all(|(h, s)| *h == 0.0 && *s == 0.0) { + return None; + } + let mut p = [[0.0f64; 3]; 3]; + for (i, (hue, sat)) in prim.iter().enumerate() { + let mut e = [0.0f32; 3]; + e[i] = 1.0; + let [l, ch, h] = lab_to_lch(oklab_from_2020(e)); + let h2 = h + (hue.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_HUE; + let c2 = ch * (1.0 + (sat.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_SAT); + let q = oklab_to_2020(lch_to_lab([l, c2, h2])); + for (r, row) in p.iter_mut().enumerate() { + row[i] = q[r] as f64; + } + } + let m = Mat3(p); + let k = m.inverse()?.apply([1.0, 1.0, 1.0]); + Some(std::array::from_fn(|r| std::array::from_fn(|col| (p[r][col] * k[col]) as f32))) +} + +/// Shadows-tint strength at ±100: the green channel's relative change in deep shadows. +pub const SHADOW_TINT: f32 = 0.3; + +/// Calibration in scene-linear light (before tone mapping): the primaries matrix, then the shadows +/// tint (green ↔ magenta, weighted towards dark tones, luminance kept). +#[inline] +pub fn calibrate(c: [f32; 3], m: Option<&[[f32; 3]; 3]>, shadow_tint: f32) -> [f32; 3] { + let mut c = c; + if let Some(m) = m { + c = std::array::from_fn(|r| (m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2]).max(0.0)); + } + if shadow_tint != 0.0 { + let y0 = lightcraft_color::luminance_2020(c); + let w = 1.0 - smooth(-5.0, -0.5, (y0.max(1e-7) / 0.18).log2()); + c[1] *= (1.0 - SHADOW_TINT * shadow_tint * w).max(0.0); + let y1 = lightcraft_color::luminance_2020(c).max(1e-9); + c = c.map(|v| v * y0 / y1); + } + c +} + #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); @@ -219,6 +352,73 @@ mod tests { assert!((cr - cr0).abs() < 0.01); } + #[test] + fn calibration_keeps_white_and_moves_primaries() { + assert!(calibration_matrix(&Calibration::default()).is_none()); + let cal = Calibration { red_hue: 60.0, blue_sat: -50.0, ..Default::default() }; + let m = calibration_matrix(&cal).unwrap(); + let grey = calibrate([0.3, 0.3, 0.3], Some(&m), 0.0); + assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-4), "{grey:?}"); + // red rotates in hue, blue loses chroma + let red = [0.4, 0.05, 0.03]; + let h0 = lab_to_lch(oklab_from_2020(red))[2]; + let h1 = lab_to_lch(oklab_from_2020(calibrate(red, Some(&m), 0.0)))[2]; + assert!(h1 - h0 > 0.05, "{h0} -> {h1}"); + let blue = [0.03, 0.05, 0.4]; + let c0 = lab_to_lch(oklab_from_2020(blue))[1]; + let c1 = lab_to_lch(oklab_from_2020(calibrate(blue, Some(&m), 0.0)))[1]; + assert!(c1 < c0 * 0.97, "{c0} -> {c1}"); + } + + #[test] + fn shadow_tint_targets_shadows() { + let dark = calibrate([0.01, 0.01, 0.01], None, 1.0); + let bright = calibrate([0.8, 0.8, 0.8], None, 1.0); + assert!(dark[1] < dark[0] * 0.85, "magenta shadows: {dark:?}"); + assert!((bright[1] - bright[0]).abs() < 1e-3, "{bright:?}"); + let y = |c: [f32; 3]| lightcraft_color::luminance_2020(c); + assert!((y(dark) - 0.01).abs() < 1e-5); + let green = calibrate([0.01, 0.01, 0.01], None, -1.0); + assert!(green[1] > green[0]); + } + + #[test] + fn point_color_targets_its_range() { + let skin = [0.35, 0.16, 0.09]; + let [l, c, h] = lab_to_lch(oklab_from_2020(skin)); + let sample = + PointColor { lum: l as f64, chroma: c as f64, hue: (h as f64).to_degrees(), hue_shift: 60.0, sat_shift: -50.0, ..Default::default() }; + let mut s = DevelopSettings::default(); + s.point_colors.push(sample); + let ops = ColorOps::new(&s); + let out = lab_to_lch(oklab_from_2020(ops.apply(skin, 0.0, 0.0))); + assert!(out[1] < c * 0.7, "chroma {c} -> {}", out[1]); + assert!(wrap(out[2] - h) > 0.2, "hue {h} -> {}", out[2]); + // far colours are untouched + for far in [[0.02, 0.05, 0.4], [0.05, 0.3, 0.05], [0.3, 0.3, 0.3]] { + let o = ops.apply(far, 0.0, 0.0); + assert!(far.iter().zip(&o).all(|(a, b)| (a - b).abs() < 1e-4), "{far:?} -> {o:?}"); + } + // weight: 1 at the sample, falling to 0 outside the range; a wider range reaches further + let k = PointK::new(&sample); + assert!((k.weight(l, c, h) - 1.0).abs() < 1e-6); + assert_eq!(k.weight(l, c, h + 1.0), 0.0); + let wide = PointK::new(&PointColor { range: 100.0, ..sample }); + assert!(wide.weight(l, c, h + 0.45) > k.weight(l, c, h + 0.45)); + } + + #[test] + fn point_color_variance_compresses_towards_the_sample() { + let p = PointColor { lum: 0.6, chroma: 0.1, hue: 40.0, variance: -100.0, range: 100.0, ..Default::default() }; + let k = PointK::new(&p); + let (h0, near) = (40f32.to_radians(), 40f32.to_radians() + 0.1); + let o = k.apply([0.62, 0.11, near]); + assert!((o[2] - h0).abs() < 0.1 && (o[1] - 0.1).abs() < 0.01 && (o[0] - 0.6).abs() < 0.02, "{o:?}"); + let k = PointK::new(&PointColor { variance: 100.0, ..p }); + let o = k.apply([0.62, 0.11, near]); + assert!(o[2] - h0 > 0.1, "{o:?}"); + } + #[test] fn grading_tints_shadows_only() { let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 5a79a2ded..0ab6199e4 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -145,8 +145,13 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { pub struct FinishParams { pub tone: ToneMap, pub ops: ColorOps, + /// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1). + pub calib: Option<[[f32; 3]; 3]>, + pub shadow_tint: f32, /// Tone curves (parametric ∘ point, per channel) on encoded values, 1024 entries each. pub curves: Option<[Lut1; 3]>, + /// Refine Saturation as 0..1 (1 = the curves' own saturation). + pub refine_sat: f32, pub vig: Option, pub to_srgb: [[f32; 3]; 3], pub hl: f32, @@ -187,7 +192,10 @@ impl FinishParams { let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * px_per_long as f32; ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) }); + let calibration = s.section_enabled("calibration"); FinishParams { + calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None }, + shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 }, tone: if info.raw { ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) } else { @@ -195,6 +203,7 @@ impl FinishParams { }, ops: ColorOps::new(s), curves: curve_luts(&s.curve), + refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32, vig: if effects { vignette(s) } else { None }, to_srgb: REC2020.to_space(&SRGB).to_f32(), hl: (s.light.highlights / 100.0) as f32, @@ -330,6 +339,11 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So c = c.map(|v| v * g); } + // --- calibration (scene linear, before the tone map) + if fp.calib.is_some() || fp.shadow_tint != 0.0 { + c = crate::colorops::calibrate(c, fp.calib.as_ref(), fp.shadow_tint); + } + // --- tone map on luminance, highlight desaturation let yl = luminance_2020(c); let o = tone.apply(yl); @@ -397,7 +411,11 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So // --- encode, curves, grain let mut e = r.map(|v| encode_srgb(srgb, v)); if let Some(l) = curves { + let e0 = e; e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; + if fp.refine_sat < 1.0 { + e = refine_saturation(e0, e, fp.refine_sat); + } } if let Some((amt, cell, rough, seed)) = *grain { let n = out_to_norm.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); @@ -415,6 +433,23 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So out } +/// Refine Saturation: scale the curved colour's chroma (around its luma, encoded values) so its +/// saturation (chroma / luma) moves from the curve's towards the pre-curve one: the ratio is +/// `(s_curve / s_before)^refine`, so 1 keeps the curve, 0 restores the original saturation. +#[inline] +pub fn refine_saturation(before: [f32; 3], after: [f32; 3], refine: f32) -> [f32; 3] { + let luma = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + let chroma = |e: [f32; 3]| e[0].max(e[1]).max(e[2]) - e[0].min(e[1]).min(e[2]); + let (y0, y1) = (luma(before), luma(after)); + let s0 = chroma(before) / y0.max(1e-4); + let s1 = chroma(after) / y1.max(1e-4); + if s1 <= 1e-6 || s0 <= 1e-6 { + return after; + } + let k = (s0 / s1).powf(1.0 - refine).clamp(0.0, 4.0); + after.map(|v| y1 + (v - y1) * k) +} + pub const SRGB_LUT_N: usize = 4096; /// Linear → sRGB-encoded table (`SRGB_LUT_N + 1` entries over 0..1), interpolated linearly. @@ -449,6 +484,21 @@ fn enc(v: f32) -> u8 { mod tests { use super::*; + #[test] + fn refine_saturation_restores_the_pre_curve_saturation() { + let before = [0.5, 0.3, 0.2]; + let after = [0.7, 0.35, 0.15]; // a contrasty curve: more saturated + assert_eq!(refine_saturation(before, after, 1.0), after); + let r = refine_saturation(before, after, 0.0); + let sat = |e: [f32; 3]| (e[0] - e[2]) / (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]); + assert!((sat(r) - sat(before)).abs() < 1e-4, "{r:?}"); + let half = refine_saturation(before, after, 0.5); + assert!(sat(half) > sat(before) && sat(half) < sat(after)); + // luma is kept + let y = |e: [f32; 3]| 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + assert!((y(r) - y(after)).abs() < 1e-5); + } + #[test] fn srgb_table_matches_the_exact_curve() { let lut = srgb_lut(); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 17cfc6229..10e0f5151 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -30,10 +30,14 @@ pub mod local; pub mod masks; pub mod optics; pub mod profiles; +pub mod redeye; pub mod spots; pub mod tone; pub mod transform; pub mod upright; +pub mod visualize; + +pub use visualize::Overlay; use lightcraft_develop::{DevelopSettings, Treatment}; use std::any::Any; @@ -78,11 +82,13 @@ pub struct RenderRequest { pub quality: Quality, /// Show the crop frame's content only (true) or the whole uncropped image (crop tool active). pub apply_crop: bool, + /// A diagnostic view drawn over the result (e.g. Point Color's "visualize range"). + pub overlay: Overlay, } impl RenderRequest { pub fn fit(max_w: usize, max_h: usize) -> Self { - Self { max_w, max_h, quality: Quality::Full, apply_crop: true } + Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None } } } @@ -257,14 +263,17 @@ pub struct Plan<'a> { pub geo: u64, /// Key of the white-balanced, retouched, denoised image (and of the planes computed from it). pub lin_key: u64, + /// Red eye / pet eye corrections with their detected pupils, in output pixels. + pub eyes: Vec, } /// Resolve `s` against `src` for `req` (see [`Plan`]). pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &RenderRequest) -> Plan<'a> { - let settings: Cow<'a, DevelopSettings> = match profiles::effective(s) { + let mut settings: Cow<'a, DevelopSettings> = match profiles::effective(s) { Cow::Borrowed(b) => upright::resolve(src, info, b), Cow::Owned(o) => Cow::Owned(upright::resolve(src, info, &o).into_owned()), }; + visualize::adjust_settings(req.overlay, &mut settings); let s = &*settings; let frame = frame_for(src, info, s, req.apply_crop); let (w, h) = frame.fit(req.max_w, req.max_h); @@ -272,17 +281,19 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R let src_long = src.width.max(src.height); let geo = hash_of((format!("{frame:?}"), w, h)); let (wb_t, wb_tint) = local::effective_wb(info, s); + let eyes = redeye::resolve(src, &s.red_eye, s.orientation, &frame, w, h, px_per_long); let d = &s.detail; let lin_key = hash_of(( geo, [wb_t, wb_tint, info.as_shot_temp, info.as_shot_tint].map(f64::to_bits), format!("{:?}", s.spots), + format!("{eyes:?}"), // defringe runs in this stage format!("{:?}", s.optics), [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), src_long, )); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key } + Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, eyes } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -299,6 +310,7 @@ pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) { local::white_balance(img, info, s); optics::defringe(img, s, p.px_per_long / optics::DEFRINGE_REF_LONG); spots::apply(img, &s.spots, &p.frame, p.px_per_long); + redeye::apply(img, &p.eyes); } /// Render `src` with settings `s`. @@ -369,6 +381,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); + let mut image = image; + visualize::apply(&mut image, req.overlay, &plan); Rendered { image, histogram } } diff --git a/crates/pipeline/src/redeye.rs b/crates/pipeline/src/redeye.rs new file mode 100644 index 000000000..3bad1d634 --- /dev/null +++ b/crates/pipeline/src/redeye.rs @@ -0,0 +1,310 @@ +//! Red eye and pet eye correction, in scene-linear light after white balance. +//! +//! The user draws an ellipse over the eye; inside it the pupil is found automatically on the +//! source image (resolution independent: a fixed grid of samples in normalized coordinates): +//! +//! - **red eye:** red-dominant pixels (`(R − max(G, B)) / (R + G + B)`) — flash light reflected by +//! the retina; +//! - **pet eye:** the brightest pixels relative to the ellipse (the tapetum's glow, any colour). +//! +//! Their weighted centroid and area give the pupil disc, scaled by Pupil Size. Rendering (shared +//! by the CPU and the GPU kernel `redeye_k`): red eye neutralizes the red inside the disc (only +//! where it is red, so iris and catchlights stay) and darkens it; pet eye replaces the disc with a +//! dark pupil and can add a catchlight. + +use lightcraft_develop::RedEye; +use lightcraft_geom::{Orientation, Point}; +use lightcraft_raster::Rgb32f; + +use crate::for_rows; +use crate::geometry::Frame; + +/// `f32`s per eye in [`EyeK::words`] (the GPU parameter layout). +pub const EYE_WORDS: usize = 9; + +/// Samples per axis of the detection grid over the ellipse's bounding box. +const GRID: usize = 40; + +/// One eye resolved for rendering, in output pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EyeK { + pub cx: f32, + pub cy: f32, + /// Pupil radius. + pub r: f32, + /// Darken 0..1. + pub darken: f32, + pub pet: bool, + /// Catchlight disc (centre, radius), pet eye only. + pub catchlight: Option<(f32, f32, f32)>, +} + +impl EyeK { + pub fn words(&self) -> [f32; EYE_WORDS] { + let (lx, ly, lr) = self.catchlight.unwrap_or((0.0, 0.0, 0.0)); + [self.cx, self.cy, self.r, self.darken, self.pet as u8 as f32, self.catchlight.is_some() as u8 as f32, lx, ly, lr] + } +} + +/// A detected pupil: centre (normalized coordinates) and radius (long-edge units). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pupil { + pub center: Point, + pub radius: f64, + /// Whether anything was found (else: the ellipse centre and a default radius). + pub found: bool, +} + +/// Normalized point of the user-oriented image → normalized point of the source (inverse of +/// `Rgb32f::oriented`: undo the quarter turns, then the flip). +pub fn oriented_to_source(o: Orientation, p: Point) -> Point { + let (flip, turns) = o.to_parts(); + let (mut u, mut v) = (p.x, p.y); + for _ in 0..turns { + // a clockwise turn maps source (x, y) to (1 − y, x) + (u, v) = (v, 1.0 - u); + } + if flip { + u = 1.0 - u; + } + Point::new(u, v) +} + +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Find the pupil inside `eye`'s ellipse on `src` (the EXIF-oriented source; `o` = the user +/// orientation, `ow × oh` = the oriented image size in any unit). +pub fn detect(src: &Rgb32f, o: Orientation, ow: f64, oh: f64, eye: &RedEye) -> Pupil { + let long = ow.max(oh); + let (rx, ry) = (eye.rx.max(1e-4), eye.ry.max(1e-4)); + let fallback = Pupil { center: eye.center, radius: 0.5 * rx.min(ry), found: false }; + if src.width == 0 || src.height == 0 { + return fallback; + } + // samples inside the ellipse: (normalized point, colour) + let mut samples = Vec::with_capacity(GRID * GRID); + for j in 0..GRID { + for i in 0..GRID { + let (dx, dy) = ((i as f64 + 0.5) / GRID as f64 * 2.0 - 1.0, (j as f64 + 0.5) / GRID as f64 * 2.0 - 1.0); + if dx * dx + dy * dy > 1.0 { + continue; + } + let n = Point::new(eye.center.x + dx * rx * long / ow, eye.center.y + dy * ry * long / oh); + if !(0.0..1.0).contains(&n.x) || !(0.0..1.0).contains(&n.y) { + continue; + } + let s = oriented_to_source(o, n); + let (px, py) = ((s.x * src.width as f64) as usize, (s.y * src.height as f64) as usize); + samples.push((n, src.data[py.min(src.height - 1) * src.width + px.min(src.width - 1)])); + } + } + if samples.is_empty() { + return fallback; + } + let lum = |c: [f32; 3]| lightcraft_color::luminance_2020(c); + let weights: Vec = if eye.pet { + let ymax = samples.iter().map(|(_, c)| lum(*c)).fold(0.0f32, f32::max); + let mean = samples.iter().map(|(_, c)| lum(*c)).sum::() / samples.len() as f32; + if ymax <= 1e-6 || ymax < 1.5 * mean { + return fallback; + } + samples.iter().map(|(_, c)| smooth(0.5, 0.8, lum(*c) / ymax)).collect() + } else { + samples + .iter() + .map(|(_, c)| { + let s = (c[0] - c[1].max(c[2])) / (c[0] + c[1] + c[2] + 1e-6); + if c[0] > 1e-3 { smooth(0.22, 0.42, s) } else { 0.0 } + }) + .collect() + }; + let total: f32 = weights.iter().sum(); + if total < 3.0 { + return fallback; + } + let (mut x, mut y) = (0.0, 0.0); + for ((n, _), w) in samples.iter().zip(&weights) { + x += n.x * *w as f64; + y += n.y * *w as f64; + } + let frac = total as f64 / samples.len() as f64; + Pupil { center: Point::new(x / total as f64, y / total as f64), radius: (frac * rx * ry).sqrt(), found: true } +} + +/// Resolve the eyes for a `w × h` render of `frame` (`ppl` = output pixels per long-edge unit). +pub fn resolve(src: &Rgb32f, eyes: &[RedEye], orientation: Orientation, frame: &Frame, w: usize, h: usize, ppl: f64) -> Vec { + if eyes.is_empty() { + return Vec::new(); + } + let to_out = frame.norm_to_out(w, h); + eyes.iter() + .map(|e| { + let p = detect(src, orientation, frame.ow, frame.oh, e); + let r = (p.radius * (0.5 + e.pupil_size.clamp(0.0, 100.0) / 100.0)).min(e.rx.max(e.ry) * 1.1); + let c = to_out.apply(p.center); + let r_px = (r * ppl) as f32; + let catchlight = + e.catchlight.filter(|_| e.pet).map(|off| ((c.x + off.x * r * ppl) as f32, (c.y + off.y * r * ppl) as f32, (0.22 * r_px).max(0.5))); + EyeK { cx: c.x as f32, cy: c.y as f32, r: r_px.max(0.5), darken: (e.darken.clamp(0.0, 100.0) / 100.0) as f32, pet: e.pet, catchlight } + }) + .collect() +} + +/// One pixel (pixel centre `(x, y)` in output pixels) through every eye. +#[inline] +pub fn eye_pixel(c: [f32; 3], x: f32, y: f32, eyes: &[EyeK]) -> [f32; 3] { + let mut c = c; + for e in eyes { + let d = ((x - e.cx) * (x - e.cx) + (y - e.cy) * (y - e.cy)).sqrt() / e.r; + // red: generous disc (the red gate protects the iris); pet: the disc itself is replaced + let m = if e.pet { 1.0 - smooth(0.95, 1.2, d) } else { 1.0 - smooth(1.0, 1.35, d) }; + if m > 0.0 { + if e.pet { + let k = lightcraft_color::luminance_2020(c).min(0.04) * (1.0 - 0.85 * e.darken); + c = c.map(|v| v + (k - v) * m); + } else { + let red = (c[0] - c[1].max(c[2])) / c[0].max(1e-6); + let a = m * smooth(0.25, 0.5, red); + let n = 0.5 * (c[1] + c[2]) * (1.0 - 0.9 * e.darken); + c = c.map(|v| v + (n - v) * a); + } + } + if let Some((lx, ly, lr)) = e.catchlight { + let dc = ((x - lx) * (x - lx) + (y - ly) * (y - ly)).sqrt() / lr; + let mc = 1.0 - smooth(0.5, 1.0, dc); + if mc > 0.0 { + c = c.map(|v| v + (0.9 - v) * mc); + } + } + } + c +} + +/// Apply the eyes to `img` (output resolution), in place. +pub fn apply(img: &mut Rgb32f, eyes: &[EyeK]) { + if eyes.is_empty() { + return; + } + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + *px = eye_pixel(*px, x as f32 + 0.5, y as f32 + 0.5, eyes); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn orientation_inverse_matches_raster() { + let img = Rgb32f::from_fn(5, 3, |x, y| [x as f32, y as f32, 0.0]); + use Orientation::*; + for o in [Normal, Rotate90, Rotate180, Rotate270, FlipH, Transverse, FlipV, Transpose] { + let r = img.oriented(o); + for y in 0..r.height { + for x in 0..r.width { + let n = Point::new((x as f64 + 0.5) / r.width as f64, (y as f64 + 0.5) / r.height as f64); + let s = oriented_to_source(o, n); + let (sx, sy) = ((s.x * 5.0) as usize, (s.y * 3.0) as usize); + assert_eq!(r.get(x, y), img.get(sx, sy), "{o:?} at {x},{y}"); + } + } + } + } + + /// A skin-coloured patch with a red pupil of radius `pr` at (cx, cy) (pixels). + fn eye_image(w: usize, h: usize, cx: f32, cy: f32, pr: f32, pupil: [f32; 3]) -> Rgb32f { + Rgb32f::from_fn(w, h, |x, y| { + let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + if d < pr { + pupil + } else if d < pr * 2.0 { + [0.12, 0.1, 0.08] // iris + } else { + [0.45, 0.3, 0.22] + } + }) + } + + #[test] + fn detects_the_red_pupil_off_centre() { + let img = eye_image(200, 100, 90.0, 52.0, 6.0, [0.5, 0.04, 0.04]); + // ellipse centred off the pupil (long edge 200: rx 0.1 = 20 px) + let eye = RedEye { center: Point::new(0.48, 0.5), rx: 0.1, ry: 0.08, pupil_size: 50.0, darken: 50.0, pet: false, catchlight: None }; + let p = detect(&img, Orientation::Normal, 200.0, 100.0, &eye); + assert!(p.found); + assert!((p.center.x - 0.45).abs() < 0.01 && (p.center.y - 0.52).abs() < 0.02, "{p:?}"); + assert!((p.radius * 200.0 - 6.0).abs() < 1.5, "{}", p.radius * 200.0); + // nothing red: fallback + let plain = eye_image(200, 100, 90.0, 52.0, 6.0, [0.03, 0.03, 0.03]); + assert!(!detect(&plain, Orientation::Normal, 200.0, 100.0, &eye).found); + } + + #[test] + fn renders_fix_the_red_pupil_at_any_size() { + use crate::{RenderRequest, SourceInfo, render}; + let src = eye_image(400, 200, 180.0, 104.0, 12.0, [0.5, 0.04, 0.04]); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.red_eye.push(RedEye { center: Point::new(0.47, 0.5), rx: 0.1, ry: 0.08, ..Default::default() }); + let info = SourceInfo::default(); + for size in [400, 160] { + let img = render(&src, &info, &s, &RenderRequest::fit(size, size)).image; + let (x, y) = ((0.45 * img.width as f64) as usize, (0.52 * img.height as f64) as usize); + let p = img.data[y * img.width + x]; + assert!(p[0] < 90 && p[0].abs_diff(p[1]) < 12, "size {size}: pupil {p:?}"); + // skin outside the ellipse is untouched + let q = img.data[(img.height / 2) * img.width + img.width / 10]; + let plain = render(&src, &info, &Default::default(), &RenderRequest::fit(size, size)).image; + assert_eq!(q, plain.data[(img.height / 2) * img.width + img.width / 10]); + } + } + + #[test] + fn pet_eye_finds_the_glow_darkens_it_and_adds_a_catchlight() { + use crate::{RenderRequest, SourceInfo, render}; + // a green-yellow tapetum glow (any colour: pet eyes are found by brightness) + let src = eye_image(400, 200, 210.0, 96.0, 14.0, [0.5, 0.7, 0.3]); + let eye = RedEye { center: Point::new(0.52, 0.5), rx: 0.1, ry: 0.09, pet: true, ..Default::default() }; + let p = detect(&src, Orientation::Normal, 400.0, 200.0, &eye); + assert!(p.found && (p.center.x - 0.525).abs() < 0.01 && (p.center.y - 0.48).abs() < 0.02, "{p:?}"); + assert!((p.radius * 400.0 - 14.0).abs() < 3.0, "{}", p.radius * 400.0); + // a red pupil is not a glow candidate for red-eye detection when it's green + assert!(!detect(&src, Orientation::Normal, 400.0, 200.0, &RedEye { pet: false, ..eye }).found); + let info = SourceInfo::default(); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.red_eye.push(eye); + let img = render(&src, &info, &s, &RenderRequest::fit(400, 400)).image; + let at = |img: &lightcraft_raster::Rgba8, x: f64, y: f64| img.data[(y * 200.0) as usize * 400 + (x * 400.0) as usize]; + let c = at(&img, 0.525, 0.48); + assert!(c[1] < 70 && c[0].abs_diff(c[1]) < 4, "dark neutral pupil: {c:?}"); + // catchlight up-left of the centre + s.red_eye[0].catchlight = Some(Point::new(-0.35, -0.35)); + let img = render(&src, &info, &s, &RenderRequest::fit(400, 400)).image; + let r = 14.0 / 400.0; + let l = at(&img, 0.525 - 0.35 * r, 0.48 - 0.35 * r * 2.0); + assert!(l[0] > 200 && l[1] > 200 && l[2] > 200, "catchlight {l:?}"); + assert!(at(&img, 0.525 + 0.4 * r, 0.48 + 0.4 * r * 2.0)[1] < 70, "rest of the pupil stays dark"); + } + + #[test] + fn red_pupil_is_neutralized_and_darkened_iris_kept() { + let eyes = [EyeK { cx: 50.0, cy: 50.0, r: 10.0, darken: 0.5, pet: false, catchlight: None }]; + let red = eye_pixel([0.5, 0.04, 0.05], 50.0, 50.0, &eyes); + assert!((red[0] - red[1]).abs() < 0.01 && red[0] < 0.05, "{red:?}"); + let iris = [0.12, 0.1, 0.08]; + let o = eye_pixel(iris, 52.0, 50.0, &eyes); + assert!(o.iter().zip(&iris).all(|(a, b)| (a - b).abs() < 0.02), "not red: barely touched {o:?}"); + let far = eye_pixel([0.5, 0.04, 0.05], 80.0, 50.0, &eyes); + assert_eq!(far, [0.5, 0.04, 0.05]); + // darker with more Darken + let dark = eye_pixel([0.5, 0.2, 0.2], 50.0, 50.0, &[EyeK { darken: 1.0, ..eyes[0] }]); + let light = eye_pixel([0.5, 0.2, 0.2], 50.0, 50.0, &[EyeK { darken: 0.0, ..eyes[0] }]); + assert!(dark[1] < light[1]); + } +} diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 6a8c4e4a9..bfdfae378 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -177,7 +177,7 @@ fn stage_cache_matches_uncached_render_through_a_slider_session() { let info = SourceInfo { raw: true, ..Default::default() }; let cache = StageCache::default(); let full = RenderRequest::fit(200, 200); - let draft = RenderRequest { max_w: 120, max_h: 120, quality: Quality::Draft, apply_crop: true }; + let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(120, 120) }; let mut s = typical_edits(); // every kind of edit: tone, exposure, spatial amounts, NR, WB, crop, colour — at two sizes let steps: Vec> = vec![ @@ -235,3 +235,55 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() { let d = max_diff(&a, &b); assert!(d <= 2, "max difference {d}"); } + +#[test] +fn calibration_shifts_colours_but_keeps_greys() { + let info = SourceInfo { raw: true, ..Default::default() }; + let req = RenderRequest::fit(64, 8); + // a grey ramp is untouched by the primaries + let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]); + let mut s = DevelopSettings::default(); + s.calibration.red_hue = 80.0; + s.calibration.green_sat = -60.0; + let a = render(&grey, &info, &DevelopSettings::default(), &req).image; + let b = render(&grey, &info, &s, &req).image; + assert!(max_diff(&a, &b) <= 1); + // a red patch rotates towards yellow (green rises relative to blue) + let red = Rgb32f::filled(16, 16, [0.3, 0.04, 0.03]); + let r0 = render(&red, &info, &DevelopSettings::default(), &RenderRequest::fit(16, 16)).image.data[0]; + let r1 = render(&red, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(r1[1] as i32 - r1[2] as i32 > r0[1] as i32 - r0[2] as i32 + 5, "{r0:?} -> {r1:?}"); + // shadows tint: magenta (+) lowers green in a dark grey + let dark = Rgb32f::filled(16, 16, [0.01, 0.01, 0.01]); + s = DevelopSettings::default(); + s.calibration.shadows_tint = 100.0; + let d = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(d[1] + 3 < d[0], "{d:?}"); + // the section toggle disables it + s.set_section_enabled("calibration", false); + let off = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(off[1].abs_diff(off[0]) <= 1, "{off:?}"); +} + +#[test] +fn refine_saturation_tames_a_contrast_curve() { + let info = SourceInfo { raw: true, ..Default::default() }; + let src = Rgb32f::filled(16, 16, [0.12, 0.05, 0.03]); + let req = RenderRequest::fit(16, 16); + let sat = |p: [u8; 4]| p[0] as i32 - p[2] as i32; + let mut s = DevelopSettings::default(); + let flat = render(&src, &info, &s, &req).image.data[0]; + s.curve.master = vec![ + lightcraft_geom::Point::new(0.0, 0.0), + lightcraft_geom::Point::new(0.3, 0.15), + lightcraft_geom::Point::new(0.7, 0.85), + lightcraft_geom::Point::new(1.0, 1.0), + ]; + let full = render(&src, &info, &s, &req).image.data[0]; + s.curve.refine_saturation = 0.0; + let refined = render(&src, &info, &s, &req).image.data[0]; + assert!(sat(full) > sat(refined), "{flat:?} {full:?} {refined:?}"); + // the curve's tone change stays + let y = |p: [u8; 4]| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32; + assert!((y(full) - y(refined)).abs() < 2.0); +} diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index f2e7c2370..99c8a1408 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -467,3 +467,23 @@ fn debug_upright_scene() { eprintln!("{m:?}: {:?}", crate::upright::solve(m, &a).0); } } + +/// Sign convention for Auto straighten: a horizon descending 6° to the right needs crop angle −6°, +/// which `level_degrees` must report (the crop panel uses it as the crop angle directly). +#[test] +fn level_degrees_sign_matches_crop_angle() { + let (w, h) = (900usize, 600usize); + let t = 6f32.to_radians().tan(); + let src = Rgb32f::from_fn(w, h, |x, y| { + let yh = h as f32 / 2.0 + (x as f32 - w as f32 / 2.0) * t; + if (y as f32) < yh { + [0.6, 0.7, 0.9] + } else if (((y as f32 - yh) / 30.0) as i32) % 2 == 0 { + [0.05, 0.1, 0.2] + } else { + [0.1, 0.2, 0.3] + } + }); + let deg = crate::upright::level_degrees(&src, &SourceInfo::default(), &DevelopSettings::default()).expect("lines found"); + assert!((deg + 6.0).abs() < 0.5, "{deg}"); +} diff --git a/crates/pipeline/src/upright.rs b/crates/pipeline/src/upright.rs index f6d250560..93dffad0c 100644 --- a/crates/pipeline/src/upright.rs +++ b/crates/pipeline/src/upright.rs @@ -569,6 +569,13 @@ pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> A a } +/// The rotation (degrees, clockwise +) that levels `src`'s dominant horizon/plumb lines, if they agree +/// (the consensus rule of Upright Level). Used by the crop panel's Auto straighten. +pub fn level_degrees(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option { + let a = analyze_source(src, info, s); + level_angle(&a.vertical, &a.horizontal) +} + /// The automatic Upright correction for `mode` on `src` (identity for Off and Guided). pub fn auto_transform(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, mode: Upright) -> Homography { if matches!(mode, Upright::Off | Upright::Guided) { diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs new file mode 100644 index 000000000..e8e47e062 --- /dev/null +++ b/crates/pipeline/src/visualize.rs @@ -0,0 +1,187 @@ +//! Diagnostic views drawn over a finished render ([`Overlay`]), shared by the CPU and GPU renderers +//! (they run on the 8-bit result, after the histogram is taken). +//! +//! - **Point Color range:** the photo is rendered without that sample's own adjustment; pixels +//! inside the sample's range keep their colour, the rest turn grey (weighted by the range). +//! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white, +//! so dust spots and specks stand out as white dots. The scale is relative to the image (the +//! same spots show at any preview size); the threshold slider (0..100) raises sensitivity. + +use std::borrow::Cow; + +use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::Rgba8; + +use crate::colorops::PointK; +use crate::{Plan, for_rows}; + +/// A diagnostic view of a render. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum Overlay { + #[default] + None, + /// Point Color sample `i`: its range in colour, everything else grey. + PointColorRange(u8), + /// Visualize Spots with a threshold 0..100 (higher = more sensitive). + Spots(u8), +} + +impl Overlay { + /// A plain (kind, value) pair, e.g. for sending a request to a web worker. + pub fn to_parts(self) -> (u8, f32) { + match self { + Overlay::None => (0, 0.0), + Overlay::PointColorRange(i) => (1, i as f32), + Overlay::Spots(t) => (2, t as f32), + } + } + + /// Inverse of [`Overlay::to_parts`] (unknown kinds: no overlay). + pub fn from_parts(kind: u8, v: f32) -> Overlay { + match kind { + 1 => Overlay::PointColorRange(v as u8), + 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), + _ => Overlay::None, + } + } + + /// A stable value for render-cache keys (0 = no overlay). + pub fn key(self) -> u64 { + match self { + Overlay::None => 0, + Overlay::PointColorRange(i) => 0x1000 + i as u64, + Overlay::Spots(t) => 0x2000 + t as u64, + } + } +} + +/// Settings changes an overlay needs before rendering (e.g. the visualized sample's adjustment +/// is left out, so the range is shown on the colours it selects). +pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { + if let Overlay::PointColorRange(i) = o + && let Some(p) = s.point_colors.get(i as usize) + && !p.is_neutral() + { + let p = &mut s.to_mut().point_colors[i as usize]; + (p.hue_shift, p.sat_shift, p.lum_shift, p.variance) = (0.0, 0.0, 0.0, 0.0); + } +} + +/// Draw overlay `o` over `img` (rendered with `plan`). +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { + match o { + Overlay::None => {} + Overlay::PointColorRange(i) => { + if let Some(p) = plan.settings.point_colors.get(i as usize) { + point_color_range(img, &PointK::new(p)); + } + } + Overlay::Spots(t) => spots(img, t, plan.px_per_long), + } +} + +/// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. +const SPOT_SIGMA: f64 = 0.006; + +/// High-pass threshold (encoded luminance) for slider value `t` (0..100): 100 shows the faintest +/// specks, 0 only strong ones. +pub fn spot_threshold(t: u8) -> f32 { + let k = 1.0 - t.min(100) as f32 / 100.0; + 0.01 + 0.2 * k * k +} + +fn spots(img: &mut Rgba8, t: u8, ppl: f64) { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 { + return; + } + let l = lightcraft_raster::Plane::from_fn(w, h, |x, y| { + let p = img.data[y * w + x]; + (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) / 255.0 + }); + let b = lightcraft_raster::blur::gaussian(&l, (SPOT_SIGMA * ppl).max(0.8) as f32); + let thr = spot_threshold(t); + for (i, px) in img.data.iter_mut().enumerate() { + let v = if (l.data[i] - b.data[i]).abs() > thr { 255 } else { 0 }; + *px = [v, v, v, 255]; + } +} + +fn point_color_range(img: &mut Rgba8, k: &PointK) { + let m = SRGB.to_space(&REC2020).to_f32(); + let lut: Vec = (0..256).map(|v| srgb_to_linear(v as f32 / 255.0)).collect(); + let w = img.width; + for_rows(&mut img.data, w, |_, row| { + for px in row.iter_mut() { + let lin = [lut[px[0] as usize], lut[px[1] as usize], lut[px[2] as usize]]; + let c = std::array::from_fn(|r| m[r][0] * lin[0] + m[r][1] * lin[1] + m[r][2] * lin[2]); + let [l, ch, h] = lab_to_lch(oklab_from_2020(c)); + let a = k.weight(l, ch, h); + let grey = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32; + for c in 0..3 { + px[c] = (grey + (px[c] as f32 - grey) * a).round().clamp(0.0, 255.0) as u8; + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RenderRequest, SourceInfo, render}; + use lightcraft_develop::PointColor; + use lightcraft_raster::Rgb32f; + + #[test] + fn spots_view_marks_specks_at_any_size() { + // a smooth gradient with two small dark specks + let src = Rgb32f::from_fn(400, 300, |x, y| { + let d = |cx: f32, cy: f32| ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt(); + let base = 0.1 + 0.3 * x as f32 / 400.0; + if d(100.0, 100.0) < 2.5 || d(300.0, 200.0) < 2.5 { [base * 0.5; 3] } else { [base; 3] } + }); + let info = SourceInfo::default(); + let s = DevelopSettings::default(); + for size in [400, 200] { + let req = RenderRequest { overlay: Overlay::Spots(50), ..RenderRequest::fit(size, size) }; + let v = render(&src, &info, &s, &req).image; + let at = |x: f64, y: f64| v.data[(y * v.height as f64) as usize * v.width + (x * v.width as f64) as usize]; + assert_eq!(at(0.25, 1.0 / 3.0), [255, 255, 255, 255], "speck at size {size}"); + assert_eq!(at(0.75, 2.0 / 3.0), [255, 255, 255, 255]); + assert_eq!(at(0.5, 0.5), [0, 0, 0, 255], "smooth gradient is black"); + let white = v.data.iter().filter(|p| p[0] == 255).count() as f64 / v.data.len() as f64; + assert!(white < 0.01, "{white}"); + } + assert!(spot_threshold(0) > spot_threshold(50) && spot_threshold(50) > spot_threshold(100)); + let (k, v) = Overlay::Spots(37).to_parts(); + assert_eq!(Overlay::from_parts(k, v), Overlay::Spots(37)); + } + + #[test] + fn point_color_range_keeps_the_selected_colour_only() { + // left half orange, right half blue + let src = Rgb32f::from_fn(32, 16, |x, _| if x < 16 { [0.4, 0.15, 0.04] } else { [0.03, 0.06, 0.35] }); + let info = SourceInfo::default(); + let plain = render(&src, &info, &DevelopSettings::default(), &RenderRequest::fit(32, 16)).image; + // sample the orange as it renders + let px = plain.data[8 * 32 + 4]; + let lin = [px[0], px[1], px[2]].map(|v| srgb_to_linear(v as f32 / 255.0)); + let c = SRGB.to_space(&REC2020).apply_f32(lin); + let [l, ch, h] = lab_to_lch(oklab_from_2020(c)); + let mut s = DevelopSettings::default(); + s.point_colors.push(PointColor { lum: l as f64, chroma: ch as f64, hue: (h as f64).to_degrees(), hue_shift: 80.0, ..Default::default() }); + let req = RenderRequest { overlay: Overlay::PointColorRange(0), ..RenderRequest::fit(32, 16) }; + let v = render(&src, &info, &s, &req).image; + let (o, b) = (v.data[8 * 32 + 4], v.data[8 * 32 + 28]); + // the orange stays orange (and unshifted: the overlay shows the selection, not the edit) + assert!(o[0].abs_diff(px[0]) <= 2 && o[2].abs_diff(px[2]) <= 2, "{o:?} vs {px:?}"); + // the blue turns grey + assert!(b[0].abs_diff(b[2]) <= 1, "{b:?}"); + // without the overlay the edit shows + let e = render(&src, &info, &s, &RenderRequest::fit(32, 16)).image.data[8 * 32 + 4]; + assert!(e[1].abs_diff(px[1]) > 10, "{e:?} vs {px:?}"); + } +} diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 6bdeeb279..10bb8ded5 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -16,6 +16,61 @@ pub enum DngCompression { Lj92 { tile: u32, }, + /// Deflate (zlib) tiles of `tile × tile` pixels. Floating-point data is stored as 16-bit (`half`) or + /// 32-bit IEEE floats with the floating-point predictor (TIFF predictor 3, Adobe TIFF Technical Note 3); + /// integer data with horizontal differencing (predictor 2). 16-bit float is what HDR merges usually use. + Deflate { + tile: u32, + half: bool, + }, +} + +/// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept). +pub fn f32_to_f16(v: f32) -> u16 { + let b = v.to_bits(); + let sign = ((b >> 16) & 0x8000) as u16; + let exp = ((b >> 23) & 0xff) as i32; + let man = b & 0x7f_ffff; + if exp == 0xff { + return sign | 0x7c00 | if man != 0 { 0x200 } else { 0 }; + } + let e = exp - 127 + 15; + if e >= 0x1f { + return sign | 0x7c00; + } + if e <= 0 { + if e < -10 { + return sign; + } + // subnormal half + let m = man | 0x80_0000; + let shift = (14 - e) as u32; + let half = m >> shift; + let rem = m & ((1 << shift) - 1); + let mid = 1 << (shift - 1); + let r = if rem > mid || (rem == mid && half & 1 == 1) { half + 1 } else { half }; + return sign | r as u16; + } + let half = ((e as u32) << 10) | (man >> 13); + let rem = man & 0x1fff; + let r = if rem > 0x1000 || (rem == 0x1000 && half & 1 == 1) { half + 1 } else { half }; + sign | r as u16 +} + +/// Apply the floating-point predictor (inverse of `unpack::undo_float_predictor`) to one row of +/// `n` big-endian samples of `bytes_per` bytes: split into byte planes (most significant first), then +/// byte-difference with `stride` samples. +fn float_predict(row: &[u8], n: usize, bytes_per: usize, stride: usize) -> Vec { + let mut planes = vec![0u8; row.len()]; + for i in 0..n { + for b in 0..bytes_per { + planes[b * n + i] = row[i * bytes_per + b]; + } + } + for i in (stride..planes.len()).rev() { + planes[i] = planes[i].wrapping_sub(planes[i - stride]); + } + planes } #[derive(Clone, Debug, PartialEq)] @@ -131,6 +186,42 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { } match &raw.data { + RawData::F32(v) if matches!(opts.compression, DngCompression::Deflate { .. }) => { + let DngCompression::Deflate { tile, half } = opts.compression else { unreachable!() }; + let tile = (tile.max(16) & !15) as usize; + let bp = if half { 2 } else { 4 }; + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp])); + ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE])); + ifd.set(t::PREDICTOR, Value::Short(vec![3])); + let (ta, td) = (w.div_ceil(tile), h.div_ceil(tile)); + let tiles: Vec> = (0..ta * td) + .into_par_iter() + .map(|i| { + let (tx, ty) = (i % ta * tile, i / ta * tile); + let mut buf = Vec::with_capacity(tile * tile * cpp * bp); + let mut row = Vec::with_capacity(tile * cpp * bp); + for y in 0..tile { + let sy = (ty + y).min(h - 1); + row.clear(); + for x in 0..tile { + let sx = (tx + x).min(w - 1); + for s in 0..cpp { + let f = v[(sy * w + sx) * cpp + s]; + if half { + row.extend_from_slice(&f32_to_f16(f).to_be_bytes()); + } else { + row.extend_from_slice(&f.to_bits().to_be_bytes()); + } + } + } + buf.extend_from_slice(&float_predict(&row, tile * cpp, bp, cpp)); + } + miniz_oxide::deflate::compress_to_vec_zlib(&buf, 6) + }) + .collect(); + ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); + } RawData::F32(v) => { ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![32; cpp])); ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp])); @@ -164,6 +255,36 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { .collect(); ifd.set_image(ImageData::Strips { rows_per_strip: rows as u32, strips }); } + DngCompression::Deflate { tile, .. } => { + let tile = (tile.max(16) & !15) as usize; + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16; cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE])); + ifd.set(t::PREDICTOR, Value::Short(vec![2])); + let (ta, td) = (w.div_ceil(tile), h.div_ceil(tile)); + let tiles: Vec> = (0..ta * td) + .into_par_iter() + .map(|i| { + let (tx, ty) = (i % ta * tile, i / ta * tile); + let mut buf = Vec::with_capacity(tile * tile * cpp * 2); + let mut row = vec![0u16; tile * cpp]; + for y in 0..tile { + let sy = (ty + y).min(h - 1); + for x in 0..tile { + let sx = (tx + x).min(w - 1); + for s in 0..cpp { + row[x * cpp + s] = v[(sy * w + sx) * cpp + s]; + } + } + for i in (cpp..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - cpp]); + } + row.iter().for_each(|&x| opts.order.put_u16(&mut buf, x)); + } + miniz_oxide::deflate::compress_to_vec_zlib(&buf, 6) + }) + .collect(); + ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); + } DngCompression::Lj92 { tile } => { let tile = (tile.max(2) & !1) as usize; if tile > 65534 { diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 654faa995..c7e77ba06 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -108,6 +108,40 @@ fn writer_roundtrip_all_layouts() { } } +#[test] +fn deflate_roundtrips_integer_and_float() { + for (w, h) in [(64, 48), (70, 45)] { + for cfa in [Some(Cfa::bayer("RGGB").unwrap()), None] { + let cpp = if cfa.is_some() { 1 } else { 3 }; + let raw = synthetic(w, h, cfa, cpp); + for order in [ByteOrder::Little, ByteOrder::Big] { + let bytes = + write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 32, half: false }, order, xmp: None }).unwrap(); + assert_same(&raw, &decode(&bytes).unwrap()); + } + } + } + // linear float data: 32-bit exact, 16-bit within half precision + let mut raw = synthetic(70, 45, None, 3); + let vals: Vec = (0..70 * 45 * 3).map(|i| ((i % 97) as f32 / 97.0).powi(3) * 0.9 + 1e-4).collect(); + raw.data = RawData::F32(vals.clone()); + raw.black = BlackLevel::uniform(0.0); + raw.white = vec![1.0]; + for half in [false, true] { + let bytes = write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 32, half }, ..Default::default() }).unwrap(); + let back = decode(&bytes).unwrap(); + let RawData::F32(b) = &back.data else { panic!("float data expected") }; + for (a, b) in vals.iter().zip(b) { + let tol = if half { a * 1e-3 + 1e-7 } else { 0.0 }; + assert!((a - b).abs() <= tol, "{a} vs {b} (half: {half})"); + } + } + assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(1.0), 0x3c00); + assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(-2.0), 0xc000); + assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(1e9), 0x7c00); + assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(2f32.powi(-24)), 0x0001); +} + #[test] fn normalized_and_develop() { let mut raw = synthetic(40, 30, Some(Cfa::bayer("RGGB").unwrap()), 1); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9fad1a4fd..74d9792d4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && 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(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "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, "memory": memory(app), @@ -143,6 +143,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } "engine.commands" => ok(all_commands(app)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), + "ui.menu.tree" => { + ok(Value::Array(crate::menubar::menu_bar(app).into_iter().map(|(title, items)| json!({"label": title, "children": items})).collect())) + } "ui.inspect" => ok(inspect(app, ctx)), "ui.widgets" => { let filter = s("filter").unwrap_or(""); @@ -340,6 +343,14 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result bool { - self.app.renderer.in_flight() > 0 || !self.app.synthetic.is_empty() || !self.events.is_empty() + self.app.renderer.in_flight() > 0 || self.app.merge.busy() || !self.app.synthetic.is_empty() || !self.events.is_empty() } /// Run frames until nothing is pending (renders finished, input consumed) for a few frames in @@ -242,6 +242,10 @@ impl Headless { mod tests { use super::*; + /// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out + /// settle would show a half-rendered UI. + const SETTLE: Duration = Duration::from_secs(120); + fn demo(size: [f32; 2]) -> Headless { let services = crate::Services { png: None, ..Default::default() }; let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); @@ -253,7 +257,7 @@ mod tests { fn demo_grid_snapshot_has_ui_pixels() { let t0 = Instant::now(); let mut h = demo([1200.0, 760.0]); - let img = h.snapshot(Duration::from_secs(20)); + let img = h.snapshot(SETTLE); eprintln!("headless snapshot: {:?} in {:?} ({} frames)", img.size, t0.elapsed(), h.frames()); assert_eq!(img.size, [1200, 760]); // not blank: many distinct colours @@ -275,24 +279,110 @@ mod tests { let mut h = demo([900.0, 600.0]); let r = h.request("ui.set", json!({"view": "detail"}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); - h.snapshot(Duration::from_secs(20)) + h.snapshot(SETTLE) }; let (a, b) = (shot(), shot()); assert_eq!(a.size, b.size); - let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x != y).count(); + // The photo may come from the GPU in one run and the CPU in the other (the GPU warms up in + // the background), which differ by ≤ 1–2 LSB; anything more is a real difference. + let diff = a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.to_array().iter().zip(y.to_array()).any(|(p, q)| p.abs_diff(q) > 2)).count(); assert_eq!(diff, 0, "{diff} pixels differ between two runs"); } + /// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance), + /// Survey (keys hit the active photo only), and auto-advance in Detail. + #[test] + fn compare_survey_and_auto_advance_by_keyboard() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating; + let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag; + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t); + let r = h.request("engine.execute", json!({"command": "view.compare"}), t); + assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); + // rating applies to the candidate (active) only + let before = rating(&h, vis[0]); + h.request("ui.key", json!({"key": "2"}), t); + assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2)); + // arrows move the candidate; the select stays + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("ui.key", json!({"key": "left"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[1]))); + // Shift+X: reject and advance to the next candidate + h.request("ui.key", json!({"key": "x", "shift": true}), t); + assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("engine.execute", json!({"command": "compare.swap"}), t); + assert_eq!(h.app.ui.compare, Some((vis[2], vis[0]))); + h.request("engine.execute", json!({"command": "compare.makeSelect"}), t); + assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0])); + // Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t); + let r = h.request("engine.execute", json!({"command": "view.survey"}), t); + assert_eq!(r["result"]["photos"], 3); + h.request("engine.execute", json!({"command": "view.autoAdvance"}), t); + assert!(h.app.ui.auto_advance); + h.request("ui.key", json!({"key": "5"}), t); + h.request("ui.key", json!({"key": "p"}), t); + assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); + assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1000, 700]); + // Detail: auto-advance moves to the next photo + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "u"}), t); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7])); + // Escape leaves the culling views for Detail + h.request("ui.set", json!({"view": "survey"}), t); + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); + // let in-flight renders finish: worker threads must not outlive the test process' TLS + h.settle(SETTLE); + } + + /// The filter bar drives `library.filter` and saves the view as a smart album. + #[test] + fn filter_bar_filters_and_saves_a_smart_album() { + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + let all = h.app.session.visible_cloned().len(); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + assert!(h.app.ui.filter_bar); + for w in ["filter:star3", "filter:pick", "filter:label-red", "filter:label-red"] { + let r = h.request("ui.clickWidget", json!({"id": w}), t); + assert_eq!(r["ok"], true, "{w}: {r}"); + } + let f = h.app.session.filter.clone(); + assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label"); + let n = h.app.session.visible_cloned().len(); + assert!(n > 0 && n < all); + h.request("ui.clickWidget", json!({"id": "button:filterSave"}), t); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::NewSmartAlbum { .. }))); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id; + assert_eq!(h.app.session.catalog.album_count(smart), n); + h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t); + assert_eq!(h.app.session.filter, Default::default()); + assert_eq!(h.app.session.visible_cloned().len(), all); + h.settle(SETTLE); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); - let r = h.request("ui.screenshot", json!({}), Duration::from_secs(20)); + let r = h.request("ui.screenshot", json!({}), SETTLE); assert_eq!(r["ok"], true, "{r}"); assert_eq!(r["result"]["width"], 800); assert_eq!(r["result"]["height"], 500); let r = h.request("ui.resize", json!({"width": 640, "height": 400}), Duration::from_secs(5)); assert_eq!(r["ok"], true); - let r = h.request("ui.screenshot", json!({"headless": true}), Duration::from_secs(20)); + let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE); assert_eq!(r["result"]["width"], 640, "{r}"); } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a8656c6f9..c91ac6508 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -29,6 +29,7 @@ pub enum Icon { GridSquare, Single, Compare, + Survey, Sort, Star, StarFilled, @@ -45,6 +46,8 @@ pub enum Icon { Plus, Minus, Album, + SmartAlbum, + Stack, Folder, Photos, Clock, @@ -66,6 +69,8 @@ pub enum Icon { Video, Heart, Edited, + /// Targeted adjustment: a ring with a centre dot and up/down arrows (drag vertically). + Target, } struct Pen<'a> { @@ -240,9 +245,15 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect_fill(10.5, 10.5, 16.5, 16.0, 0.5); } Single => pen.rect_fill(3.0, 5.0, 17.0, 15.0, 0.5), + Survey => { + pen.rect(2.5, 3.5, 9.0, 9.0, 1.0); + pen.rect(11.0, 3.5, 17.5, 9.0, 1.0); + pen.rect(2.5, 11.0, 9.0, 16.5, 1.0); + pen.rect(11.0, 11.0, 17.5, 16.5, 1.0); + } Compare => { - pen.rect_fill(3.0, 5.0, 9.5, 15.0, 0.5); - pen.rect_fill(10.5, 5.0, 17.0, 15.0, 0.5); + pen.rect_fill(2.0, 5.0, 8.8, 15.0, 0.5); + pen.rect_fill(11.2, 5.0, 18.0, 15.0, 0.5); } Sort => { pen.line(&[(3.0, 5.0), (17.0, 5.0)]); @@ -302,6 +313,17 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); } + Stack => { + pen.rect(2.5, 7.0, 13.5, 17.5, 1.0); + pen.line(&[(5.0, 4.5), (16.0, 4.5), (16.0, 14.5)]); + pen.line(&[(7.5, 2.0), (18.5, 2.0), (18.5, 11.5)]); + } + SmartAlbum => { + pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); + pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); + // a small funnel: the album is a saved filter + pen.closed(&[(5.5, 8.5), (12.5, 8.5), (10.0, 11.5), (10.0, 14.5), (8.0, 13.5), (8.0, 11.5)]); + } Folder => pen.closed(&[(2.5, 5.0), (8.0, 5.0), (9.5, 7.0), (17.5, 7.0), (17.5, 15.5), (2.5, 15.5)]), Photos => { pen.rect(2.5, 4.0, 17.5, 16.0, 1.5); @@ -402,5 +424,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(10.0, 10.0, 7.0); pen.line(&[(7.0, 13.0), (13.0, 7.0)]); } + Target => { + pen.circle(8.0, 10.0, 5.0); + pen.dot(8.0, 10.0, 1.3); + pen.line(&[(15.5, 3.5), (15.5, 16.5)]); + pen.line(&[(13.5, 5.5), (15.5, 3.5), (17.5, 5.5)]); + pen.line(&[(13.5, 14.5), (15.5, 16.5), (17.5, 14.5)]); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 757301ea0..71aed1e87 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,7 +8,9 @@ pub mod control; pub mod headless; pub mod icons; +pub mod menubar; pub mod menus; +pub mod merge; pub mod panels; pub mod render; pub mod shortcuts; @@ -30,6 +32,8 @@ pub type PickFiles = Box 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)] @@ -43,6 +47,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)] @@ -59,8 +65,11 @@ pub struct LightcraftApp { pub perf: Perf, /// macOS: the host draws the traffic lights over our top bar. pub integrated_titlebar: bool, - /// The host installed a native menu bar. + /// The host installed a native menu bar (no in-window menus then). pub native_menu: bool, + /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler + /// leaves them alone so nothing fires twice. + pub native_shortcuts: std::collections::HashSet, /// The host is [`headless::Headless`] (it answers viewport screenshot commands itself). pub headless_host: bool, control_rx: Option>, @@ -83,6 +92,8 @@ pub struct LightcraftApp { /// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small", /// "thumb", "none"). pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>, + /// Photo Merge dialog previews and background merges. + pub merge: merge::MergeState, } impl LightcraftApp { @@ -97,6 +108,7 @@ impl LightcraftApp { perf: Perf::default(), integrated_titlebar: false, native_menu: false, + native_shortcuts: Default::default(), headless_host: false, control_rx: None, pending_screenshots: vec![], @@ -111,6 +123,7 @@ impl LightcraftApp { widgets: vec![], gesture: None, loupe_shown: None, + merge: merge::MergeState::default(), } } @@ -174,7 +187,7 @@ impl LightcraftApp { return; } let now = now_ms(); - let busy = self.renderer.in_flight() > 0; + let busy = self.renderer.in_flight() > 0 || self.merge.busy(); let mut shots = std::mem::take(&mut self.pending_screenshots); let mut shadow_ticked = false; shots.retain_mut(|s| { @@ -231,7 +244,13 @@ impl LightcraftApp { let memory = main.memory(|m| m.clone()); view.ctx.memory_mut(|m| *m = memory); view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.ui(ui)); - let img = capture.then(|| view.paint(&self.renderer.cpu_textures())); + let img = capture.then(|| { + let mut tex = self.renderer.cpu_textures(); + if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { + tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); + } + view.paint(&tex) + }); self.shadow = Some(view); img } @@ -281,6 +300,7 @@ impl LightcraftApp { ctx.request_repaint(); } self.renderer.poll(ctx, &mut self.session); + merge::poll(self, ctx); self.session.persist_if_dirty(); self.collect_screenshots(ctx); self.issue_screenshots(ctx); @@ -335,10 +355,13 @@ impl LightcraftApp { } panels::bottombar::show(self, ui); let t = theme::Tokens::get(&ctx); - let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare) { t.canvas } else { t.grid_bg }; + let bg = + if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey) { t.canvas } else { t.grid_bg }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view { state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui), - state::ViewMode::Detail | state::ViewMode::Compare => panels::detail::show(self, ui), + state::ViewMode::Detail => panels::detail::show(self, ui), + state::ViewMode::Compare => panels::compare::show_compare(self, ui), + state::ViewMode::Survey => panels::compare::show_survey(self, ui), }); panels::dialogs::show(self, &ctx); panels::toast(self, &ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs new file mode 100644 index 000000000..1b56db044 --- /dev/null +++ b/crates/ui-egui/src/menubar.rs @@ -0,0 +1,580 @@ +//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry +//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts, +//! enabled and checked state. +//! +//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the +//! in-window menu bar (web, Windows, Linux — [`show_in_window`]) and the control channel +//! (`ui.menu.tree`). Items are a command id plus parameters, run through [`run_item`] so they +//! behave exactly like their keyboard shortcuts. + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::menus::MenuEntry; +use crate::state::{RightPanel, ViewMode}; + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum MenuNode { + Item { + id: String, + #[serde(skip_serializing_if = "Value::is_null")] + params: Value, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + shortcut: Option, + enabled: bool, + /// `Some` for toggles and radio-style choices. + #[serde(skip_serializing_if = "Option::is_none")] + checked: Option, + }, + Separator, + Submenu { + label: String, + children: Vec, + }, +} + +impl MenuNode { + /// `id|params` — a stable key for one item (native menu ids). + pub fn key(id: &str, params: &Value) -> String { + if params.is_null() { id.to_string() } else { format!("{id}|{params}") } + } +} + +/// Top-level menus in order (the macOS app menu is added by the host). +pub const MENUS: &[&str] = &["File", "Edit", "View", "Photo", "Window", "Help"]; + +/// Order and grouping per menu: command ids, `---` separators and `@Submenu` placeholders. +/// Entries with a menu path that aren't listed are appended at the end of their menu. +const LAYOUT: &[(&str, &[&str])] = &[ + ( + "File", + &[ + "file.addPhotos", + "---", + "dialog.newAlbum", + "dialog.newFolder", + "dialog.newSmartAlbum", + "---", + "file.importPresets", + "file.exportPresets", + "---", + "dialog.export", + "app.exportPrevious", + "---", + "library.toggleAutoWriteXmp", + "---", + ], + ), + ( + "Edit", + &["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"], + ), + ( + "View", + &[ + "view.photoGrid", + "view.squareGrid", + "view.detail", + "view.compare", + "view.survey", + "---", + "view.leftPanel", + "view.filmstrip", + "view.histogram", + "---", + "view.showOriginal", + "view.beforeAfter", + "view.beforeAfterSplit", + "---", + "view.zoomIn", + "view.zoomOut", + "view.zoomToggle", + "view.zoomFit", + "view.zoom100", + "---", + "view.clipping", + "view.maskOverlay", + "view.cropOverlay", + "---", + "@Sort", + "@Stacks", + "view.filterBar", + "library.clearFilter", + "---", + "compare.swap", + "compare.makeSelect", + ], + ), + ( + "Photo", + &[ + "@Add to Album", + "---", + "@Set Rating", + "@Set Flag", + "@Set Color Label", + "view.autoAdvance", + "---", + "photo.rotateLeft", + "photo.rotateRight", + "photo.flipHorizontal", + "photo.flipVertical", + "---", + "version.create", + "photo.virtualCopy", + "@Stack", + "@Photo Merge", + "---", + "develop.auto", + "develop.treatment", + "develop.reset", + "dialog.createPreset", + "---", + "photo.saveMetadataToFile", + "photo.readMetadataFromFile", + "app.showInFinder", + "---", + "photo.delete", + ], + ), + ( + "Window", + &[ + "panel.edit", + "panel.crop", + "panel.remove", + "panel.masking", + "panel.redeye", + "---", + "panel.presets", + "panel.versions", + "panel.activity", + "panel.info", + "panel.keywords", + "---", + "@Edit Sections", + "@Tools", + ], + ), + ("Help", &["app.shortcuts", "---", "app.about"]), +]; + +/// Registry entries that are reached another way (parameterized commands are expanded into +/// submenus below; others duplicate a dialog command). +const HIDDEN: &[&str] = &[ + "photo.rate", + "photo.flag", + "photo.pick", + "photo.reject", + "photo.unflag", + "photo.label", + "library.sort", + "album.addPhotos", + "album.create", + "library.import", + "preset.create", +]; + +fn item(id: &str, params: Value, label: impl Into, shortcut: Option<&str>, enabled: bool, checked: Option) -> MenuNode { + MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked } +} + +/// Checked state of toggles and radio items. +pub fn checked(app: &LightcraftApp, id: &str) -> Option { + let u = &app.ui; + let panel = |p: RightPanel| Some(u.right == p); + match id { + "view.photoGrid" => Some(u.view == ViewMode::PhotoGrid), + "view.squareGrid" => Some(u.view == ViewMode::SquareGrid), + "view.detail" => Some(u.view == ViewMode::Detail), + "view.compare" => Some(u.view == ViewMode::Compare), + "view.survey" => Some(u.view == ViewMode::Survey), + "view.leftPanel" => Some(u.left_panel), + "view.filmstrip" => Some(u.filmstrip), + "view.histogram" => Some(u.histogram), + "view.clipping" => Some(u.show_clipping), + "view.maskOverlay" => Some(u.mask_overlay), + "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), + "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), + "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), + "view.autoAdvance" => Some(u.auto_advance), + "view.filterBar" => Some(u.filter_bar), + "panel.edit" => panel(RightPanel::Edit), + "panel.crop" => panel(RightPanel::Crop), + "panel.remove" => panel(RightPanel::Remove), + "panel.masking" => panel(RightPanel::Masking), + "panel.redeye" => panel(RightPanel::RedEye), + "panel.versions" => panel(RightPanel::Versions), + "panel.activity" => panel(RightPanel::Activity), + "panel.info" => panel(RightPanel::Info), + "panel.keywords" => panel(RightPanel::Keywords), + "panel.presets" => Some(u.presets), + "library.toggleAutoWriteXmp" => Some(app.session.xmp.auto_write), + _ => None, + } +} + +/// Labels that follow the state ("Undo Exposure", "Delete 3 Photos"). +fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { + let n = app.session.selection.ids.len(); + match id { + "edit.undo" => app.session.undo.last().map(|e| format!("Undo {}", e.label)).unwrap_or_else(|| "Undo".into()), + "edit.redo" => app.session.redo.last().map(|e| format!("Redo {}", e.label)).unwrap_or_else(|| "Redo".into()), + "photo.delete" if n > 1 => format!("Delete {n} Photos"), + "photo.virtualCopy" if n > 1 => format!("Create {n} Virtual Copies"), + _ => label.to_string(), + } +} + +/// The parameterized submenus. +fn expanded(app: &LightcraftApp, name: &str) -> Option> { + let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); + let has = active.is_some(); + Some(match name { + "Set Rating" => (0..=5u8) + .map(|r| { + let label = if r == 0 { "None".to_string() } else { "★".repeat(r as usize) }; + let sc = ["0", "1", "2", "3", "4", "5"][r as usize]; + item("photo.rate", json!({"rating": r}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.rating == r))) + }) + .collect(), + "Set Flag" => [ + ("pick", "Pick", "P", lightcraft_catalog::Flag::Pick), + ("reject", "Reject", "X", lightcraft_catalog::Flag::Reject), + ("none", "Unflagged", "U", lightcraft_catalog::Flag::None), + ] + .into_iter() + .map(|(f, label, sc, flag)| { + item("photo.flag", json!({"flag": f}), label, Some(sc), has, Some(active.as_ref().is_some_and(|p| p.flag == flag))) + }) + .collect(), + "Set Color Label" => { + let mut v: Vec = lightcraft_catalog::ColorLabel::ALL + .iter() + .zip([Some("6"), Some("7"), Some("8"), Some("9"), None]) + .map(|(l, sc)| { + let name = format!("{l:?}"); + item( + "photo.label", + json!({"label": name.to_lowercase()}), + name, + sc, + has, + Some(active.as_ref().is_some_and(|p| p.label == Some(*l))), + ) + }) + .collect(); + v.push(MenuNode::Separator); + v.push(item("photo.label", json!({"label": "none"}), "None", None, has, Some(active.as_ref().is_some_and(|p| p.label.is_none())))); + v + } + "Sort" => { + use lightcraft_catalog::SortKey::*; + let cur = app.session.sort; + let mut v: Vec = [ + ("Capture Date", CaptureDate, "captureDate"), + ("Import Date", ImportDate, "importDate"), + ("Modified Date", EditDate, "editDate"), + ("File Name", FileName, "fileName"), + ("Rating", Rating, "rating"), + ("File Size", FileSize, "fileSize"), + ] + .into_iter() + .map(|(label, key, k)| item("library.sort", json!({"key": k}), label, None, true, Some(cur.key == key))) + .collect(); + v.push(MenuNode::Separator); + v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, true, Some(cur.ascending))); + v.push(item("library.sort", json!({"ascending": false}), "Descending", None, true, Some(!cur.ascending))); + v + } + "Add to Album" => { + let sel = !app.session.selection.ids.is_empty() || has; + let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); + albums.sort_by_key(|(n, _)| n.to_lowercase()); + let mut v: Vec = + albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel, None)).collect(); + if !v.is_empty() { + v.push(MenuNode::Separator); + } + v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, true, None)); + v + } + _ => return None, + }) +} + +fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { + item(&e.id, Value::Null, live_label(app, &e.id, &e.label), e.shortcut.as_deref(), e.enabled, checked(app, &e.id)) +} + +/// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus. +fn tidy(v: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(v.len()); + for n in v { + let n = match n { + MenuNode::Submenu { label, children } => { + let children = tidy(children); + if children.is_empty() { + continue; + } + MenuNode::Submenu { label, children } + } + n => n, + }; + if n == MenuNode::Separator && out.last().is_none_or(|l| *l == MenuNode::Separator) { + continue; + } + out.push(n); + } + while out.last() == Some(&MenuNode::Separator) { + out.pop(); + } + out +} + +/// The whole menu bar: (title, items) per menu in [`MENUS`] order. +pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec)> { + let entries: Vec = crate::menus::menu_entries(app).into_iter().filter(|e| !HIDDEN.contains(&e.id.as_str())).collect(); + let mut used = vec![false; entries.len()]; + let mut bar = Vec::new(); + for title in MENUS { + let layout = LAYOUT.iter().find(|(t, _)| t == title).map(|(_, l)| *l).unwrap_or(&[]); + let mut items = Vec::new(); + let sub = |name: &str, used: &mut [bool]| -> Vec { + let mut children = expanded(app, name).unwrap_or_default(); + let mut dialogs = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if !used[i] && e.menu.len() == 2 && e.menu[0] == *title && e.menu[1] == name { + used[i] = true; + // commands that open a dialog go last, after a separator + if e.label.ends_with('…') { dialogs.push(node(app, e)) } else { children.push(node(app, e)) } + } + } + if !dialogs.is_empty() { + children.push(MenuNode::Separator); + children.extend(dialogs); + } + children + }; + for slot in layout { + if *slot == "---" { + items.push(MenuNode::Separator); + } else if let Some(name) = slot.strip_prefix('@') { + let children = sub(name, &mut used); + items.push(MenuNode::Submenu { label: name.to_string(), children }); + } else if let Some(i) = entries.iter().position(|e| e.id == *slot && e.menu.first().map(String::as_str) == Some(title)) { + used[i] = true; + items.push(node(app, &entries[i])); + } + } + // everything else that names this menu + items.push(MenuNode::Separator); + let mut extra_subs: Vec = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if used[i] || e.menu.first().map(String::as_str) != Some(title) { + continue; + } + if e.menu.len() == 1 { + used[i] = true; + items.push(node(app, e)); + } else if !extra_subs.contains(&e.menu[1]) { + extra_subs.push(e.menu[1].clone()); + } + } + for name in extra_subs { + let children = sub(&name, &mut used); + items.push(MenuNode::Submenu { label: name, children }); + } + bar.push((title.to_string(), tidy(items))); + } + bar +} + +/// Run a menu item. Rating, flag and label items behave like their keys (the active photo only in +/// Compare/Survey; Auto Advance moves on). +pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result { + let mut params = if params.is_null() { json!({}) } else { params }; + let culling_cmd = matches!(id, "photo.rate" | "photo.flag" | "photo.label" | "photo.pick" | "photo.reject" | "photo.unflag"); + if culling_cmd { + crate::panels::compare::target_active(app, &mut params); + } + let r = app.run(id, params); + if culling_cmd && r.is_ok() && app.ui.auto_advance { + crate::panels::compare::advance(app); + } + r +} + +/// Human-readable shortcut text for menus: `Cmd+Shift+Z` → `⌘⇧Z` on macOS, `Ctrl+Shift+Z` elsewhere. +pub fn shortcut_text(sc: &str, mac: bool) -> String { + if mac { + let mut mods = String::new(); + let mut key = ""; + for part in sc.split('+') { + match part { + "Ctrl" => mods.push('⌃'), + "Alt" => mods.push('⌥'), + "Shift" => mods.push('⇧'), + "Cmd" => mods.push('⌘'), + k => key = k, + } + } + let key = match key { + "Delete" => "⌫", + "Escape" => "⎋", + "Left" => "←", + "Right" => "→", + k => k, + }; + format!("{mods}{key}") + } else { + sc.replace("Cmd", "Ctrl") + } +} + +/// Width of the in-window menu bar's titles. +pub fn bar_width(ui: &egui::Ui) -> f32 { + let t = crate::theme::Tokens::get(ui.ctx()); + MENUS.iter().map(|m| ui.painter().layout_no_wrap(m.to_string(), t.font(13.0), t.text).size().x + 16.0).sum::() + + ui.spacing().item_spacing.x * MENUS.len() as f32 +} + +/// The in-window menu bar (hosts without a native one): one dropdown per menu, or a single +/// "Menu" button when the space is too narrow. Returns the width used. +pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32) -> f32 { + let t = crate::theme::Tokens::get(ui.ctx()); + let bar = menu_bar(app); + let font = t.font(13.0); + let widths: Vec = bar.iter().map(|(title, _)| ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x + 16.0).collect(); + let total: f32 = widths.iter().sum(); + let mut clicked: Option<(String, Value)> = None; + let start = ui.cursor().left(); + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + if total <= max_width { + for (title, items) in &bar { + let r = ui.add(egui::Button::new(egui::RichText::new(title).font(font.clone()).color(t.text_label)).frame(false)); + crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect); + egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked)); + } + } else { + let r = ui.add(egui::Button::new(egui::RichText::new("Menu").font(font.clone()).color(t.text_label)).frame(false)); + crate::widgets::register(ui.ctx(), "menu:all", r.rect); + egui::Popup::menu(&r).show(|ui| { + for (title, items) in &bar { + ui.menu_button(title, |ui| nodes_ui(ui, items, mac, &mut clicked)); + } + }); + } + if let Some((id, params)) = clicked { + let _ = run_item(app, &id, params); + } + ui.cursor().left() - start +} + +fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) { + ui.set_min_width(220.0); + for n in nodes { + match n { + MenuNode::Separator => { + ui.separator(); + } + MenuNode::Submenu { label, children } => { + ui.menu_button(format!(" {label}"), |ui| nodes_ui(ui, children, mac, clicked)); + } + MenuNode::Item { id, params, label, shortcut, enabled, checked } => { + // a gutter for check marks, like native menus + let mut b = egui::Button::new(format!(" {label}")); + if let Some(sc) = shortcut { + b = b.shortcut_text(shortcut_text(sc, mac)); + } + let r = ui.add_enabled(*enabled, b); + if *checked == Some(true) { + let t = crate::theme::Tokens::get(ui.ctx()); + let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0)); + crate::icons::paint(ui.painter(), c, crate::icons::Icon::Check, if *enabled { t.text } else { t.text_disabled }); + } + if r.clicked() { + *clicked = Some((id.clone(), params.clone())); + ui.close(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()) + } + + fn find<'a>(nodes: &'a [MenuNode], id: &str) -> Option<&'a MenuNode> { + nodes.iter().find_map(|n| match n { + MenuNode::Item { id: i, .. } if i == id => Some(n), + MenuNode::Submenu { children, .. } => find(children, id), + _ => None, + }) + } + + #[test] + fn bar_follows_the_registry_and_state() { + let mut app = app(); + let bar = menu_bar(&app); + let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(titles, MENUS); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + // every engine command with a menu path is reachable (parameterized ones via submenus) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) { + assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); + } + for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { + assert!(find(&all, id).is_some(), "{id}"); + } + // no doubled or dangling separators + fn check(v: &[MenuNode]) { + assert_ne!(v.first(), Some(&MenuNode::Separator)); + assert_ne!(v.last(), Some(&MenuNode::Separator)); + for w in v.windows(2) { + assert!(!(w[0] == MenuNode::Separator && w[1] == MenuNode::Separator)); + } + for n in v { + if let MenuNode::Submenu { children, .. } = n { + check(children); + } + } + } + bar.iter().for_each(|(_, v)| check(v)); + // live state: undo label, enabled, checked + let Some(MenuNode::Item { enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert!(!enabled); + run_item(&mut app, "photo.rate", json!({"rating": 3})).unwrap(); + let bar = menu_bar(&app); + let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); + let Some(MenuNode::Item { label, enabled, .. }) = find(&all, "edit.undo") else { panic!() }; + assert_eq!((label.as_str(), *enabled), ("Undo Set Rating", true)); + let rated: Vec<_> = all + .iter() + .filter_map(|n| match n { + MenuNode::Submenu { label, children } if label == "Set Rating" => Some(children.clone()), + _ => None, + }) + .flatten() + .filter(|n| matches!(n, MenuNode::Item { checked: Some(true), .. })) + .collect(); + assert!(matches!(&rated[..], [MenuNode::Item { params, .. }] if params["rating"] == 3)); + let Some(MenuNode::Item { checked, .. }) = find(&all, "view.filmstrip") else { panic!() }; + assert_eq!(*checked, Some(app.ui.filmstrip)); + } + + #[test] + fn shortcut_text_per_platform() { + assert_eq!(shortcut_text("Cmd+Shift+Z", true), "⌘⇧Z"); + assert_eq!(shortcut_text("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); + assert_eq!(shortcut_text("Delete", true), "⌫"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1dd7101f5..4d31a0024 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -14,10 +14,17 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.photoGrid", "Photo Grid", Some("G"), "View"), ("view.squareGrid", "Square Grid", Some("Shift+G"), "View"), ("view.detail", "Detail", Some("D"), "View"), + ("view.compare", "Compare", Some("Shift+C"), "View"), + ("view.survey", "Survey", Some("N"), "View"), + ("compare.swap", "Swap Compare Photos", None, "View"), + ("compare.makeSelect", "Make Candidate the Select", None, "View"), + ("view.autoAdvance", "Auto Advance", None, "Photo"), ("view.filmstrip", "Filmstrip", Some("/"), "View"), ("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"), @@ -27,40 +34,49 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.clipping", "Show Clipping", Some("J"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), - ("view.back", "Back to Grid", Some("Escape"), "View"), - ("view.filterBar", "Filter", None, "View"), - ("panel.edit", "Edit", Some("E"), "View"), - ("panel.crop", "Crop & Rotate", Some("C"), "View"), - ("panel.remove", "Remove", Some("H"), "View"), - ("panel.masking", "Masking", Some("M"), "View"), - ("panel.redeye", "Red Eye", None, "View"), - ("panel.presets", "Presets", Some("Shift+P"), "View"), - ("panel.info", "Info", Some("I"), "View"), - ("panel.keywords", "Keywords", Some("K"), "View"), - ("panel.versions", "Versions", Some("Shift+V"), "View"), - ("panel.activity", "History", Some("Shift+Y"), "View"), - ("panel.close", "Close Panel", None, "View"), - ("section.light", "Light", Some("Cmd+Alt+1"), "View"), - ("section.color", "Color", Some("Cmd+Alt+2"), "View"), - ("section.effects", "Effects", Some("Cmd+Alt+3"), "View"), - ("section.detail", "Detail", Some("Cmd+Alt+4"), "View"), - ("section.optics", "Optics", Some("Cmd+Alt+5"), "View"), - ("tool.brush", "Brush", Some("B"), "View"), - ("tool.linear", "Linear Gradient", Some("L"), "View"), - ("tool.radial", "Radial Gradient", Some("R"), "View"), - ("tool.wbPicker", "White Balance Selector", Some("W"), "View"), - ("tool.none", "No Tool", None, "View"), + ("view.back", "Back to Grid", Some("Escape"), ""), + ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), + ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.crop", "Crop & Rotate", Some("C"), "Window"), + ("panel.remove", "Remove", Some("H"), "Window"), + ("panel.masking", "Masking", Some("M"), "Window"), + ("panel.redeye", "Red Eye", None, "Window"), + ("panel.presets", "Presets", Some("Shift+P"), "Window"), + ("panel.info", "Info", Some("I"), "Window"), + ("panel.keywords", "Keywords", Some("K"), "Window"), + ("panel.versions", "Versions", Some("Shift+V"), "Window"), + ("panel.activity", "History", None, "Window"), + ("panel.close", "Close Panel", None, ""), + ("section.light", "Light", Some("Cmd+Alt+1"), "Window>Edit Sections"), + ("section.color", "Color", Some("Cmd+Alt+2"), "Window>Edit Sections"), + ("section.effects", "Effects", Some("Cmd+Alt+3"), "Window>Edit Sections"), + ("section.detail", "Detail", Some("Cmd+Alt+4"), "Window>Edit Sections"), + ("section.optics", "Optics", Some("Cmd+Alt+5"), "Window>Edit Sections"), + ("tool.brush", "Brush", Some("B"), "Window>Tools"), + ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), + ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), + ("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…", 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.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"), + ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), + ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), ("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"), + ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), + ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), + ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), ("file.importPresets", "Import Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), - ("app.about", "About LightCraft", None, "LightCraft"), + ("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) { @@ -96,11 +112,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "view.survey" => { + app.ui.view = ViewMode::Survey; + Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) + } + "compare.swap" => crate::panels::compare::swap(app), + "compare.makeSelect" => crate::panels::compare::make_select(app), + "view.autoAdvance" => { + app.ui.auto_advance = !app.ui.auto_advance; + app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + Ok(json!({"autoAdvance": app.ui.auto_advance})) + } "view.back" => { if app.ui.dialog.is_some() { app.ui.dialog = None; } else if !app.ui.tool.is_empty() { app.ui.tool.clear(); + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { + app.ui.view = ViewMode::Detail; } else if app.ui.view == ViewMode::Detail { app.ui.view = ViewMode::PhotoGrid; } @@ -122,6 +152,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) @@ -164,6 +202,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // like Lightroom's A: opens the Remove tool with the view on, or toggles it there + if app.ui.right == RightPanel::Remove { + app.ui.visualize_spots = !app.ui.visualize_spots; + } else { + app.ui.right = RightPanel::Remove; + app.ui.visualize_spots = true; + } + Ok(Value::Null) + } "view.cropOverlay" => { use crate::state::CropOverlay::*; app.ui.crop_overlay = match app.ui.crop_overlay { @@ -176,8 +224,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.left_panel = true; - Ok(Value::Null) + app.ui.filter_bar = !app.ui.filter_bar; + if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + Ok(json!({"filterBar": app.ui.filter_bar})) } "panel.edit" => { panel(app, &ctx, RightPanel::Edit, "Edit"); @@ -262,10 +313,22 @@ 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) } + "dialog.autoStack" => { + app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); + Ok(Value::Null) + } + "dialog.newSmartAlbum" => { + app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() }); + Ok(Value::Null) + } "dialog.createPreset" => { app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() }); Ok(Value::Null) @@ -277,10 +340,20 @@ 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) } + "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), + "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), + "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), "app.about" => { app.ui.dialog = Some(Dialog::About); Ok(Value::Null) @@ -353,6 +426,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) @@ -362,7 +440,19 @@ 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, + s if s.starts_with("dialog.merge") => app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none(), _ => true, } } @@ -384,7 +474,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { .map(|(id, label, sc, m)| MenuEntry { id: id.to_string(), label: label.to_string(), - menu: vec![m.to_string()], + menu: m.split('>').map(str::to_string).collect(), shortcut: sc.map(str::to_string), enabled: ui_enabled(app, id), }) @@ -402,3 +492,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/merge.rs b/crates/ui-egui/src/merge.rs new file mode 100644 index 000000000..507eee2fd --- /dev/null +++ b/crates/ui-egui/src/merge.rs @@ -0,0 +1,389 @@ +//! Photo Merge in the UI: the HDR / Panorama / HDR Panorama dialog (options + live preview) and +//! the background merge jobs. +//! +//! Previews and the final merge run [`lightcraft_engine::merge::MergeJob`]s on worker threads with +//! progress and cancellation; the window stays responsive. Changing an option cancels the running +//! preview and starts a new one. The final merge keeps running after the dialog closes (progress +//! in a toast); when it finishes, the result is written, imported and selected +//! ([`lightcraft_engine::Session::finish_merge`]). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, channel}; +use std::sync::{Arc, Mutex}; + +use lightcraft_catalog::PhotoId; +use lightcraft_engine::merge::{MergeJob, MergeOutput}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; + +/// The dialog's options (also the parameters of the `merge.*` commands). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MergeDialog { + /// "merge.hdr" | "merge.panorama" | "merge.hdrPanorama". + pub command: String, + pub align: bool, + /// none | low | medium | high + pub deghost: String, + pub show_overlay: bool, + pub auto_settings: bool, + pub stack: bool, + /// auto | spherical | cylindrical | perspective + pub projection: String, + pub boundary_warp: f64, + pub auto_crop: bool, + pub fill_edges: bool, + /// HDR panorama: photos per bracket (0 = from EXIF). + pub bracket: u32, +} + +impl Default for MergeDialog { + fn default() -> Self { + MergeDialog { + command: "merge.hdr".into(), + align: true, + deghost: "none".into(), + show_overlay: false, + auto_settings: true, + stack: false, + projection: "auto".into(), + boundary_warp: 0.0, + auto_crop: true, + fill_edges: false, + bracket: 0, + } + } +} + +impl MergeDialog { + pub fn for_command(command: &str) -> MergeDialog { + MergeDialog { command: command.into(), ..Default::default() } + } + pub fn title(&self) -> &'static str { + match self.command.as_str() { + "merge.panorama" => "Panorama Merge Preview", + "merge.hdrPanorama" => "HDR Panorama Merge Preview", + _ => "HDR Merge Preview", + } + } + pub fn is_hdr(&self) -> bool { + self.command != "merge.panorama" + } + pub fn is_pano(&self) -> bool { + self.command != "merge.hdr" + } + pub fn params(&self) -> Value { + serde_json::to_value(self).unwrap_or_default() + } +} + +/// A merge running on a worker thread. +pub struct MergeTask { + pub preview: bool, + pub job: MergeJob, + /// The dialog options it was started with. + pub options: MergeDialog, + pub progress: Arc>, + pub cancel: Arc, + rx: Receiver>, +} + +/// Merge state of the app. +#[derive(Default)] +pub struct MergeState { + pub preview_task: Option, + pub final_task: Option, + /// The latest preview (texture, size, its options, stats) and the latest error. + pub preview: Option<(egui::TextureHandle, [usize; 2], MergeDialog, Value)>, + pub preview_pixels: Option>, + pub error: Option, + /// Options whose preview failed (not retried until they change). + pub failed_options: Option, + /// The last finished merge (`{id, path, …}`). + pub last_result: Option, + /// Photos the dialog merges (the selection when it opened). + pub ids: Vec, +} + +impl MergeState { + /// A preview or a merge is running. + pub fn busy(&self) -> bool { + self.preview_task.is_some() || self.final_task.is_some() + } +} + +fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: bool) -> Result { + let (kind, finish) = lightcraft_engine::merge::parse(&options.command, &options.params()).map_err(|e| e.to_string())?; + let job = app.session.plan_merge(kind, finish, ids, preview).map_err(|e| e.to_string())?; + let progress = Arc::new(Mutex::new((0.0, "Starting".to_string()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = channel(); + let (j, p, c) = (job.clone(), progress.clone(), cancel.clone()); + let work = move || { + let r = j.run(&|f, stage| { + if let Ok(mut g) = p.lock() { + *g = (f, stage.to_string()); + } + !c.load(Ordering::Relaxed) + }); + let _ = tx.send(r); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("photo-merge".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx }) +} + +/// Open the merge dialog for the selection. +pub fn open(app: &mut LightcraftApp, command: &str) -> Result { + let ids = app.session.targets(&json!({})); + if ids.len() < 2 { + return Err("select at least 2 photos to merge".into()); + } + app.merge.ids = ids; + app.merge.error = None; + app.merge.preview = None; + app.merge.preview_pixels = None; + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) }); + Ok(json!({"photos": app.merge.ids.len()})) +} + +/// Start the full-resolution merge (the dialog's Merge button / `ui.dialog.confirm`). +pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result { + if app.merge.final_task.is_some() { + return Err("a merge is already running".into()); + } + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + let ids = app.merge.ids.clone(); + let task = spawn(app, opts, &ids, false)?; + app.merge.final_task = Some(task); + Ok(json!({"started": true, "photos": ids.len()})) +} + +/// Per frame: keep the preview in sync with the dialog's options, collect finished jobs. +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + // preview for the open dialog + let dialog_opts = match &app.ui.dialog { + Some(crate::state::Dialog::Merge { opts }) => Some(opts.clone()), + _ => None, + }; + match &dialog_opts { + Some(opts) => { + let current = app.merge.preview_task.as_ref().map(|t| &t.options).or(app.merge.preview.as_ref().map(|p| &p.2)); + let stale = current != Some(opts) && !last_failed_matches(app, opts); + if stale { + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + let ids = app.merge.ids.clone(); + match spawn(app, opts, &ids, true) { + Ok(t) => { + app.merge.error = None; + app.merge.preview_task = Some(t); + } + Err(e) => { + app.merge.error = Some(e); + app.merge.failed_options = Some(opts.clone()); + } + } + } + } + None => { + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + } + } + if let Some(t) = &app.merge.preview_task { + match t.rx.try_recv() { + Ok(Ok(out)) => { + let t = app.merge.preview_task.take().expect("task"); + if let Some(img) = out.preview { + let color = Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let tex = ctx.load_texture("merge-preview", color.clone(), egui::TextureOptions::LINEAR); + app.merge.preview_pixels = Some(color); + app.merge.preview = Some((tex, [img.width, img.height], t.options, out.info)); + } + } + Ok(Err(e)) => { + let t = app.merge.preview_task.take().expect("task"); + if e != "cancelled" { + app.merge.error = Some(e); + app.merge.failed_options = Some(t.options); + } + } + Err(_) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), + } + } + // the final merge + if let Some(t) = &app.merge.final_task { + match t.rx.try_recv() { + Ok(r) => { + let t = app.merge.final_task.take().expect("task"); + let what = if t.options.command == "merge.panorama" { + "Panorama" + } else if t.options.command == "merge.hdrPanorama" { + "HDR panorama" + } else { + "HDR" + }; + match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) { + Ok(v) => { + app.merge.last_result = Some(v); + app.toast(ctx, format!("{what} merge added")); + } + Err(e) => { + app.ui.status = e.to_string(); + app.toast(ctx, format!("{what} merge failed: {e}")); + } + } + } + Err(_) => { + let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); + let now = ctx.input(|i| i.time); + app.ui.toast = Some((format!("Merging… {stage} {:.0}%", f * 100.0), now + 0.5)); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + } +} + +fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { + app.merge.failed_options.as_ref() == Some(opts) +} + +/// The dialog body (options on the left, preview on the right). +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + // preview + ui.vertical(|ui| { + let box_size = egui::vec2(520.0, 360.0); + let (rect, _) = ui.allocate_exact_size(box_size, egui::Sense::hover()); + ui.painter().rect_filled(rect, 4.0, t.canvas); + crate::widgets::register(ui.ctx(), "merge.preview", rect); + if let Some((tex, size, _, _)) = &app.merge.preview { + let s = (box_size.x / size[0] as f32).min(box_size.y / size[1] as f32); + let r = egui::Rect::from_center_size(rect.center(), egui::vec2(size[0] as f32 * s, size[1] as f32 * s)); + ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + } + if let Some(task) = &app.merge.preview_task { + let (f, stage) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); + ui.painter().text( + rect.center_bottom() - egui::vec2(0.0, 14.0), + egui::Align2::CENTER_CENTER, + format!("{stage}… {:.0}%", f * 100.0), + t.font(12.0), + t.text, + ); + } + if let Some(e) = &app.merge.error { + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, e, t.font(13.0), t.text); + } + let info = app.merge.preview.as_ref().map(|p| p.3.clone()); + if let Some(info) = info { + let txt = if opts.is_pano() { + format!( + "{} photos · {} · {:.0}° × {:.0}°", + info["used"].as_array().map_or(0, Vec::len), + info["projection"].as_str().unwrap_or(""), + info["fov"][0].as_f64().unwrap_or(0.0), + info["fov"][1].as_f64().unwrap_or(0.0) + ) + } else { + let ev: Vec = + info["ev"].as_array().map(|a| a.iter().filter_map(Value::as_f64).map(|v| format!("{v:+.1}")).collect()).unwrap_or_default(); + format!("{} photos · exposures {} EV", ev.len(), ev.join(" / ")) + }; + ui.label(egui::RichText::new(txt).color(t.text_dim)); + } + }); + ui.add_space(12.0); + // options + ui.vertical(|ui| { + ui.set_width(220.0); + if opts.is_pano() { + ui.label(egui::RichText::new("Projection").color(t.text_label)); + for (v, l) in [("auto", "Auto Select"), ("spherical", "Spherical"), ("cylindrical", "Cylindrical"), ("perspective", "Perspective")] { + if ui.radio(opts.projection == v, l).clicked() { + opts.projection = v.to_string(); + } + } + ui.add_space(4.0); + let mut bw = opts.boundary_warp; + if num(ui, &BOUNDARY_WARP, &mut bw) { + opts.boundary_warp = bw.round(); + } + ui.checkbox(&mut opts.fill_edges, "Fill Edges"); + ui.checkbox(&mut opts.auto_crop, "Auto Crop"); + ui.add_space(4.0); + } + if opts.is_hdr() { + ui.checkbox(&mut opts.align, "Auto Align"); + ui.label(egui::RichText::new("Deghost Amount").color(t.text_label)); + ui.horizontal(|ui| { + for (i, (v, l)) in [("none", "None"), ("low", "Low"), ("medium", "Med"), ("high", "High")].iter().enumerate() { + if crate::widgets::text_button(ui, &format!("mergeDeghost-{i}"), l, opts.deghost == *v).clicked() { + opts.deghost = v.to_string(); + } + } + }); + if opts.command == "merge.hdr" { + ui.add_enabled_ui(opts.deghost != "none", |ui| ui.checkbox(&mut opts.show_overlay, "Show Deghost Overlay")); + } + if opts.command == "merge.hdrPanorama" { + let mut b = opts.bracket as f64; + if num(ui, &BRACKET, &mut b) { + opts.bracket = b.round() as u32; + } + } + ui.add_space(4.0); + } + ui.checkbox(&mut opts.auto_settings, "Auto Settings"); + ui.checkbox(&mut opts.stack, "Create Stack"); + ui.add_space(8.0); + ui.label(egui::RichText::new(format!("{} photos", app.merge.ids.len())).color(t.text_dim)); + }); + }); +} + +const BOUNDARY_WARP: lightcraft_develop::ControlSpec = lightcraft_develop::ControlSpec { + id: "merge.boundaryWarp", + label: "Boundary Warp", + section: lightcraft_develop::Section::Light, + min: 0.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, +}; +const BRACKET: lightcraft_develop::ControlSpec = lightcraft_develop::ControlSpec { + id: "merge.bracket", + label: "Photos per bracket (0 = auto)", + section: lightcraft_develop::Section::Light, + min: 0.0, + max: 9.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, +}; + +fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) -> bool { + match crate::widgets::slider(ui, spec, *v, true, None).value { + Some(n) => { + *v = n; + true + } + None => false, + } +} diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index eb508ff22..3c09bf8d3 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -23,6 +23,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (Shift+G)"), ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), + ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), + ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), ] { if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { let _ = app.run(&format!("view.{id}"), json!({})); @@ -45,7 +47,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { pill_ui.spacing_mut().item_spacing.x = 3.0; let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { - let _ = app.run("photo.rate", json!({"rating": r})); + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); } pill_ui.add_space(6.0); let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); @@ -64,7 +68,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; paint(pill_ui.painter(), r.shrink(3.0), icon, c); if resp.clicked() { - let _ = app.run("photo.flag", json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }})); + let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.flag", params); } } // copy / paste settings @@ -102,7 +108,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .layout(egui::Layout::right_to_left(egui::Align::Center)), ); child.spacing_mut().item_spacing.x = 6.0; - if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) + && icon_button( + &mut child, + "autoAdvance", + Icon::ChevronRight, + vec2(30.0, 30.0), + app.ui.auto_advance, + true, + "Auto Advance after rating or flagging", + ) + .clicked() + { + let _ = app.run("view.autoAdvance", json!({})); + } + if app.ui.view == ViewMode::Survey { + // no zoom or thumbnail size in Survey + } else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { if icon_button( &mut child, "beforeAfter", diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs new file mode 100644 index 000000000..ef38fb1f4 --- /dev/null +++ b/crates/ui-egui/src/panels/compare.rs @@ -0,0 +1,339 @@ +//! Culling views: **Compare** (the "select" and a "candidate" side by side, with synced zoom and +//! pan) and **Survey** (the selected photos tiled). Rating, flag and label keys act on the active +//! photo only here, and with Auto Advance on they move to the next candidate / photo. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use lightcraft_catalog::{Flag, PhotoId}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::{Icon, paint}; +use crate::render::Slot; +use crate::state::{ViewMode, Zoom}; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Most photos a survey shows. +pub const SURVEY_MAX: usize = 48; + +/// Compare or Survey. +pub fn culling(app: &LightcraftApp) -> bool { + matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) +} + +/// The (select, candidate) pair, repaired or chosen if needed: the active photo against the next +/// selected photo, else against its neighbour in the view. +pub fn compare_pair(app: &mut LightcraftApp) -> Option<(PhotoId, PhotoId)> { + let exists = |app: &LightcraftApp, id: PhotoId| app.session.catalog.photo(id).is_some_and(|p| !p.deleted); + if let Some((a, b)) = app.ui.compare { + let (a, b) = (PhotoId(a), PhotoId(b)); + if a != b && exists(app, a) && exists(app, b) { + return Some((a, b)); + } + } + let vis = app.session.visible_cloned(); + let sel = app.session.active().or(vis.first().copied())?; + let cand = app.session.selection.ids.iter().copied().find(|x| *x != sel).or_else(|| { + let i = vis.iter().position(|x| *x == sel)?; + vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))).copied() + })?; + app.ui.compare = Some((sel.0, cand.0)); + Some((sel, cand)) +} + +/// Enter Compare with the current selection (the active photo becomes the select). +pub fn enter_compare(app: &mut LightcraftApp) -> Result { + app.ui.compare = None; + let (a, b) = compare_pair(app).ok_or("Compare needs at least two photos")?; + app.ui.view = ViewMode::Compare; + // the candidate is active: rating/flag keys judge it against the select + select_pair(app, a, b, b); + Ok(json!({"select": a.0, "candidate": b.0})) +} + +fn select_pair(app: &mut LightcraftApp, a: PhotoId, b: PhotoId, active: PhotoId) { + app.ui.compare = Some((a.0, b.0)); + let _ = app.session.execute("library.select", &json!({"ids": [a.0, b.0], "active": active.0})); +} + +/// Move the candidate `d` photos through the view (skipping the select); it becomes active. +pub fn compare_step(app: &mut LightcraftApp, d: isize) -> Result { + let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?; + let vis: Vec = app.session.visible_cloned().into_iter().filter(|x| *x != sel).collect(); + let Some(i) = vis.iter().position(|x| *x == cand).or(if vis.is_empty() { None } else { Some(0) }) else { return Ok(Value::Null) }; + let j = (i as isize + d).clamp(0, vis.len() as isize - 1) as usize; + select_pair(app, sel, vis[j], vis[j]); + Ok(json!({"candidate": vis[j].0})) +} + +pub fn swap(app: &mut LightcraftApp) -> Result { + let (a, b) = compare_pair(app).ok_or("nothing to compare")?; + let active = app.session.active().unwrap_or(b); + select_pair(app, b, a, active); + Ok(Value::Null) +} + +/// The candidate becomes the select; the next photo becomes the candidate. +pub fn make_select(app: &mut LightcraftApp) -> Result { + let (_, b) = compare_pair(app).ok_or("nothing to compare")?; + let vis = app.session.visible_cloned(); + let i = vis.iter().position(|x| *x == b).unwrap_or(0); + let next = vis.iter().skip(i + 1).chain(vis.iter().take(i).rev()).copied().find(|x| *x != b).ok_or("no next photo")?; + select_pair(app, b, next, next); + Ok(json!({"select": b.0, "candidate": next.0})) +} + +/// The photos a survey shows: the selection (in view order), or the active photo alone. +pub fn survey_photos(app: &mut LightcraftApp) -> Vec { + let vis = app.session.visible_cloned(); + let sel = &app.session.selection; + let mut v: Vec = vis.iter().copied().filter(|x| sel.contains(*x)).collect(); + // selected photos hidden in the view (e.g. inside a collapsed stack) come last + let hidden: Vec = sel.ids.iter().copied().filter(|x| !v.contains(x) && app.session.catalog.photo(*x).is_some()).collect(); + v.extend(hidden); + if v.is_empty() { + v.extend(app.session.active()); + } + v.truncate(SURVEY_MAX); + v +} + +/// Move the active photo `d` steps within the survey. +pub fn survey_step(app: &mut LightcraftApp, d: isize) -> Result { + let v = survey_photos(app); + if v.is_empty() { + return Ok(Value::Null); + } + let i = app.session.selection.active.and_then(|a| v.iter().position(|x| *x == a)).unwrap_or(0); + let j = (i as isize + d).clamp(0, v.len() as isize - 1) as usize; + app.session.selection.active = Some(v[j]); + Ok(json!({"active": v[j].0})) +} + +/// After rating/flagging with Auto Advance: next candidate (Compare), next photo in the survey, +/// else the next photo in the view. +pub fn advance(app: &mut LightcraftApp) { + let _ = match app.ui.view { + ViewMode::Compare => compare_step(app, 1), + ViewMode::Survey => survey_step(app, 1), + _ => app.session.execute("library.next", &json!({})).map_err(|e| e.to_string()), + }; +} + +/// In the culling views, point a photo command at the active photo only (not the whole selection). +pub fn target_active(app: &LightcraftApp, params: &mut Value) { + if culling(app) + && params.get("ids").is_none() + && let Some(a) = app.session.active() + { + params["ids"] = json!([a.0]); + } +} + +fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect { + let t = Tokens::get(ui.ctx()); + let full = ui.max_rect(); + let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 }; + let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h)); + app.canvas_rect = Some(canvas); + if app.ui.filmstrip { + super::detail::filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); + } + canvas +} + +/// Draw one photo fitted into `area` (rendered at its display size into `slot`), with its caption +/// strip below. Returns the image rect and the click/drag response. +fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) { + let t = Tokens::get(ui.ctx()); + let ppp = ui.ctx().pixels_per_point(); + let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); + let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) }; + let caption_h = 26.0; + let img_area = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - caption_h)); + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + photo.width.max(1) as usize, + photo.height.max(1) as usize, + &photo.develop, + true, + photo.embedded_lens.as_ref(), + ); + let aspect = frame.aspect() as f32; + let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; + let img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan); + let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).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, true) { + app.renderer.request(slot, job, 60); + } + if let Some(job) = app.session.thumb_job(id, 256) { + app.renderer.request(Slot::Thumb(id), job, 30); + } + let p = ui.painter_at(img_area); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + if let Some(tex) = app.renderer.textures.get(&slot).filter(|x| x.photo == id) { + p.image(tex.tex.id(), img, uv, Color32::WHITE); + } else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) { + p.image(tex.tex.id(), img, uv, Color32::WHITE); + } else { + p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38)); + } + if photo.flag == Flag::Reject { + p.rect_filled(img, 0.0, Color32::from_black_alpha(110)); + } + let active = app.session.selection.active == Some(id); + if active { + p.rect_stroke(img.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + } + // caption: label · file name · stars · flag + // caption right under the image (at the bottom when zoomed in) + let cap_top = (img.bottom() + 2.0).min(img_area.bottom()); + let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h)); + let pt = ui.painter(); + let mut x = cap.left() + 4.0; + if !label.is_empty() { + let g = pt.layout_no_wrap(label.to_string(), t.semibold(12.0), if active { t.text } else { t.text_label }); + pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g.clone(), t.text); + x += g.size().x + 10.0; + } + let name = photo.copy_name.as_ref().map(|c| format!("{} ({c})", photo.file_name)).unwrap_or(photo.file_name.clone()); + let g = pt.layout_no_wrap(name, t.font(11.5), t.text_dim); + let name_w = g.size().x; + if x + name_w + 110.0 < cap.right() { + pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g, t.text_dim); + x += name_w + 10.0; + } + for i in 0..5u8 { + let filled = i < photo.rating; + let r = Rect::from_min_size(pos2(x + i as f32 * 13.0, cap.center().y - 6.0), vec2(12.0, 12.0)); + if r.right() < cap.right() { + paint(pt, r, if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); + } + } + x += 5.0 * 13.0 + 4.0; + let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0)); + match photo.flag { + Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick), + Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), + _ => {} + } + register(ui.ctx(), format!("cull:{}", id.0), img); + (img, resp) +} + +fn slot_index(s: Slot) -> u8 { + match s { + Slot::Compare(i) => i, + _ => 255, + } +} + +/// Drag-to-pan when zoomed in (shared by both compare panes: zoom and pan are synced). +fn pan(app: &mut LightcraftApp, resp: &egui::Response, img: Rect) { + if app.ui.zoom != Zoom::Fit && resp.dragged() { + let d = resp.drag_delta(); + let (px, py) = app.ui.pan; + app.ui.pan = ((px - d.x / img.width().max(1.0)).clamp(0.0, 1.0), (py - d.y / img.height().max(1.0)).clamp(0.0, 1.0)); + } +} + +pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let canvas = area_and_filmstrip(app, ui); + let Some((sel, cand)) = compare_pair(app) else { + super::empty_message(ui, canvas, "Nothing to compare", "Select two photos, then choose View → Compare (Shift+C)"); + return; + }; + let area = canvas.shrink(18.0); + let half = (area.width() - 16.0) / 2.0; + let panes = [ + (sel, Rect::from_min_size(area.min, vec2(half, area.height())), "Select", 0u8), + (cand, Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height())), "Candidate", 1u8), + ]; + let zoom = app.ui.zoom; + for (id, r, label, i) in panes { + let (img, resp) = photo_tile(app, ui, id, Slot::Compare(i), r, label, zoom); + if i == 1 { + app.image_rect = Some(img); + } + pan(app, &resp, img); + if resp.clicked() { + select_pair(app, sel, cand, id); + } + if resp.double_clicked() { + app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit }; + } + resp.context_menu(|ui| { + if ui.button("Swap").clicked() { + let _ = swap(app); + } + if ui.button("Make Candidate the Select").clicked() { + let _ = make_select(app); + } + ui.separator(); + super::grid::context_menu(app, ui, id); + }); + } + let mid = area.center().x; + ui.painter().line_segment([pos2(mid, area.top()), pos2(mid, area.bottom())], Stroke::new(1.0, Tokens::get(ui.ctx()).divider)); +} + +/// Columns for `n` tiles of aspect ~3:2 in `area`, maximizing the tile size. +pub fn survey_columns(n: usize, area: Rect) -> usize { + let mut best = (1, 0.0f32); + for cols in 1..=n.max(1) { + let rows = n.div_ceil(cols); + let w = area.width() / cols as f32; + let h = area.height() / rows as f32; + let s = w.min(h * 1.5); + if s > best.1 { + best = (cols, s); + } + } + best.0 +} + +pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let canvas = area_and_filmstrip(app, ui); + let photos = survey_photos(app); + if photos.is_empty() { + super::empty_message(ui, canvas, "Nothing to survey", "Select photos, then choose View → Survey (N)"); + return; + } + let area = canvas.shrink(14.0); + let cols = survey_columns(photos.len(), area); + let rows = photos.len().div_ceil(cols); + let (cw, ch) = (area.width() / cols as f32, area.height() / rows as f32); + for (i, id) in photos.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let cell = Rect::from_min_size(pos2(area.left() + c as f32 * cw, area.top() + r as f32 * ch), vec2(cw, ch)).shrink(6.0); + let (img, resp) = photo_tile(app, ui, *id, Slot::Compare(i as u8), cell, "", Zoom::Fit); + if app.session.selection.active == Some(*id) { + app.image_rect = Some(img); + } + if resp.clicked() { + app.session.selection.active = Some(*id); + } + if resp.double_clicked() { + let _ = app.run("library.select", json!({"ids": [id.0]})); + app.ui.view = ViewMode::Detail; + } + // remove from the survey (deselect) on hover + if resp.hovered() && photos.len() > 1 { + let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); + let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text("Remove from survey"); + ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); + paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); + if xresp.clicked() { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "toggle"})); + } + } + resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); + } + let n = photos.len(); + let msg = + if app.session.selection.ids.len() > SURVEY_MAX { format!("Showing {n} of {}", app.session.selection.ids.len()) } else { String::new() }; + if !msg.is_empty() { + ui.painter().text(pos2(canvas.right() - 16.0, canvas.top() + 10.0), Align2::RIGHT_TOP, msg, t.font(12.0), t.text_dim); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 5abcca6a0..54e2575ef 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -28,6 +28,10 @@ pub enum Gesture { start: lightcraft_geom::Rect, angle: f64, }, + /// Straighten tool: a line drawn along something that should be level (or plumb). + StraightenLine { + a: Pos2, + }, CropRotate { start_angle: f64, a0: f32, @@ -44,6 +48,15 @@ pub enum Gesture { Guide { a: Point, }, + /// Red Eye: an ellipse being dragged from corner `a` (normalized). + Eye { + a: Point, + }, + /// Targeted adjustment drag from `at` (normalized); `acc` = vertical travel not yet applied. + Targeted { + at: Point, + acc: f32, + }, } /// Screen ↔ normalized-image mapping for the displayed frame. @@ -71,7 +84,7 @@ impl CanvasMap { } } -fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { +pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { let (aw, ah) = (area.width(), area.height()); let (w, h) = match zoom { Zoom::Fit => { @@ -124,15 +137,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); @@ -144,6 +167,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; + let job = job.with_overlay(view_overlay(app, &d)); app.renderer.request(Slot::Main, job, 100); } // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and @@ -174,7 +198,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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); @@ -221,6 +245,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); } @@ -231,6 +263,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), + RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), _ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect), } resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); @@ -248,6 +281,51 @@ fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { } } +/// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or +/// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all +/// in one interaction = one undo step). +fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, target: &str) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + if resp.drag_started() + && let Some(q) = resp.interact_pointer_pos() + { + let _ = app.run("develop.beginInteraction", json!({"label": "Targeted Adjustment"})); + app.gesture = Some(Gesture::Targeted { at: map.norm(q), acc: 0.0 }); + } + if resp.dragged() + && let Some(Gesture::Targeted { at, acc }) = &mut app.gesture + { + *acc -= resp.drag_delta().y * 0.5; + let step = acc.trunc(); + if step != 0.0 { + *acc -= step; + let at = *at; + let mut p = json!({"target": target, "x": at.x, "y": at.y, "delta": step}); + if target == "curve" && app.ui.curve_channel != "parametric" { + p["channel"] = json!(app.ui.curve_channel); + } + let _ = app.run("develop.targeted", p); + } + } + if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::Targeted { .. })) { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } +} + +/// The diagnostic overlay the loupe shows (Point Color's visualized range). +fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { + use lightcraft_pipeline::Overlay; + if app.ui.right == RightPanel::Remove && app.ui.visualize_spots { + return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8); + } + let edit = app.ui.right == RightPanel::Edit; + if edit && app.ui.point_color_visualize && app.ui.flyout_open("pointColor") && app.ui.point_color < d.point_colors.len() { + return Overlay::PointColorRange(app.ui.point_color as u8); + } + Overlay::None +} + 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()) { @@ -282,6 +360,23 @@ fn general_interaction( } return; } + if let Some(target) = app.ui.tool.strip_prefix("tat:").map(str::to_string) { + targeted_drag(app, ui, resp, map, &target); + return; + } + if app.ui.tool == "pointColor" { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + if let Ok(r) = app.run("pointColor.pick", json!({"x": n.x, "y": n.y})) { + app.ui.point_color = r["index"].as_u64().unwrap_or(0) as usize; + } + app.ui.tool.clear(); + } + return; + } // click toggles Fit ↔ 100 % at the clicked point; drag pans when zoomed let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; if resp.double_clicked() || (resp.clicked() && !zoomed) { @@ -312,6 +407,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons guided_overlay(app, ui, resp, map, frame, d); return; } + if app.ui.tool == "straighten" { + straighten_overlay(app, ui, resp); + return; + } let quad = frame_crop_quad(d, frame); let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); let p = ui.painter(); @@ -699,9 +798,67 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo } } +// ------------------------------------------------------------------------ red eye + +/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. +fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + // screen points per long-edge unit + let o = map.screen(Point::new(0.0, 0.0)); + let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o)); + let per_long = sx.max(sy); + let p = ui.painter(); + for (i, e) in d.red_eye.iter().enumerate() { + let c = map.screen(e.center); + let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long); + let sel = i == app.ui.eye; + let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) }; + p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col))); + p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col)); + p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col)); + } + if resp.hover_pos().is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + // from where the button went down (a drag is recognized only after some movement) + app.gesture = Some(Gesture::Eye { a: map.norm(q) }); + } + if let Some(Gesture::Eye { a }) = app.gesture + && let Some(q) = resp.interact_pointer_pos() + { + let (a, b) = (map.screen(a), q); + p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE))); + } + if resp.drag_stopped() + && let Some(Gesture::Eye { a }) = app.gesture.take() + && let Some(q) = resp.interact_pointer_pos() + { + let (sa, sb) = (map.screen(a), q); + let half = ((sb - sa) / 2.0).abs() / per_long; + if half.x > 0.002 && half.y > 0.002 { + let c = map.norm(sa.lerp(sb, 0.5)); + let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet})); + if let Ok(v) = r { + app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize; + } + } + } else if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + && let Some(i) = d.red_eye.iter().position(|e| { + let c = map.screen(e.center); + let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0)); + dx * dx + dy * dy <= 1.0 + }) + { + app.ui.eye = i; + } +} + // ------------------------------------------------------------------------ filmstrip -fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { +pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(r, 0.0, t.canvas); ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); @@ -749,6 +906,15 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { if sel { p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); } + if let Some(st) = app.session.catalog.stack_of(*id) { + let text = + if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; + let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); + let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); + p.rect_filled(br, 7.5, Color32::from_black_alpha(170)); + crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + } } if resp.clicked() { let m = ui.input(|i| i.modifiers); @@ -764,3 +930,41 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } }); } + +/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. +/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. +fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.double_clicked() { + let _ = app.run("crop.autoStraighten", json!({})); + app.ui.tool.clear(); + app.gesture = None; + return; + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + app.gesture = Some(Gesture::StraightenLine { a: q }); + } + let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return }; + let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + let p = ui.painter(); + p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140))); + p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + if resp.drag_stopped() { + app.gesture = None; + let v = b - a; + if v.length() > 8.0 { + let mut deg = v.y.atan2(v.x).to_degrees() as f64; + // fold to the nearest axis: a line closer to vertical straightens plumb lines + while deg > 45.0 { + deg -= 90.0; + } + while deg < -45.0 { + deg += 90.0; + } + let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0})); + } + app.ui.tool.clear(); + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0269aad2c..0ac333088 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,9 +22,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { folder: true, .. } => "Create Folder", Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", + Dialog::NewSmartAlbum { .. } => "Create Smart Album", + Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", Dialog::CreatePreset { .. } => "Create Preset", Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", Dialog::Export { .. } => "Export", + Dialog::Merge { opts } => opts.title(), Dialog::About => "About LightCraft", Dialog::Shortcuts => "Keyboard Shortcuts", }; @@ -38,6 +41,35 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; match &mut dlg { + Dialog::AutoStack { gap } => { + ui.label(egui::RichText::new("Stack photos taken within this time of each other:").color(t.text_label)); + ui.add( + egui::Slider::new(gap, 0.0..=86400.0) + .logarithmic(true) + .smallest_positive(1.0) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()), + ); + let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); + let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; + ui.label( + egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim), + ); + } + Dialog::NewSmartAlbum { name } => { + let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); + r.request_focus(); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + confirm = true; + } + let rules = app.session.view_rules(); + let n = app.session.catalog.query(&rules, &Default::default()).len(); + ui.label(egui::RichText::new(format!("Matches: {}", rules.describe())).color(t.text_label)); + ui.label( + egui::RichText::new(format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" })) + .color(t.text_dim), + ); + } Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); r.request_focus(); @@ -156,6 +188,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); field(ui, "Folder", |ui| ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY))); } + Dialog::Merge { opts } => crate::merge::body(app, ui, opts), Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); @@ -180,6 +213,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(); + } }); }); } @@ -190,7 +235,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if !informational && ui.button("Cancel").clicked() { close = true; } - if ui.button(if informational { "Close" } else { "OK" }).clicked() { + let ok = match dlg { + Dialog::Merge { .. } => "Merge", + _ if informational => "Close", + _ => "OK", + }; + if ui.button(ok).clicked() { if informational { close = true; } else { @@ -213,10 +263,25 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } /// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// `90` → `1 min 30 s`. +pub fn fmt_gap(v: f64) -> String { + let v = v.round() as u64; + match v { + 0..60 => format!("{v} s"), + 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), + 60..3600 => format!("{} min {} s", v / 60, v % 60), + 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), + 3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60), + _ => "1 day".into(), + } +} + pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { match dlg { Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), + Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), Dialog::CreatePreset { name, group } => { app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})) } @@ -229,6 +294,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 0ffd398a9..239316a6f 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -36,7 +36,7 @@ fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: let Some(spec) = controls::find(id) else { return }; let v = controls::get(d, id).unwrap_or(spec.default); let out = slider(ui, spec, v, enabled, None); - apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v}))); + apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": id, "value": v}))); } /// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift. @@ -177,6 +177,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if open { mixer(app, ui, d); } + if !crate::is_bw(d) { + let open = app.ui.flyout_open("pointColor"); + if flyout_row(ui, "pointColor", "Point Color", Icon::Picker, open).clicked() { + app.ui.toggle_flyout("pointColor"); + } + if open { + point_color(app, ui, d); + } + } let open = app.ui.flyout_open("grading"); if flyout_row(ui, "grading", "Color Grading", Icon::Presets, open).clicked() { app.ui.toggle_flyout("grading"); @@ -191,6 +200,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { control(app, ui, d, c, true); } sub_title(ui, "Vignette"); + { + use lightcraft_develop::VignetteStyle as V; + let styles = [ + (V::HighlightPriority, "Highlight", "highlightPriority"), + (V::ColorPriority, "Color", "colorPriority"), + (V::PaintOverlay, "Paint", "paintOverlay"), + ]; + let items: Vec<(&str, &str)> = styles.iter().map(|(_, l, k)| (*l, *k)).collect(); + let active = styles.iter().position(|(v, _, _)| *v == d.vignette.style); + let mut chosen = None; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + chosen = crate::widgets::segmented(ui, "vignetteStyle", &items, active, 3); + }); + if let Some(i) = chosen { + let _ = app.run( + "develop.merge", + serde_json::json!({"settings": {"vignette": {"style": serde_json::to_value(styles[i].0).unwrap_or_default()}}}), + ); + } + } for c in ["vignette.amount", "vignette.midpoint", "vignette.feather", "vignette.roundness", "vignette.highlights"] { let enabled = c == "vignette.amount" || d.vignette.amount != 0.0; control(app, ui, d, c, enabled); @@ -261,10 +290,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } ui.add_space(8.0); }); + // Lightroom Classic's Calibration panel (the cloud app hides it): last, like there. + section(app, ui, &d, "calibration", "Calibration", |app, ui, d| { + sub_title(ui, "Shadows"); + control(app, ui, d, "calibration.shadowsTint", true); + for (title, k) in [("Red Primary", "red"), ("Green Primary", "green"), ("Blue Primary", "blue")] { + sub_title(ui, title); + control(app, ui, d, &format!("calibration.{k}Hue"), true); + control(app, ui, d, &format!("calibration.{k}Sat"), true); + } + ui.add_space(8.0); + }); ui.add_space(40.0); } -fn sub_title(ui: &mut egui::Ui, title: &str) { +pub fn sub_title(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); @@ -301,7 +341,15 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let p = ui.painter(); p.rect_filled(r, 0.0, t.button); let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0)); - if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.clone()) { + // the loupe render of this photo, else its thumbnail (grid view before the loupe has rendered it) + let hist = app + .renderer + .textures + .get(&Slot::Main) + .filter(|t| t.photo == id) + .and_then(|t| t.histogram.clone()) + .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())); + if let Some(h) = hist { let smooth = |v: &[u32]| -> Vec { (0..256) .map(|i| { @@ -402,6 +450,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev app.ui.curve_channel = ch.into(); } } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + tat_button(app, ui, "tat:curve", "Targeted adjustment: drag up/down on the photo to adjust the curve there"); + }); }); }); let ch = app.ui.curve_channel.clone(); @@ -452,6 +503,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] { control(app, ui, d, c, true); } + control(app, ui, d, "curve.refineSaturation", true); return; } let pts_of = |d: &DevelopSettings| -> Vec { @@ -543,9 +595,19 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let _ = app.run("develop.endInteraction", json!({})); } ui.data_mut(|dd| dd.insert_temp(drag_id, dragging)); + control(app, ui, d, "curve.refineSaturation", true); let _ = id; } +/// Toggle for a targeted-adjustment tool (`tool` = `tat:`). +fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) { + let active = app.ui.tool == tool; + let id = tool.replace(':', "-"); + if crate::widgets::icon_button(ui, &id, Icon::Target, vec2(26.0, 26.0), active, true, tip).clicked() { + app.ui.tool = if active { String::new() } else { tool.to_string() }; + } +} + // ------------------------------------------------------------------------------ colour mixer fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { @@ -569,6 +631,26 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { } }); }); + // targeted adjustment: pick the attribute, then drag on the photo + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); + paint(ui.painter(), r, Icon::Target, t.icon); + let items: &[(&str, &str)] = + if crate::is_bw(d) { &[("lum", "B&W Mix")] } else { &[("hue", "Hue"), ("sat", "Saturation"), ("lum", "Luminance")] }; + for (k, label) in items { + let tool = format!("tat:{k}"); + let active = app.ui.tool == tool; + if text_button(ui, &format!("tatMixer-{k}"), label, active) + .on_hover_text("Targeted adjustment: drag up/down on the photo to adjust the colours there") + .clicked() + { + app.ui.tool = if active { String::new() } else { tool }; + } + } + }); + }); let b = bands[sel]; if crate::is_bw(d) { control(app, ui, d, &format!("bw.{b}"), true); @@ -579,6 +661,77 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { } } +// ------------------------------------------------------------------------------ point color + +/// Display colour of an OkLCh sample (lightness, chroma, hue in degrees). +fn oklch_color(l: f64, c: f64, h_deg: f64) -> Color32 { + use lightcraft_color::perceptual::{lch_to_lab, oklab_to_2020}; + let lin = oklab_to_2020(lch_to_lab([l as f32, c as f32, (h_deg as f32).to_radians()])); + let s = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).apply_f32(lin); + let e = s.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8); + Color32::from_rgb(e[0], e[1], e[2]) +} + +/// Point Color: swatches of the samples (+ the eyedropper), the selected sample's shifts and range, +/// and "Visualize range". +fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + let n = d.point_colors.len(); + if app.ui.point_color >= n && n > 0 { + app.ui.point_color = n - 1; + } + let sel = app.ui.point_color; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + let active = app.ui.tool == "pointColor"; + let full = n >= lightcraft_develop::MAX_POINT_COLORS; + if crate::widgets::icon_button(ui, "pointColorPicker", Icon::Picker, vec2(26.0, 26.0), active, !full, "Sample a colour on the photo") + .clicked() + { + app.ui.tool = if active { String::new() } else { "pointColor".into() }; + } + for (i, p) in d.point_colors.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + register(ui.ctx(), format!("pointColor:{i}"), r); + ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); + if i == sel { + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + } + if resp.clicked() { + app.ui.point_color = i; + } + } + }); + }); + if n == 0 { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| { + ui.label(egui::RichText::new("Pick a colour on the photo with the eyedropper.").size(12.0).color(t.text_dim)); + }); + return; + } + for k in ["hueShift", "satShift", "lumShift", "variance"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + sub_title(ui, "Range"); + for k in ["range", "hueRange", "satRange", "lumRange"] { + control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| { + ui.horizontal(|ui| { + let mut v = app.ui.point_color_visualize; + if ui.checkbox(&mut v, "Visualize range").changed() { + app.ui.point_color_visualize = v; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, "pointColorDelete", "Delete", false).clicked() { + let _ = app.run("pointColor.delete", json!({"index": sel})); + } + }); + }); + }); +} + // ------------------------------------------------------------------------------ colour grading fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs new file mode 100644 index 000000000..7dcea37b0 --- /dev/null +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -0,0 +1,255 @@ +//! The library filter bar above the grid: rating (≥ / = / ≤), flags, colour labels, kind, +//! edited state and camera / lens / keyword pickers — every control drives `library.filter`. +//! "Save as Smart Album…" keeps the current view as a live album; "Clear" resets the filter. + +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, vec2}; +use lightcraft_catalog::{ColorLabel, Flag, MediaKind, RatingOp}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; +use crate::widgets::register; + +pub const HEIGHT: f32 = 40.0; +/// Bar width (points) below which the controls take two rows. +const WIDE: f32 = 1240.0; + +/// Colour-label swatches (UI colours, our own choice). +pub fn label_color(l: ColorLabel) -> Color32 { + match l { + ColorLabel::Red => Color32::from_rgb(222, 72, 72), + ColorLabel::Yellow => Color32::from_rgb(232, 196, 58), + ColorLabel::Green => Color32::from_rgb(88, 176, 92), + ColorLabel::Blue => Color32::from_rgb(72, 130, 222), + ColorLabel::Purple => Color32::from_rgb(158, 100, 210), + } +} + +fn filter(app: &mut LightcraftApp, patch: Value) { + let _ = app.run("library.filter", patch); +} + +/// A small label before a group of controls. +fn caption(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).font(t.font(12.0)).color(t.text_dim)); +} + +/// A square toggle with an icon or a colour swatch. +fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + register(ui.ctx(), format!("filter:{id}"), r); + let p = ui.painter(); + if on { + p.rect_filled(r, 5.0, t.tool_active); + p.rect_stroke(r, 5.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } else if resp.hovered() { + p.rect_filled(r, 5.0, t.hover); + } + draw( + p, + r.shrink(5.0), + if on { + t.text + } else if resp.hovered() { + t.text_label + } else { + t.icon + }, + ); + resp.on_hover_text(tip) +} + +/// A dropdown showing `current`; `items` are (label, filter patch, selected). +fn picker(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, current: &str, active: bool, items: Vec<(String, Value, bool)>) { + let t = Tokens::get(ui.ctx()); + let r = crate::widgets::dropdown(ui, &format!("filter-{id}"), current, t.font(12.5), if active { t.text } else { t.text_label }); + if active { + ui.painter().rect_stroke(r.rect.expand(2.0), 4.0, Stroke::new(1.0, t.accent.gamma_multiply(0.7)), StrokeKind::Outside); + } + let mut chosen = None; + egui::Popup::menu(&r).show(|ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (label, patch, sel) in &items { + if ui.selectable_label(*sel, label).clicked() { + chosen = Some(patch.clone()); + } + } + }); + }); + if let Some(p) = chosen { + filter(app, p); + } +} + +fn distinct(mut v: Vec) -> Vec { + v.retain(|s| !s.trim().is_empty()); + v.sort_by_key(|s| s.to_lowercase()); + v.dedup(); + v +} + +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + // one row when there is room, else the metadata pickers and actions go to a second row + let two_rows = ui.available_width() < WIDE; + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), if two_rows { 2.0 * HEIGHT - 8.0 } else { HEIGHT }), Sense::hover()); + ui.painter().rect_filled(bar, 0.0, t.chrome); + ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.divider)); + register(ui.ctx(), "filterbar", bar); + let f = app.session.filter.clone(); + let row = |ui: &mut egui::Ui, i: f32| { + let r = Rect::from_min_size(bar.min + vec2(16.0, 4.0 + i * (HEIGHT - 8.0)), vec2(bar.width() - 32.0, HEIGHT - 8.0)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::left_to_right(egui::Align::Center))); + c.spacing_mut().item_spacing.x = 4.0; + c + }; + let mut row1 = row(ui, 0.0); + let mut row2 = row(ui, 1.0); + let ui = &mut row1; + + // rating: operator + stars + caption(ui, "Rating"); + let op_label = match f.rating_op { + RatingOp::AtLeast => "≥", + RatingOp::Exactly => "=", + RatingOp::AtMost => "≤", + }; + picker( + app, + ui, + "ratingOp", + op_label, + false, + [("≥ at least", "atLeast", RatingOp::AtLeast), ("= exactly", "exactly", RatingOp::Exactly), ("≤ at most", "atMost", RatingOp::AtMost)] + .into_iter() + .map(|(l, k, op)| (l.to_string(), json!({"ratingOp": k}), f.rating_op == op)) + .collect(), + ); + for i in 1..=5u8 { + let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); + register(ui.ctx(), format!("filter:star{i}"), r); + let filled = i <= f.rating; + let c = if filled { + t.star + } else if resp.hovered() { + t.text_label + } else { + t.icon + }; + paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if filled { Icon::StarFilled } else { Icon::Star }, c); + if resp.clicked() { + filter(app, json!({"rating": if f.rating == i { 0 } else { i }})); + } + } + ui.add_space(14.0); + + // flags (one at a time; click again to clear) + caption(ui, "Flag"); + for (id, icon, flag, tip) in [ + ("pick", Icon::FlagPick, Flag::Pick, "Picked"), + ("reject", Icon::FlagReject, Flag::Reject, "Rejected"), + ("none", Icon::Circle, Flag::None, "Unflagged"), + ] { + let on = f.flag == Some(flag); + let color = match flag { + Flag::Pick if on => t.pick, + Flag::Reject if on => t.reject, + _ => Color32::TRANSPARENT, + }; + let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color })); + if resp.clicked() { + filter(app, json!({"flag": if on { Value::Null } else { json!(id) }})); + } + } + ui.add_space(14.0); + + // colour labels + caption(ui, "Label"); + for l in ColorLabel::ALL { + let on = f.label == Some(l); + let name = format!("{l:?}").to_lowercase(); + let resp = toggle(ui, &format!("label-{name}"), on, &format!("{l:?} label"), |p, r, _| { + p.circle_filled(r.center(), 5.5, label_color(l)); + }); + if resp.clicked() { + filter(app, json!({"label": if on { Value::Null } else { json!(name) }})); + } + } + ui.add_space(14.0); + + // kind + edited + let kind_label = match f.kind { + None => "All types", + Some(MediaKind::Image) => "Photos", + Some(MediaKind::Raw) => "Raw", + Some(MediaKind::Video) => "Videos", + }; + picker( + app, + ui, + "kind", + kind_label, + f.kind.is_some(), + [ + ("All types", Value::Null, None), + ("Photos", json!("image"), Some(MediaKind::Image)), + ("Raw", json!("raw"), Some(MediaKind::Raw)), + ("Videos", json!("video"), Some(MediaKind::Video)), + ] + .into_iter() + .map(|(l, v, k)| (l.to_string(), json!({"kind": v}), f.kind == k)) + .collect(), + ); + let edited_label = match f.edited { + None => "Edited or not", + Some(true) => "Edited", + Some(false) => "Unedited", + }; + picker( + app, + ui, + "edited", + edited_label, + f.edited.is_some(), + [("Edited or not", Value::Null, None), ("Edited", json!(true), Some(true)), ("Unedited", json!(false), Some(false))] + .into_iter() + .map(|(l, v, e)| (l.to_string(), json!({"edited": v}), f.edited == e)) + .collect(), + ); + + // metadata pickers + let ui: &mut egui::Ui = if two_rows { &mut row2 } else { &mut row1 }; + let photos: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).cloned().collect(); + let cameras = distinct(photos.iter().map(|p| p.meta.camera.clone()).collect()); + let lenses = distinct(photos.iter().map(|p| p.meta.lens.clone()).collect()); + let keywords: Vec = app.session.catalog.keywords().into_iter().map(|(k, _)| k).collect(); + for (id, key, all_label, current, values) in [ + ("camera", "camera", "Camera", f.camera.clone(), cameras), + ("lens", "lens", "Lens", f.lens.clone(), lenses), + ("keyword", "keyword", "Keyword", f.keyword.clone(), keywords), + ] { + let mut items = vec![(format!("Any {}", all_label.to_lowercase()), json!({key: Value::Null}), current.is_none())]; + items.extend(values.into_iter().map(|v| (v.clone(), json!({key: v.clone()}), current.as_deref() == Some(v.as_str())))); + let shown = current.clone().unwrap_or_else(|| all_label.to_string()); + let shown = if shown.chars().count() > 22 { format!("{}…", shown.chars().take(21).collect::()) } else { shown }; + picker(app, ui, id, &shown, current.is_some(), items); + } + + // save / clear + ui.add_space(14.0); + let filtering = app.session.filter != Default::default(); + let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Save as Smart Album…").font(t.font(12.5)))); + register(ui.ctx(), "button:filterSave", save.rect); + if save.clicked() { + let _ = app.run("dialog.newSmartAlbum", json!({})); + } + let clear = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new("Clear").font(t.font(12.5)))); + register(ui.ctx(), "button:filterClear", clear.rect); + if clear.clicked() { + app.ui.search.clear(); + let _ = app.run("library.clearFilter", json!({})); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 434248daa..9886c8f14 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -36,9 +36,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let sel_n = app.session.selection.ids.len(); let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) }; ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); + if app.ui.filter_bar { + super::filterbar::show(app, ui); + } app.canvas_rect = Some(ui.max_rect()); if ids.is_empty() { - super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here"); + if app.session.filter != Default::default() { + super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)"); + } else { + super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here"); + } return; } let ppp = ui.ctx().pixels_per_point(); @@ -106,6 +113,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(key, active)); if last != active { active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect)) } else { None } }; + let stacks = app.session.catalog.stack_index(); let mut visible_ids = HashSet::new(); egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover()); @@ -122,6 +130,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let r = c.rect.translate(origin.to_vec2()); let onscreen = c.rect.intersects(viewport); cell(app, ui, c.id, r, square, onscreen, ppp); + if let Some((sid, pos)) = stacks.get(&c.id) { + stack_badge(app, ui, c.id, *sid, *pos, r, square); + } } }); app.renderer.evict_thumbs(&visible_ids, 600); @@ -212,6 +223,23 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label); } } + if let Some(name) = &photo.copy_name { + // virtual copy: a folded-corner tag at the top right + let g = p.layout_no_wrap(name.clone(), t.semibold(10.5), Color32::WHITE); + let tr = if square { img_rect.right_top() + vec2(-6.0, 6.0) } else { img_rect.right_top() + vec2(-5.0, 5.0) }; + let br = Rect::from_min_max(pos2(tr.x - g.size().x - 26.0, tr.y), pos2(tr.x, tr.y + 20.0)); + p.rect_filled(br, 10.0, Color32::from_black_alpha(165)); + let c = pos2(br.left() + 13.0, br.center().y); + let s = 5.0; + p.add(egui::Shape::convex_polygon( + vec![c + vec2(-s, -s), c + vec2(s * 0.3, -s), c + vec2(s, -s * 0.3), c + vec2(s, s), c + vec2(-s, s)], + Color32::TRANSPARENT, + Stroke::new(1.2, Color32::WHITE), + )); + p.line_segment([c + vec2(s * 0.3, -s), c + vec2(s * 0.3, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.line_segment([c + vec2(s * 0.3, -s * 0.3), c + vec2(s, -s * 0.3)], Stroke::new(1.2, Color32::WHITE)); + p.galley(pos2(br.left() + 22.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + } if photo.flag == Flag::Reject { p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110)); } @@ -234,6 +262,36 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square resp.context_menu(|ui| context_menu(app, ui, id)); } +/// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the +/// members of an expanded stack. Clicking it expands/collapses the stack. +pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.catalog.stack(sid) else { return }; + let n = st.photos.len(); + let collapsed = st.collapsed; + let text = if collapsed { n.to_string() } else { format!("{}/{n}", pos + 1) }; + let p = ui.painter(); + let g = p.layout_no_wrap(text, t.semibold(11.0), Color32::WHITE); + let origin = if square { r.min + vec2(10.0, 26.0) } else { r.min + vec2(5.0, 5.0) }; + let br = Rect::from_min_size(origin, vec2(g.size().x + 30.0, 20.0)); + let resp = ui.interact(br, egui::Id::new(("stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { + "Stack — click to expand (S)" + } else { + "Stack — click to collapse (S)" + }); + register(ui.ctx(), format!("stack:{}", id.0), br); + let fill = if resp.hovered() { Color32::from_black_alpha(220) } else { Color32::from_black_alpha(165) }; + p.rect_filled(br, 10.0, fill); + if pos == 0 && !collapsed { + p.rect_stroke(br, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + if resp.clicked() { + let _ = app.run("stack.toggle", json!({"ids": [id.0]})); + } +} + pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if !app.session.selection.contains(id) { let _ = app.run("library.select", json!({"ids": [id.0]})); @@ -257,13 +315,35 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.menu_button("Add to Album", |ui| { - let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder).map(|a| (a.id.0, a.name.clone())).collect(); + let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); for (aid, name) in albums { if ui.button(name).clicked() { let _ = app.run("album.addPhotos", json!({"id": aid})); } } }); + if ui.button("Create Virtual Copy").clicked() { + let _ = app.run("photo.virtualCopy", json!({})); + } + ui.menu_button("Stack", |ui| { + let stacked = app.session.catalog.stack_of(id).is_some(); + let several = app.session.selection.ids.len() > 1; + for (label, cmd, on) in [ + ("Group into Stack", "stack.group", several), + ("Ungroup Stack", "stack.ungroup", stacked), + ("Remove from Stack", "stack.remove", stacked), + ("Set as Top of Stack", "stack.setTop", stacked), + ("Expand/Collapse Stack", "stack.toggle", stacked), + ] { + if ui.add_enabled(on, egui::Button::new(label)).clicked() { + let _ = app.run(cmd, json!({})); + } + } + ui.separator(); + if ui.button("Auto-Stack by Capture Time…").clicked() { + let _ = app.run("dialog.autoStack", json!({})); + } + }); ui.separator(); if ui.button("Copy Edit Settings").clicked() { let _ = app.run("develop.copy", json!({})); @@ -274,6 +354,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button("Reset Edits").clicked() { let _ = app.run("develop.reset", json!({})); } + ui.menu_button("Photo Merge", |ui| { + let n = app.session.targets(&json!({})).len(); + for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { + if ui.add_enabled(n >= 2, egui::Button::new(label)).clicked() { + let _ = app.run(id, json!({})); + } + } + }); ui.separator(); if ui.button("Rotate Left").clicked() { let _ = app.run("photo.rotateLeft", json!({})); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3a4bc4950..48ecc1754 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -88,6 +88,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button("Create Album…").clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); } + if ui.button("Create Smart Album from Filter…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); + } if ui.button("Create Folder…").clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true }); } @@ -132,7 +135,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } } else { let sel = app.session.source == LibrarySource::Album(a.id); - let resp = row(app, ui, &format!("album:{}", a.id.0), Icon::Album, &a.name, Some(a.photos.len()), sel, indent); + let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; + let n = app.session.catalog.album_count(a.id); + let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &a.name, Some(n), sel, indent); + if let Some(rules) = &a.smart { + resp = resp.on_hover_text(format!("Smart album: {}", rules.describe())); + } if resp.clicked() { let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); } @@ -143,9 +151,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { - if !a.folder && ui.button("Add Selected Photos").clicked() { + if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() { let _ = app.run("album.addPhotos", json!({"id": a.id.0})); } + if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() { + let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})); + } if ui.button("Rename…").clicked() { app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() }); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 66a6eb07a..76cbb0116 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,9 +1,11 @@ //! Window regions and panels. pub mod bottombar; +pub mod compare; pub mod detail; pub mod dialogs; pub mod edit; +pub mod filterbar; pub mod grid; pub mod left; pub mod masking; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 45e652910..016c9a1d0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -30,7 +30,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::Crop => crop(app, ui, id), RightPanel::Remove => remove(app, ui, id), RightPanel::Masking => super::masking::show(app, ui, id), - RightPanel::RedEye => red_eye(app, ui), + RightPanel::RedEye => red_eye(app, ui, id), RightPanel::Info => info(app, ui, id), RightPanel::Keywords => keywords(app, ui, id), RightPanel::Versions => versions(app, ui, id), @@ -116,6 +116,17 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten")); super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v}))); } + padded(ui, |ui| { + ui.horizontal(|ui| { + let on = app.ui.tool == "straighten"; + if text_button(ui, "straightenTool", "Straighten Tool", on).on_hover_text("Drag along the horizon; double-click for Auto").clicked() { + app.ui.tool = if on { String::new() } else { "straighten".into() }; + } + if text_button(ui, "straightenAuto", "Auto", false).clicked() { + let _ = app.run("crop.autoStraighten", json!({})); + } + }); + }); padded(ui, |ui| { ui.label("Overlay"); { @@ -217,6 +228,28 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if let Some(v) = out.value { app.ui.remove_size = (v / 1000.0) as f32; } + // Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out + padded(ui, |ui| { + let mut v = app.ui.visualize_spots; + if ui.checkbox(&mut v, "Visualize Spots (A)").changed() { + app.ui.visualize_spots = v; + } + }); + let spec = lightcraft_develop::ControlSpec { + id: "ui.spotsThreshold", + label: "Threshold", + section: lightcraft_develop::Section::Detail, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, + }; + let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None); + if let Some(v) = out.value { + app.ui.spots_threshold = v as f32; + } padded(ui, |ui| { if !d.spots.is_empty() && text_button(ui, "removeClear", "Delete all spots", false).clicked() { for i in (0..d.spots.len()).rev() { @@ -226,13 +259,58 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } -fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui) { +fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let d = app.session.develop_of(id).unwrap_or_default(); header(ui, "Red Eye"); padded(ui, |ui| { - ui.label("Click and drag over an eye to correct it."); - if text_button(ui, "redeyeAuto", "Auto Correct", false).clicked() { - app.ui.status = "Red eye correction arrives in M8".into(); + ui.horizontal_wrapped(|ui| { + for (label, pet) in [("Red Eye", false), ("Pet Eye", true)] { + if text_button(ui, &format!("eyeMode-{}", if pet { "pet" } else { "red" }), label, app.ui.eye_pet == pet).clicked() { + app.ui.eye_pet = pet; + } + } + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Drag over an eye on the photo; the pupil inside is found automatically.").color(Tokens::get(ui.ctx()).text_dim), + ); + }); + let n = d.red_eye.len(); + if n == 0 { + return; + } + if app.ui.eye >= n { + app.ui.eye = n - 1; + } + let i = app.ui.eye; + let eye = d.red_eye[i]; + divider(ui); + super::edit::sub_title(ui, &format!("{} {} of {n}", if eye.pet { "Pet Eye" } else { "Red Eye" }, i + 1)); + for k in ["pupilSize", "darken"] { + let cid = format!("redEye.{i}.{k}"); + let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue }; + let v = lightcraft_develop::controls::get(&d, &cid).unwrap_or(spec.default); + let out = slider(ui, spec, v, true, None); + super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": cid, "value": v}))); + } + padded(ui, |ui| { + if eye.pet { + let mut on = eye.catchlight.is_some(); + if ui.checkbox(&mut on, "Add Catchlight").changed() { + let _ = app.run("redeye.catchlight", json!({"index": i, "on": on})); + } + ui.add_space(4.0); } + ui.horizontal(|ui| { + if text_button(ui, "eyeDelete", "Delete", false).clicked() { + let _ = app.run("redeye.delete", json!({"index": i})); + } + if n > 1 && text_button(ui, "eyeDeleteAll", "Delete all", false).clicked() { + for k in (0..n).rev() { + let _ = app.run("redeye.delete", json!({"index": k})); + } + } + }); }); } @@ -243,8 +321,12 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { padded(ui, |ui| { ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text)); ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim)); + if let Some(name) = &p.copy_name { + let of = p.copy_of.and_then(|m| app.session.catalog.photo(m)).map(|m| m.file_name.clone()).unwrap_or_else(|| "a removed photo".into()); + ui.label(egui::RichText::new(format!("Virtual copy “{name}” of {of}")).color(t.text_label)); + } ui.add_space(8.0); - if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) { + if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner { let _ = app.run("photo.rate", json!({"rating": r})); } }); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7429a05d5..64899ee6f 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -16,6 +16,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); + let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + if !app.native_menu { + // leave room for the in-window menus left of the (centred) search field + let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; + sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); + } + let search_left = full.center().x - sw / 2.0; ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, "Show/hide My Photos panel").clicked() { @@ -26,9 +33,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("view.back", json!({})); } icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward"); + if !app.native_menu { + // no native menu bar (web, Windows, Linux): menus in the top bar + ui.add_space(10.0); + let room = search_left - ui.cursor().left() - 16.0; + crate::menubar::show_in_window(app, ui, room); + } }); // search field (centred on the window) - let sw = 640.0f32.min(full.width() - 460.0).max(200.0); let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); let id = egui::Id::new("search-field"); let focused = ui.memory(|m| m.has_focus(id)); @@ -66,7 +78,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click()); register(ui.ctx(), "icon:filter", fr); - let filtering = app.session.filter != Default::default(); + let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( ui.painter(), fr.shrink(6.0), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index af53a4bdf..01f91faea 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,19 +26,21 @@ 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), "-" => Some(Key::Minus), "[" => Some(Key::OpenBracket), "]" => Some(Key::CloseBracket), + "'" => Some(Key::Quote), _ => None, }) } @@ -38,7 +52,11 @@ 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 + // `Ctrl` is the physical Control key (on macOS distinct from Cmd; elsewhere Cmd = Ctrl); + // "Delete" (⌫ = Backspace) also matches forward-delete + let ctrl_ok = if m.ctrl { modifiers.ctrl } else { !modifiers.ctrl || modifiers.command }; + let cmd_ok = m.ctrl || modifiers.command == m.command; + (*key == k || (k == Key::Backspace && *key == Key::Delete)) && ctrl_ok && cmd_ok && modifiers.shift == m.shift && modifiers.alt == m.alt } _ => false, }) @@ -50,45 +68,109 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { return; } 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.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() { - if let Some((m, k)) = c.shortcut.and_then(parse) + // 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) { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) { fire.push(format!("rate:{n}:0")); } if matches(i, Modifiers::SHIFT, *key) { fire.push(format!("rate:{n}:1")); } } - for (label, key) in [("red", Key::Num6), ("yellow", Key::Num7), ("green", Key::Num8), ("blue", Key::Num9)] { - if matches(i, Modifiers::NONE, key) { + for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { + if matches(i, Modifiers::NONE, key) && !native(sc) { fire.push(format!("label:{label}")); } } }); + use crate::panels::compare; + // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or + // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) + let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { + compare::target_active(app, &mut params); + let ok = app.run(id, params).is_ok(); + if ok && (advance || app.ui.auto_advance) { + 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")); - let _ = app.run("photo.rate", json!({"rating": n.parse::().unwrap_or(0), "advance": adv == "1"})); + cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) }; app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { - let _ = app.run("photo.label", json!({"label": l})); + cull(app, "photo.label", json!({"label": l}), false); + } 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) }; + } else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { + cull(app, &f, json!({}), false); + match f.as_str() { + "photo.pick" => app.toast(ctx, "Flagged as Pick"), + "photo.reject" => app.toast(ctx, "Flagged as Reject"), + _ => 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!({})); @@ -112,6 +194,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] @@ -133,4 +221,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 34b3b15a7..8e3ba2821 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -10,7 +10,10 @@ pub enum ViewMode { PhotoGrid, SquareGrid, Detail, + /// Two photos side by side (select | candidate), synced zoom. Compare, + /// The selected photos tiled. + Survey, } /// The right-hand tool/panel shown next to the tool strip. @@ -55,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)] @@ -107,6 +114,23 @@ pub struct UiState { pub brush_flow: f32, pub brush_erase: bool, pub remove_size: f32, + /// Selected Point Color sample. + pub point_color: usize, + /// Point Color "Visualize range": the selected sample's range in colour, the rest grey. + pub point_color_visualize: bool, + /// Red Eye panel: selected correction, and whether new ones are pet eyes. + pub eye: usize, + pub eye_pet: bool, + /// Remove tool: Visualize Spots (high-pass black/white view) and its threshold 0..100. + pub visualize_spots: bool, + pub spots_threshold: f32, + /// The library filter bar above the grid. + pub filter_bar: bool, + /// Culling: after a rating, flag or colour-label key, move to the next photo. + pub auto_advance: bool, + /// Compare view: (select, candidate) photo ids. + #[serde(skip)] + pub compare: Option<(u64, u64)>, /// Transient toast text and its expiry (seconds of app time). #[serde(skip)] pub toast: Option<(String, f64)>, @@ -127,6 +151,14 @@ pub enum Dialog { id: u64, name: String, }, + /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. + AutoStack { + gap: f32, + }, + /// Save the current view (source + filter) as a smart album. + NewSmartAlbum { + name: String, + }, CreatePreset { name: String, group: String, @@ -141,6 +173,10 @@ pub enum Dialog { limit_kb: u32, dir: String, }, + /// Photo Merge (HDR / Panorama / HDR Panorama) options; the preview lives in the app. + Merge { + opts: crate::merge::MergeDialog, + }, About, Shortcuts, } @@ -175,6 +211,15 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, remove_size: 0.02, + point_color: 0, + point_color_visualize: false, + eye: 0, + eye_pet: false, + visualize_spots: false, + spots_threshold: 50.0, + auto_advance: false, + filter_bar: false, + compare: None, toast: None, status: String::new(), dialog: None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 254040b53..b013a2ea4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,7 +19,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: |---|---|---| | `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) | | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | -| `ui.menu.list` | — | Menu entries | +| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | +| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | | `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status, memory (bytes per cache, see `library.memory`; plus stage caches and textures) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget | diff --git a/docs/merge.md b/docs/merge.md new file mode 100644 index 000000000..600eb840f --- /dev/null +++ b/docs/merge.md @@ -0,0 +1,64 @@ +# Photo Merge (HDR, Panorama, HDR Panorama) + +`crates/merge` (layer L3, no UI dependencies) implements the algorithms; `crates/engine/src/merge.rs` +plans, runs and finishes merges in the library; the desktop UI shows a dialog with a live preview. +All algorithms are our own implementations from published papers (cited in the module docs). + +## Using it + +- **App:** select the photos, then *Photo Merge ▸ HDR…* (`Ctrl+H`), *Panorama…* (`Ctrl+M`) or + *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews + the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge + in the background (progress in a toast). The result is written next to the first photo as + `-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. +- **Commands** (CLI, MCP, control channel): + - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` + - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}` + - `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}` +- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`; + `lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene). + +## What it does + +**HDR** (`hdr.rs`): frames ordered by EXIF exposure (or median luminance); the middle one is the +reference. Auto Align: features on exposure-normalised luminance, RANSAC translation / similarity / +homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured +(median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. +Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max +channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise +floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. + +**Panorama** (`pano/`): MOPS-style features, pairwise RANSAC homographies with Brown & Lowe's +verification, focal from homographies (or EXIF 35 mm), rotations chained along a maximum spanning +tree, Levenberg–Marquardt bundle adjustment of rotations + focal lengths, straightening. +Projections: spherical, cylindrical, perspective; *Auto* picks perspective for compact fields of +view < 110°, cylindrical when the vertical field is < 90°, else spherical. Exposure: EXIF, then +gain compensation from the overlaps. Seams: each pixel goes to the image with the highest +centre-weighted feather weight; multi-band blending (Laplacian pyramids) in log space. Boundary +Warp: a separable stretch of the smoothed valid-area edges to the canvas edges. Auto Crop: the +largest rectangle of image data (set as the develop crop, so it stays adjustable). Fill Edges: +push–pull diffusion. Output: 16-bit integer DNG (16-bit float for HDR panoramas). + +**DNG output** (`output.rs`): LinearRaw, 3 samples, Deflate tiles; values scaled below 1.0 with the +scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB), other sources get +`ColorMatrix1`/`ForwardMatrix1` of their RGB space. + +## Measured (synthetic tests, `cargo test -p lightcraft-merge`) + +- HDR, 3 brackets ±2 EV with 14-bit noise and handheld shifts: alignment error < 0.3 px, exposure + error < 0.02 EV, radiance median log error < 0.03 (noise-limited, bias < 0.01), recovered + highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High. +- Panorama, 5 views over 182° with a 1.3× exposure drift: relative rotations < 0.1°, focal < 1 %, + BA RMS ≈ 0.35 px; median log error vs ground truth < 0.02; no seam steps. + +## Limits (known gaps) + +- No lens-distortion model in the panorama bundle adjustment (rectilinear lenses assumed); no + 360° wrap-around; flat (translating-camera) scans are fitted with the rotation model. +- No camera response calibration for JPEG brackets (sRGB decoding is taken as linear light). +- Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion, + not patch synthesis. +- *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its + caption also lists them. +- Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- The browser build has no file access for merges yet. diff --git a/docs/parity.md b/docs/parity.md new file mode 100644 index 000000000..97cada6d8 --- /dev/null +++ b/docs/parity.md @@ -0,0 +1,797 @@ +# 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) | 12 | 4 | 9 | 2 | 7/9 (78%) | 4/9 (44%) | +| C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) | +| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | +| E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | +| F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | +| G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | +| H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | +| 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) | 3 | 0 | 1 | 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 | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | +| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | +| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | +| **Total** | 208 | 103 | 189 | 37 | 152/198 (77%) | 48/144 (33%) | + + +## 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-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. +3. **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. +4. **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. +5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +6. **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. +7. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). +8. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +9. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), + XMP prefs, cache, GPU. Medium effort. +10. **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. +11. **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. +12. **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). +13. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +14. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +15. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +16. **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 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) | +| 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 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | +| 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 | ✅ | `cmd:photo.virtualCopy`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” | + +## 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 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | +| LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | +| 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`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) | +| LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) | +| LR-FILT-LABEL | Colour-label filter | P1 | ✅ | `cmd:library.filter` (`label`), `crates/ui-egui/src/panels/filterbar.rs` | one label at a time; no “no label” choice | +| LR-FILT-TYPE | Type / edited filter | P1 | 🟡 | `cmd:library.filter` (`kind`, `edited`), `crates/ui-egui/src/panels/filterbar.rs` | photos / raw / videos, edited / unedited; no HDR/panorama/depth kinds | +| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | +| LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | +| 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`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | +| 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`, `cmd:crop.autoStraighten` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically | +| LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | +| 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 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | +| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | +| 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/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu | +| 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 | ✅ | `cmd:dialog.newSmartAlbum` | saves the current view (source + filter) | +| 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 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | | +| 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 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:dialog.autoStack` | | +| 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 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack | +| 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 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; zoom always linked | +| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | +| 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 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ | +| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | +| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album; no filter presets, lock, or multi-select columns | +| LRC-LIB-STACKS | Stacks (full) | P1 | 🟡 | `crates/catalog/src/stacks.rs` | group/ungroup/toggle/top/remove/auto by time; no split stack or move up/down | +| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | +| 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/docs/xmp-interop.md b/docs/xmp-interop.md index cfdc6ac3e..28867685d 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -58,6 +58,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `GrayMixer` | `bw_mix.` | | | `ParametricShadows`, `ParametricDarks`, `ParametricLights`, `ParametricHighlights` | `curve.shadows/darks/lights/highlights` | | | `ParametricShadowSplit`, `ParametricMidtoneSplit`, `ParametricHighlightSplit` | `curve.split_shadows/split_mid/split_highlights` | | +| `CurveRefineSaturation` | `curve.refine_saturation` | 0..100 (100 = curve saturation unchanged) | | `ToneCurvePV2012`, `ToneCurvePV2012Red/Green/Blue` | `curve.master/red/green/blue` | `"x, y"` points in 0..255 → 0..1; a straight 0→255 line = no curve | | `SplitToningShadowHue/Saturation`, `SplitToningHighlightHue/Saturation` | `grading.shadows/highlights.hue/sat` | | | `ColorGradeShadowLum`, `ColorGradeHighlightLum` | `grading.shadows/highlights.lum` | | @@ -72,6 +73,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | the switch only; lens profiles are our own | | `LensManualDistortionAmount`, `VignetteAmount`, `VignetteMidpoint` | `optics.distortion`, `optics.vignetting`, `optics.vignetting_midpoint` | | | `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | | +| `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | | `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | | | `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided | | `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fb19fc0b7..268ba72ee 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,6 +6,7 @@ mod assets; mod bench; mod layers; +mod parity; mod stats; mod web; @@ -21,11 +22,14 @@ commands: 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`) @@ -38,6 +42,7 @@ fn main() -> ExitCode { 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(), @@ -177,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"]); + } +}