diff --git a/Cargo.lock b/Cargo.lock index f6a5c9476..bf2ac824f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "heic-rs" -version = "0.1.1" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" dependencies = [ "rayon", ] diff --git a/README.md b/README.md index 8884f2bd3..2aea14d87 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - - **network libraries:** a preview reads the raw's header and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); + - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); - **on-screen HDR display, video and the Classic Print / Book / Map modules.** - **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md). @@ -409,6 +409,9 @@ nix build github:storytold/lightcraft # → ./result/bin/{light nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts ``` +On NixOS, run a `cargo` build from `nix develop`: outside it the app can't find `libvulkan.so.1`, finds no GPU and +renders on the CPU, much more slowly (it says so once at start-up). `nix run` and `nix build` set the path themselves. + In a flake configuration (NixOS, home-manager, nix-darwin): ```nix diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..3f02a1199 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | | ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | -| UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | +| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | | Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | @@ -114,6 +114,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full + size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -161,6 +163,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 63a4deb76..b6cad57e0 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -303,10 +303,14 @@ fn mcp(args: &[String]) -> Result<(), String> { while i < args.len() { match args[i].as_str() { "--connect" => { - // Optional address argument. - match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) { + // Optional address argument. A bare port (all digits) is taken as a port on the + // loopback default — matches the help's `--connect [ADDR]` hint alongside the + // `--control 7980` example. A non-numeric, non-`--`-prefixed token (e.g. a + // command id like `ui.inspect`) leaves the address at its default so the + // command is still consumed next. (#1178) + match args.get(i + 1).filter(|a| a.contains(':') || a.chars().all(|c| c.is_ascii_digit())) { Some(a) => { - connect = Some(a.clone()); + connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") }); i += 1; } None => connect = Some(DEFAULT_ADDR.to_string()), @@ -525,9 +529,9 @@ fn run(args: &[String]) -> Result<(), String> { i += 1; break; } - "--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) { + "--connect" => match args.get(i + 1).filter(|a| !a.contains('=') && (a.contains(':') || a.chars().all(|c| c.is_ascii_digit()))) { Some(a) => { - connect = Some(a.clone()); + connect = Some(if a.contains(':') { a.clone() } else { format!("127.0.0.1:{a}") }); i += 1; } None => connect = Some(DEFAULT_ADDR.to_string()), diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9aafc9310..bf56b02ab 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -476,6 +476,28 @@ fn connect_failure_hint_names_the_attempted_port() { assert!(!err.contains("--control 7980"), "{err}"); } +/// Issue #1178: `--connect 7980 ui.inspect` (after `lightcraft --control 7980`) was leaving +/// `7980` unconsumed so it parsed as a command id and produced "unknown command `7980`". +/// A bare port is now taken as `127.0.0.1:`; the same address used with `:port` still +/// works; non-numeric, non-`ADDR`-shaped tokens (e.g. a real command id) keep falling through. +#[test] +fn connect_bare_port_is_treated_as_loopback_port() { + // Bare port: the failure hint names that port. + let (ok, _lines, err) = run_cli(&["--connect", "18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "bare port must reach the hint: {err}"); + assert!(!err.contains("--control 7980"), "{err}"); + // `:` still works (issue #167 case). + let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "{err}"); + // `--connect=` already worked (the `a.starts_with("--connect=")` arm); spot-check + // the hint matches its port too. + let (ok, _lines, err) = run_cli(&["--connect=18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "{err}"); +} + /// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is /// reported, and `RUST_LOG=off` silences it. Before, no logger was installed and nothing appeared. #[test] diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index a4ef7cbee..18c896367 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -228,6 +228,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen reveal: None, log_file: None, open_with: None, + open_all_with: None, check_editor: None, open_url: Some(Box::new(|url: &str| { let w = web_sys::window().ok_or("no window")?; diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index e231c47c5..b3d685f5f 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -64,7 +64,7 @@ impl WireJob { /// `view`: the slot name, for jobs that keep a stage cache. pub fn from_job(job: &RenderJob, thumb_cached: bool, view: &str) -> WireJob { let max_edge = match &job.source { - SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } => *max_edge, + SourceRef::Demo { max_edge, .. } | SourceRef::File { max_edge, .. } | SourceRef::Fit { max_edge, .. } => *max_edge, SourceRef::Loaded(img) => img.image.width.max(img.image.height), SourceRef::Smart { .. } => 2560, }; diff --git a/apps/lightcraft/src/colord.rs b/apps/lightcraft/src/colord.rs new file mode 100644 index 000000000..70f7fbdd2 --- /dev/null +++ b/apps/lightcraft/src/colord.rs @@ -0,0 +1,125 @@ +//! The ICC profile the desktop assigns to the display (Linux, through colord). +//! +//! GNOME, KDE and most other desktops keep each monitor's profile in colord +//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a +//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile +//! is chosen in Settings ▸ Display, previews are shown through that profile +//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never +//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB. +//! +//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a +//! window is on), so the profile is used only when every display that has one has the same: a +//! mixed set stays sRGB rather than guess. + +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +/// How often colord is asked again (a profile change in the desktop's settings shows within this). +const POLL: Duration = Duration::from_secs(3); +/// A wait before trying again when colord isn't there (not installed, no system bus). +const RETRY: Duration = Duration::from_secs(30); +/// The largest profile file read (real display profiles are a few KiB to a few MiB). +const MAX_PROFILE: u64 = 16 << 20; + +const SERVICE: &str = "org.freedesktop.ColorManager"; +const DEVICE: &str = "org.freedesktop.ColorManager.Device"; +const PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + +/// Start the background reader and return the service the UI polls (`Services::screen_profile`). +pub fn service() -> Box Option>> { + let shared: Arc>>> = Arc::new(Mutex::new(None)); + let writer = Arc::clone(&shared); + let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || { + loop { + let wait = match connect() { + Some(conn) => { + let icc = read(&conn); + *writer.lock().unwrap_or_else(PoisonError::into_inner) = icc; + POLL + } + None => RETRY, + }; + std::thread::sleep(wait); + // the app is gone: nobody reads the profile any more + if Arc::strong_count(&writer) == 1 { + return; + } + } + }); + if let Err(e) = spawned { + log::warn!("screen profile: no colord reader: {e}"); + } + Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone()) +} + +fn connect() -> Option { + zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok() +} + +/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays). +fn read(conn: &zbus::blocking::Connection) -> Option> { + let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?; + let devices: Vec = manager.call("GetDevicesByKind", &("display",)).ok()?; + let mut files = Vec::new(); + for device in devices.iter().take(64) { + let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue }; + let profiles: Vec = proxy.get_property("Profiles").unwrap_or_default(); + // colord lists a device's profiles default first + let Some(first) = profiles.first() else { continue }; + let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue }; + if let Ok(file) = profile.get_property::("Filename") { + files.push(file); + } + } + let file = pick(&files)?; + load(std::path::Path::new(file)) +} + +/// The profile to use for these displays' profile files: the one they share, `None` with none or +/// with different ones (empty names are ignored). +fn pick(files: &[String]) -> Option<&str> { + let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty()); + let first = named.next()?; + named.all(|f| f == first).then_some(first) +} + +/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large. +fn load(path: &std::path::Path) -> Option> { + use std::io::Read as _; + let file = std::fs::File::open(path).ok()?; + let mut bytes = Vec::new(); + file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?; + (!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_shared_profile_is_used_and_a_mixed_set_is_not() { + let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::>(); + assert_eq!(pick(&s(&[])), None); + assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc")); + assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc")); + assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known"); + assert_eq!(pick(&s(&[""])), None); + } + + #[test] + fn profile_files_are_read_with_a_size_cap() { + let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let small = dir.join("small.icc"); + std::fs::write(&small, b"acsp").unwrap(); + assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..])); + let empty = dir.join("empty.icc"); + std::fs::write(&empty, b"").unwrap(); + assert_eq!(load(&empty), None); + assert_eq!(load(&dir.join("missing.icc")), None); + let big = dir.join("big.icc"); + std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap(); + assert_eq!(load(&big), None); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index 16bd5df0f..537c75bd1 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -55,16 +55,65 @@ fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Con } } +/// How long a request waits for its reply. +const REPLY_TIMEOUT: Duration = Duration::from_secs(60); +/// A request the app has not picked up while it drew no frame for this long is given up: the +/// window is not being redrawn (on Wayland, a window on a hidden workspace or minimized gets no +/// frame callbacks, so the app's loop is paused: issue #718). +const NO_FRAMES_AFTER: Duration = Duration::from_secs(5); + +/// The reply for a request given up because the app drew no frames. +pub const HIDDEN_WINDOW_ERROR: &str = "the app drew no frame for 5 s, so the request was not run: its window is probably not visible \ +(on Wayland a window on a hidden workspace or minimized is not redrawn, which pauses the app). Bring the window on screen and send it again"; + fn serve(stream: TcpStream, tx: &Sender, ctx: &egui::Context) { let Ok(read) = stream.try_clone() else { return }; serve_lines(read, stream, |method, params| { let (req, rrx) = ControlRequest::new(method, params); + let state = Arc::clone(&req.state); tx.send(req).ok()?; ctx.request_repaint(); - Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))) + Some(await_reply(&rrx, &state, || ctx.cumulative_pass_nr(), || ctx.request_repaint(), NO_FRAMES_AFTER, REPLY_TIMEOUT)) }); } +/// Wait for a request's reply. While the app has not picked the request up and `frames` (its +/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late) +/// and the reply says why; a request the app is running gets the whole `timeout`. +fn await_reply( + rrx: &Receiver, + state: &std::sync::atomic::AtomicU8, + frames: impl Fn() -> u64, + wake: impl Fn(), + no_frames: Duration, + timeout: Duration, +) -> Value { + let start = std::time::Instant::now(); + let slice = (no_frames / 10).max(Duration::from_millis(1)); + let (mut last, mut since) = (frames(), std::time::Instant::now()); + loop { + match rrx.recv_timeout(slice) { + Ok(v) => return v, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + let now = frames(); + if now != last { + (last, since) = (now, std::time::Instant::now()); + } + let taken = state.load(Ordering::SeqCst) == lightcraft_ui_egui::control::TAKEN; + if !taken && since.elapsed() >= no_frames && ControlRequest::abandon(state) { + return json!({"ok": false, "error": HIDDEN_WINDOW_ERROR}); + } + if start.elapsed() >= timeout { + // not run yet: make sure it never is; already running: it may still finish + ControlRequest::abandon(state); + return json!({"ok": false, "error": "timeout"}); + } + wake(); + } +} + /// One parsed line. enum Line { Request { @@ -160,6 +209,51 @@ fn serve_lines(read: R, mut out: W, mut handle: impl FnMut(St mod tests { use super::*; + /// Issue #718: an app that draws no frames (a hidden Wayland window) gets the request given + /// up with a reason, quickly, and the app then skips it instead of running it late. + #[test] + fn a_request_to_an_app_drawing_no_frames_is_given_up_with_a_reason() { + let (req, rrx) = ControlRequest::new("ui.inspect", json!({})); + let t0 = std::time::Instant::now(); + let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10)); + assert!(t0.elapsed() < Duration::from_secs(2)); + assert_eq!(r["ok"], false); + assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}"); + assert!(!req.take(), "the app does not run it once it shows up"); + } + + /// A request the app is running is not given up because frames stopped (a long command + /// blocks the frame loop), and its reply arrives. + #[test] + fn a_request_being_run_waits_for_its_reply() { + let (req, rrx) = ControlRequest::new("export.run", json!({})); + assert!(req.take()); + let reply = req.reply.clone(); + let worker = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + let _ = reply.send(json!({"ok": true, "result": 1})); + }); + let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(20), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + worker.join().unwrap(); + } + + /// Frames still coming: the request just waits, then is answered. + #[test] + fn a_drawing_app_is_waited_for() { + let (req, rrx) = ControlRequest::new("ui.inspect", json!({})); + let n = std::sync::atomic::AtomicU64::new(0); + let reply = req.reply.clone(); + let state = Arc::clone(&req.state); + let worker = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(150)); + let _ = reply.send(json!({"ok": true, "result": 2})); + }); + let r = await_reply(&rrx, &state, || n.fetch_add(1, Ordering::SeqCst), || {}, Duration::from_millis(30), Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + worker.join().unwrap(); + } + fn run(input: &[u8]) -> (Vec, Vec) { let mut out = Vec::new(); let mut calls = Vec::new(); diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs index 9a8cc22d2..cf6d66079 100644 --- a/apps/lightcraft/src/editors.rs +++ b/apps/lightcraft/src/editors.rs @@ -61,6 +61,39 @@ pub fn open_with(path: &str, app: &str) -> Result<(), String> { c.spawn().map(|_| ()).map_err(|e| e.to_string()) } +/// Open every file in `paths` with one start of `app`, as a file manager hands over a selection: +/// an editor that keeps a single window (DxO PhotoLab) loses the files that arrive while it is +/// still starting when it is started once per file. The system default opens them one by one +/// (Explorer and `xdg-open` take one file). +pub fn open_all_with(paths: &[String], app: &str) -> Result<(), String> { + match all_command(launcher(app)?, paths) { + Some(mut c) => c.spawn().map(|_| ()).map_err(|e| e.to_string()), + None => paths.iter().try_for_each(|p| open_with(p, app)), + } +} + +/// The one command that opens all of `paths` in `launcher` (none for the system default). +fn all_command(launcher: Launcher, paths: &[String]) -> Option { + match launcher { + Launcher::Default => None, + Launcher::MacApp(app) => { + let mut c = Command::new("open"); + c.args(["-a", &app]).args(paths); + Some(c) + } + Launcher::Program(p) => { + let mut c = Command::new(p); + c.args(paths); + Some(c) + } + Launcher::Flatpak => { + let mut c = Command::new("flatpak"); + c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@"]).args(paths).arg("@@"); + Some(c) + } + } +} + /// Windows: open `path` in its default application through Explorer, given the path as its one /// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own /// syntax (running what follows an `&` as a command). @@ -172,8 +205,10 @@ fn desktop_exec(entry: &str) -> Option { Some(rest) => rest.split('"').next()?, None => exec.split_whitespace().next()?, }; - // a bare name (`photocraft %F`) was already looked for on the PATH - Some(PathBuf::from(program)).filter(|p| p.is_absolute()) + // a bare name (`photocraft %F`) was already looked for on the PATH. Desktop entries are a + // freedesktop (POSIX) format: absolute means a leading `/` on every platform the parser runs on + // (`Path::is_absolute` wants a drive letter on Windows, where the tests run too) + program.starts_with('/').then(|| PathBuf::from(program)) } #[cfg(test)] @@ -191,6 +226,25 @@ mod tests { assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]); } + /// Open Original with several photos: one start of the editor with every file, which a + /// single-window editor (DxO PhotoLab) needs to get them all. + #[test] + fn several_files_go_to_one_start_of_the_editor() { + let paths = [r"D:\Pictures\a.dng".to_string(), r"D:\Pictures\b c.dng".to_string()]; + let args = |c: &Command| c.get_args().map(|a| a.to_string_lossy().into_owned()).collect::>(); + let c = all_command(Launcher::Program(PathBuf::from(r"C:\DxO\DxO.PhotoLab.exe")), &paths).unwrap(); + assert_eq!(c.get_program(), r"C:\DxO\DxO.PhotoLab.exe"); + assert_eq!(args(&c), paths); + let c = all_command(Launcher::MacApp("DxO PhotoLab 8".into()), &paths).unwrap(); + assert_eq!( + (c.get_program().to_string_lossy().into_owned(), args(&c)), + ("open".into(), vec!["-a".into(), "DxO PhotoLab 8".into(), paths[0].clone(), paths[1].clone()]) + ); + let c = all_command(Launcher::Flatpak, &paths).unwrap(); + assert_eq!(args(&c), ["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", &paths[0], &paths[1], "@@"]); + assert!(all_command(Launcher::Default, &paths).is_none(), "the system default opens them one by one"); + } + #[test] fn windows_lookup_prefers_the_recorded_install_folder() { let env = |k: &str| match k { diff --git a/apps/lightcraft/src/hdr_display.rs b/apps/lightcraft/src/hdr_display.rs new file mode 100644 index 000000000..2983e9a21 --- /dev/null +++ b/apps/lightcraft/src/hdr_display.rs @@ -0,0 +1,174 @@ +//! HDR display output (LR-VIEW-HDR-DISPLAY): whether the window can show the range above SDR +//! white, and with which surface format. Pure decisions, unit-tested here; the window always +//! falls back to SDR when any condition is missing, so an HDR edit is never shown wrong. +//! +//! HDR needs three things: a platform path (macOS EDR via an extended-range float Metal layer, +//! Windows HDR via scRGB float or HDR10 swap chains, Wayland via the colour-management protocol), +//! a surface format that carries values above 1.0 (`Rgba16Float`) or a 10-bit PQ signal +//! (`Rgb10a2Unorm`), and a renderer that actually configured that format. eframe 0.36 picks the +//! surface format itself (8-bit only, `egui_wgpu::preferred_framebuffer_format`), so today the +//! choice reports SDR with the reason; the moment the renderer's target is an HDR format the same +//! decision turns HDR on. + +use eframe::wgpu::TextureFormat; + +/// Windowing platform, as far as HDR output is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + MacOs, + Windows, + Wayland, + X11, + Web, + Other, +} + +impl Platform { + /// The platform this build runs on (`wayland` from the session environment on Linux / BSD). + pub fn current() -> Self { + if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_arch = "wasm32") { + Self::Web + } else if std::env::var_os("WAYLAND_DISPLAY").is_some() { + Self::Wayland + } else if std::env::var_os("DISPLAY").is_some() { + Self::X11 + } else { + Self::Other + } + } + + /// Whether the platform has an HDR compositing path wgpu can reach at all. + fn has_hdr_path(self) -> bool { + matches!(self, Self::MacOs | Self::Windows | Self::Wayland) + } +} + +/// How HDR values reach the display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HdrEncoding { + /// Linear float, SDR white at 1.0 and the headroom above it (macOS EDR, Windows scRGB, + /// Wayland extended linear). + ExtendedLinear, + /// 10-bit BT.2100 PQ (Windows HDR10, Wayland PQ). + Pq10, +} + +/// The display output decision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisplayOutput { + Sdr { reason: &'static str }, + Hdr { format: TextureFormat, encoding: HdrEncoding }, +} + +impl DisplayOutput { + #[cfg_attr(not(test), allow(dead_code))] // read by the HDR view once the surface can be HDR + pub fn is_hdr(self) -> bool { + matches!(self, Self::Hdr { .. }) + } +} + +/// The encoding a surface format carries on `platform`, if it is an HDR one. +fn encoding_of(format: TextureFormat, platform: Platform) -> Option { + match format { + TextureFormat::Rgba16Float => Some(HdrEncoding::ExtendedLinear), + // macOS takes HDR through EDR floats only + TextureFormat::Rgb10a2Unorm if platform != Platform::MacOs => Some(HdrEncoding::Pq10), + _ => None, + } +} + +/// The best HDR surface format among those a surface offers (`available`), for `platform`: +/// float first (keeps SDR white at 1.0 and needs no PQ encode), then 10-bit PQ. `None` = SDR only. +/// (For configuring the surface once eframe lets the app pick its format.) +#[cfg_attr(not(test), allow(dead_code))] +pub fn preferred_hdr_format(available: &[TextureFormat], platform: Platform) -> Option { + if !platform.has_hdr_path() { + return None; + } + [TextureFormat::Rgba16Float, TextureFormat::Rgb10a2Unorm].into_iter().find(|f| available.contains(f) && encoding_of(*f, platform).is_some()) +} + +/// The decision for the window: `target` is the format the renderer configured (`None`: the glow +/// renderer or no wgpu state), `display_headroom` the display's current peak over SDR white as the +/// OS reports it (`None`: unknown, `Some(1.0)`: an SDR display or HDR switched off in the OS), +/// `disabled` the user's opt-out (`LIGHTCRAFT_HDR_DISPLAY=0`). +pub fn choose(target: Option, platform: Platform, display_headroom: Option, disabled: bool) -> DisplayOutput { + if disabled { + return DisplayOutput::Sdr { reason: "HDR display is switched off" }; + } + if !platform.has_hdr_path() { + return DisplayOutput::Sdr { reason: "this windowing system has no HDR output" }; + } + let Some(format) = target else { + return DisplayOutput::Sdr { reason: "the window renderer is not wgpu" }; + }; + let Some(encoding) = encoding_of(format, platform) else { + return DisplayOutput::Sdr { reason: "the window surface is 8-bit" }; + }; + match display_headroom { + Some(h) if h.is_finite() && h > 1.0 => DisplayOutput::Hdr { format, encoding }, + Some(_) => DisplayOutput::Sdr { reason: "the display is in SDR mode" }, + None => DisplayOutput::Sdr { reason: "the display's HDR headroom is unknown" }, + } +} + +/// The decision for this start, logged once (the window renderer's `target` format). +pub fn report(target: Option) -> DisplayOutput { + let disabled = std::env::var("LIGHTCRAFT_HDR_DISPLAY").ok().is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")); + // no OS headroom query is wired yet: unknown, so SDR unless everything else is HDR-ready + let out = choose(target, Platform::current(), None, disabled); + match out { + DisplayOutput::Sdr { reason } => log::info!("display: SDR ({reason})"), + DisplayOutput::Hdr { format, encoding } => log::info!("display: HDR {encoding:?} ({format:?})"), + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use TextureFormat as F; + + #[test] + fn eight_bit_surfaces_are_sdr() { + for p in [Platform::MacOs, Platform::Windows, Platform::Wayland] { + for f in [F::Bgra8Unorm, F::Bgra8UnormSrgb, F::Rgba8Unorm] { + assert!(!choose(Some(f), p, Some(4.0), false).is_hdr(), "{p:?} {f:?}"); + } + } + } + + #[test] + fn hdr_needs_platform_format_and_headroom() { + let hdr = DisplayOutput::Hdr { format: F::Rgba16Float, encoding: HdrEncoding::ExtendedLinear }; + assert_eq!(choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), false), hdr); + assert_eq!( + choose(Some(F::Rgb10a2Unorm), Platform::Windows, Some(4.0), false), + DisplayOutput::Hdr { format: F::Rgb10a2Unorm, encoding: HdrEncoding::Pq10 } + ); + // any missing condition falls back to SDR + assert!(!choose(Some(F::Rgba16Float), Platform::X11, Some(4.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::Web, Some(4.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(1.0), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, None, false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(f32::NAN), false).is_hdr()); + assert!(!choose(Some(F::Rgba16Float), Platform::MacOs, Some(2.0), true).is_hdr()); + assert!(!choose(None, Platform::Windows, Some(4.0), false).is_hdr()); + // macOS has no PQ swap chain path + assert!(!choose(Some(F::Rgb10a2Unorm), Platform::MacOs, Some(4.0), false).is_hdr()); + } + + #[test] + fn preferred_format_picks_float_then_pq() { + let all = [F::Bgra8Unorm, F::Rgb10a2Unorm, F::Rgba16Float]; + assert_eq!(preferred_hdr_format(&all, Platform::Windows), Some(F::Rgba16Float)); + assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::Wayland), Some(F::Rgb10a2Unorm)); + assert_eq!(preferred_hdr_format(&[F::Bgra8Unorm, F::Rgb10a2Unorm], Platform::MacOs), None); + assert_eq!(preferred_hdr_format(&all, Platform::X11), None); + assert_eq!(preferred_hdr_format(&[], Platform::MacOs), None); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index c9b9696e1..bb713a2e7 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -24,9 +24,12 @@ mod alloc_release; mod appearance; +#[cfg(target_os = "linux")] +mod colord; mod control_server; mod dialog_filter; mod editors; +mod hdr_display; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -87,6 +90,16 @@ impl eframe::App for App { if !self.0.shutdown(EXIT_WAIT) { log::warn!("quit: background work was still running after {EXIT_WAIT:?}"); } + // macOS (#1331): end the process here, before eframe tears the window down, as Quit from + // the menu bar already does (PR #444). AccessKit's macOS adapter subclasses the window's + // content view and restores its class on teardown; AppKit's Touch Bar support observes + // that view (KVO), so in the next display cycle `-[_NSTouchBarFinderObservation + // invalidate]` throws "not registered" and closing the window ends in a crash report. + // Nothing after this is needed: settings and library are saved, background work has + // stopped and the log file is unbuffered. `on_exit` only runs once the app exists, so a + // renderer that fails to start still relaunches on the next one (`window_gpu`). + #[cfg(target_os = "macos")] + std::process::exit(0); } } @@ -450,6 +463,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), open_with: Some(Box::new(editors::open_with)), + open_all_with: Some(Box::new(editors::open_all_with)), check_editor: Some(Box::new(editors::check)), open_url: Some(Box::new(|url: &str| { if !url.starts_with("https://") { @@ -525,7 +539,10 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) #[cfg(target_os = "macos")] screen_profile: Some(Box::new(screen_profile::icc)), - #[cfg(not(target_os = "macos"))] + // Linux: the profile the desktop assigns in colord, read off the UI thread + #[cfg(target_os = "linux")] + screen_profile: Some(colord::service()), + #[cfg(not(any(target_os = "macos", target_os = "linux")))] screen_profile: None, pick_display_profile: Some(Box::new(|| { let mut d = file_dialog() @@ -833,6 +850,8 @@ fn main() -> eframe::Result { // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) session = session.with_default_sam3_model(); + // HDR display: SDR unless the surface, platform and display all allow HDR + hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format)); let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref())); if let Some(ui) = prefs { app.ui = ui; @@ -842,6 +861,7 @@ fn main() -> eframe::Result { app.notices.extend(prefs_warning); app.notices.extend(launch_notices); app.notices.extend(window_notice); + app.gpu_notice = true; // what's on disk now: only changes are written let writer = PrefsWriter { path: if in_memory { None } else { prefs_path() }, @@ -1207,6 +1227,35 @@ mod tests { assert!(loaded.is_some() && warning.is_none()); let _ = std::fs::remove_dir_all(&d); } + + /// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. + /// The check runs in a child (this test binary again), since on macOS it ends the process. + #[test] + fn on_exit_ends_the_process_on_macos() { + if std::env::var_os("LIGHTCRAFT_TEST_ON_EXIT").is_some() { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut app = App( + app, + PrefsWriter::default(), + #[cfg(target_os = "macos")] + None, + ); + println!("calling on_exit"); + eframe::App::on_exit(&mut app, None); + println!("on_exit returned"); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::on_exit_ends_the_process_on_macos", "--nocapture"]) + .env("LIGHTCRAFT_TEST_ON_EXIT", "1") + .env("LIGHTCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{stdout}"); + assert!(stdout.contains("calling on_exit"), "the child didn't run the test: {stdout}"); + assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); + } } #[cfg(test)] diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 697ddf010..dd92b5e2d 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -50,6 +50,7 @@ //! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` | //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | +//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -74,7 +75,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 7; +pub const VERSION: u32 = 8; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 92a531951..0d8b666a1 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -236,63 +236,84 @@ pub struct KeywordNode { impl Catalog { /// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive). pub fn keyword_tree(&self) -> Vec { - use std::collections::{HashMap, HashSet}; - // every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and + use std::collections::HashMap; + // every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and // path as first written, and the photos counted (once per photo, even when two of its - // keywords share a parent) + // keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case struct Level { name: String, + sort: String, path: String, count: usize, - parent: Option, + seen: usize, } - let mut levels: HashMap = HashMap::new(); - let mut this_photo: HashSet = HashSet::new(); + let mut levels: Vec = Vec::new(); + let mut by_lower: HashMap = HashMap::new(); + // each level's children (by index), and the top level + let mut kids: Vec> = Vec::new(); + let mut roots: Vec = Vec::new(); // the keyword list first (no photos counted): a listed keyword is there without photos let listed: Vec = self.listed_keywords().map(|k| k.path.clone()).collect(); + // each keyword as written → its levels (its own and those above it), worked out once: a + // large library repeats a few thousand keywords tens of thousands of times (issue #1011) + let mut chains: HashMap<&str, Vec> = HashMap::new(); let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice())); - for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) { - this_photo.clear(); + for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() { for k in keywords { - let mut path = String::new(); - let mut lower = String::new(); - let mut parent: Option = None; - for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) { - if !path.is_empty() { - path.push(SEP); - lower.push(SEP); + let chain = chains.entry(k.as_str()).or_insert_with(|| { + let mut chain: Vec = Vec::new(); + let mut path = String::new(); + let mut lower = String::new(); + for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) { + if !path.is_empty() { + path.push(SEP); + lower.push(SEP); + } + path.push_str(part); + let sort = part.to_lowercase(); + lower.push_str(&sort); + let i = match by_lower.get(&lower) { + Some(&i) => i, + None => { + let i = levels.len(); + match chain.last().and_then(|&p| kids.get_mut(p)) { + Some(k) => k.push(i), + None => roots.push(i), + } + levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 }); + kids.push(Vec::new()); + by_lower.insert(lower.clone(), i); + i + } + }; + chain.push(i); } - path.push_str(part); - lower.push_str(&part.to_lowercase()); - let l = levels.entry(lower.clone()).or_insert_with(|| Level { - name: part.to_string(), - path: path.clone(), - count: 0, - parent: parent.clone(), - }); - if counts && this_photo.insert(lower.clone()) { - l.count += 1; + chain + }); + if counts { + for &i in chain.iter() { + let Some(l) = levels.get_mut(i) else { continue }; + // photo 0 is the keyword list, which counts nothing + if l.seen != photo { + l.seen = photo; + l.count += 1; + } } - parent = Some(lower.clone()); } } } - let mut kids: HashMap, Vec> = HashMap::new(); - for (lower, l) in &levels { - kids.entry(l.parent.clone()).or_default().push(lower.clone()); - } - fn build(at: Option, levels: &HashMap, kids: &mut HashMap, Vec>) -> Vec { - let Some(mut list) = kids.remove(&at) else { return Vec::new() }; - list.sort_by_key(|l| levels[l].name.to_lowercase()); + fn build(mut list: Vec, levels: &[Level], kids: &mut [Vec]) -> Vec { + list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort))); list.into_iter() - .map(|l| { - let lv = &levels[&l]; - let children = build(Some(l.clone()), levels, kids); - KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children } + .filter_map(|i| { + let lv = levels.get(i)?; + let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default(); + let children = build(below, levels, kids); + Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }) }) .collect() } - build(None, &levels, &mut kids) + build(roots, &levels, &mut kids) } /// `SetMeta` ops for every photo whose keywords `f` changes. @@ -804,6 +825,15 @@ mod tests { assert_eq!(c.query(&f, &Default::default()).len(), 3); } + /// A keyword written twice on a photo, or in two spellings, counts the photo once; the next + /// photo with it counts again. + #[test] + fn tree_counts_a_repeated_keyword_once_per_photo() { + let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]); + let t = c.keyword_tree(); + assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]); + } + #[test] fn rename_delete_merge_are_single_undoable_batches() { let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]); diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index cc24266f2..ecde973ba 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -125,6 +125,12 @@ pub enum Op { id: PhotoId, step: HistoryStep, }, + /// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps. + SetHistoryCursor { + id: PhotoId, + #[serde(default)] + cursor: Option, + }, AddAlbum { album: Album, }, @@ -545,12 +551,25 @@ impl Catalog { Op::PushHistory { id, step } => { let p = self.photo_mut(id)?; let old = p.history.clone(); + let old_cursor = p.history_cursor.take(); + // a new edit made from an earlier step drops the steps after it + if let Some(c) = old_cursor { + p.history.truncate(c.saturating_add(1)); + } p.history.push(step); if p.history.len() > HISTORY_LIMIT { let n = p.history.len() - HISTORY_LIMIT; p.history.drain(..n); } - Op::SetHistory { id, history: old } + if old_cursor.is_none() { + Op::SetHistory { id, history: old } + } else { + Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] } + } + } + Op::SetHistoryCursor { id, cursor } => { + let p = self.photo_mut(id)?; + Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) } } Op::AddAlbum { album } => { if self.albums.contains_key(&album.id) { diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index ff83a179d..9cfc14f35 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -279,6 +279,7 @@ impl Op { | Op::SetVersions { id, .. } | Op::SetHistory { id, .. } | Op::PushHistory { id, .. } + | Op::SetHistoryCursor { id, .. } | Op::SetCaptured { id, .. } | Op::SetAnalysis { id, .. } | Op::SetFile { id, .. } diff --git a/crates/catalog/src/lock.rs b/crates/catalog/src/lock.rs index 8a2b496d6..d699da049 100644 --- a/crates/catalog/src/lock.rs +++ b/crates/catalog/src/lock.rs @@ -135,8 +135,9 @@ impl LibraryLock { } /// Another descriptor on the locked file: what a process spawned by another thread holds - /// between fork and exec (a `dup` refers to the same open file description). Tests only. - #[cfg(test)] + /// between fork and exec (a `dup` refers to the same open file description). Tests only (the + /// test that uses it is Unix-only: `flock` semantics). + #[cfg(all(test, unix))] pub(crate) fn descriptor_copy(&self) -> Option { self.file.as_ref().and_then(|f| f.try_clone().ok()) } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index abeaadad4..434ea3a1e 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -247,6 +247,11 @@ pub struct Photo { pub versions: Vec, #[serde(default)] pub history: Vec, + /// The History step the photo's settings are at, when it isn't the latest one (Go to History + /// Step moved back): later steps stay listed and can be returned to until a new edit drops + /// them. `None` (and catalogs written before the cursor existed) = the latest step. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_cursor: Option, /// In "Recently Deleted". #[serde(default)] pub deleted: bool, @@ -318,15 +323,22 @@ pub fn relative_wb_format(format: &str) -> bool { /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed /// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs): -/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera -/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into -/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730). -/// The engine's render path (`files.rs`) decides the same way from the file; both must agree. +/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either, nor +/// white-balance presets to read Kelvin from. A camera with matrices in `lightcraft_raw::spectral` edits in Kelvin: +/// the matrices turn its as-shot multipliers into a temperature, and the slider back, while its colour look stays +/// whatever the file's fit gives (issue #730). A camera in `lightcraft_raw::kelvin_presets` does the same through +/// its own presets. The engine's render path (`files.rs`) decides the same way from the file; both must agree. pub fn relative_wb_camera(format: &str, camera: &str) -> bool { - relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() + relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera) } impl Photo { + /// The History step the settings are at (the cursor, clamped to the list; `None` = no history). + pub fn history_index(&self) -> Option { + let last = self.history.len().checked_sub(1)?; + Some(self.history_cursor.map_or(last, |c| c.min(last))) + } + pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { Photo { id, @@ -347,6 +359,7 @@ impl Photo { edited: None, versions: Vec::new(), history: Vec::new(), + history_cursor: None, deleted: false, local: false, duration: None, @@ -599,6 +612,8 @@ mod edited_tests { ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), ("SONY ILCE-7M2", "ARW", true), + ("SONY ILCA-99M2", "ARW", false), + ("SONY SLT-A33", "ARW", false), ("SONY ILCE-1", "ARW", true), ("", "ARW", true), ("SONY ILCE-7M3", "DNG", false), diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 8b694a021..be6ab6cdc 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -1,5 +1,7 @@ //! Filtering, search and sorting. +use std::cmp::Ordering::Equal; + use serde::{Deserialize, Serialize}; use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId}; @@ -359,8 +361,13 @@ impl Filter { } } +/// Case-insensitive file name order without allocating (compares per-character lowercase via `char::to_lowercase`). +pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering { + a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase)) +} + impl Catalog { - /// Photos matching `filter`, in `sort` order (ties broken by id for stability). + /// Photos matching `filter`, in `sort` order (ties broken by file name, then id). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { let root = filter.library_root(); let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); @@ -369,11 +376,15 @@ impl Catalog { SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), SortKey::ImportDate => a.imported.cmp(&b.imported), SortKey::EditDate => a.edited.cmp(&b.edited), - SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), + SortKey::FileName => cmp_name(&a.file_name, &b.file_name), SortKey::Rating => a.rating.cmp(&b.rating), SortKey::FileSize => a.file_size.cmp(&b.file_size), SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), } + // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the + // order cameras number frames, so the order doesn't depend on how ids were allocated; + // the id only keeps it stable. + .then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) .then(a.id.cmp(&b.id)); if sort.ascending { o } else { o.reverse() } }); diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 304106aef..04a9f0beb 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -9,6 +9,7 @@ use std::collections::{HashMap, HashSet}; +use crate::query::cmp_name; use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId}; impl Catalog { @@ -203,17 +204,19 @@ impl Catalog { /// 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 + let mut timed: Vec<(i64, &str, 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))) + .filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.file_name.as_str(), p.id))) .collect(); - timed.sort(); - timed.dedup_by_key(|(_, id)| *id); + // a burst within one second stays in file name order (the first file is the stack's cover) + timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2))); + // equal ids are the same photo, so same time and name: adjacent after the sort + timed.dedup_by_key(|(_, _, id)| *id); let mut groups: Vec> = Vec::new(); let mut last: Option = None; - for (t, id) in timed { + 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]), @@ -264,6 +267,22 @@ mod tests { (c, ids) } + #[test] + fn auto_stack_keeps_a_burst_in_file_order() { + let mut c = Catalog::new(); + let mut by_name = Vec::new(); + for (n, name) in ["DSC_0001.ARW", "dsc_0002.ARW", "DSC_0003.ARW"].iter().enumerate() { + let id = PhotoId(1000 - n as u64); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some("2026-04-01T10:00:00".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + by_name.push(id); + } + let groups = c.auto_stack_groups(&[by_name[2], by_name[0], by_name[1]], 1.0); + assert_eq!(groups, vec![by_name.clone()]); + assert_eq!(groups[0][0], by_name[0], "the first file is the stack's cover"); + } + #[test] fn iso_seconds_parses() { assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0)); diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index bedc94e75..acfbb1bb1 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -694,3 +694,46 @@ fn undated_photos_group_under_unknown_date_and_sort_together() { let filter_year = Filter { date: Some("2026".into()), ..Default::default() }; assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]); } + +/// Photos with one import time and capture time, as one import batch or a burst gives them, added +/// with ids that run opposite to their names. +fn batch_with_reversed_ids(c: &mut Catalog) -> Vec { + let names = ["DSC_0001.ARW", "DSC_0002.ARW", "DSC_0003.ARW", "DSC_0004.ARW"]; + let mut ids = Vec::new(); + for (n, name) in names.iter().enumerate() { + let id = PhotoId(1000 - n as u64); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 10, 10, "2026-10-10T09:00:00"); + p.captured = Some("2026-10-01T12:00:00".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + ids // in name order +} + +#[test] +fn one_import_batch_sorts_by_file_name_not_id() { + let mut c = Catalog::new(); + let by_name = batch_with_reversed_ids(&mut c); + let sort = Sort { key: SortKey::ImportDate, ascending: true, ..Default::default() }; + assert_eq!(c.query(&Filter::default(), &sort), by_name); +} + +#[test] +fn burst_in_one_second_sorts_by_file_name_not_id() { + let mut c = Catalog::new(); + let by_name = batch_with_reversed_ids(&mut c); + let sort = Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() }; + assert_eq!(c.query(&Filter::default(), &sort), by_name); +} + +#[test] +fn cmp_name_ignores_case_without_allocating() { + use crate::query::cmp_name; + use std::cmp::Ordering::*; + assert_eq!(cmp_name("IMG_1.JPG", "img_1.jpg"), Equal); + assert_eq!(cmp_name("Ärger.jpg", "ärger.jpg"), Equal); + assert_eq!(cmp_name("a", "B"), Less); + assert_eq!(cmp_name("B", "a"), Greater); + assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less); + assert_eq!(cmp_name("a", "ab"), Less); +} diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index 37bd639e8..e2461781a 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -211,8 +211,9 @@ fn op_variants_are_versioned() { Op::SetKeyword { .. } => 5, Op::SetFolderRecord { .. } => 6, Op::SetAsShotWb { .. } => 7, + Op::SetHistoryCursor { .. } => 8, } } - let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None }); + let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index 1f5dcec24..d464d5c25 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -140,8 +140,7 @@ fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { std::thread::spawn(move || { let mut spawned = 0usize; while !stop.load(Ordering::Relaxed) { - if let Ok(mut c) = - std::process::Command::new("/bin/true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() + if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let _ = c.wait(); spawned += 1; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 4f9566b51..6ea5866b7 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -11,7 +11,7 @@ let fmt: Option = sniff(&bytes); // JPEG/PNG/TIFF/WebP/A let d: Decoded = decode(&bytes, DecodeOptions::default())?; // d.image: Rgb32f, linear light in the source primaries (d.space: SourceSpace) // d.alpha, d.bit_depth, d.float, d.orientation (EXIF value, NOT applied), d.exif/xmp/icc (raw blobs) -let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford) +let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford), alpha flattened onto white let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded @@ -53,7 +53,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d | GIF / BMP | yes (first GIF frame) | — | via `image` | | PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported | | JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | yes (jixel, `encode_jxl`, feature `jxl`, native only): lossy VarDCT / lossless Modular, 8/16-bit, effort (fast / normal), progressive; HDR as 16-bit Rec. 2020 PQ | decode: enum colour → rendered straight to linear Rec.2020 (XYB via linear sRGB primaries); ICC → our ICC path; orientation applied by the decoder (reported as 1). Encode: lossy files declare an enum encoding (sRGB, Display P3, Rec. 2020 BT.709 curve, Rec. 2020 PQ), lossless ones embed the ICC profile; EXIF / XMP in `Exif` / `xml ` boxes | -| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) | +| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_hdr` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) or HLG (`hlg_signal`, SDR white at 0.75) | | HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported | | Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails | diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 6333a9c8d..725980e53 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -394,11 +394,41 @@ pub fn pq_decode(e: f32) -> f32 { ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0 } +/// Transfer curve of an HDR file: SMPTE ST 2084 (PQ, absolute) or ITU-R BT.2100 HLG (relative). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HdrTransfer { + #[default] + Pq, + Hlg, +} + +/// Nominal peak of the HLG reference display, cd/m² (ITU-R BT.2100 / BT.2408). +pub const HLG_REFERENCE_PEAK_NITS: f32 = 1000.0; + +/// Display-referred linear RGB (SDR white = 1.0, Rec. 2020) → HLG signal 0..1, per BT.2100: +/// display light normalised to the 1000 cd/m² reference display, the inverse OOTF (system gamma +/// 1.2, applied on luminance so hue is kept), then the HLG OETF. SDR white lands at signal 0.75, +/// as BT.2408 asks. Non-finite or negative inputs count as 0. +pub fn hlg_signal(rgb: [f32; 3]) -> [f32; 3] { + const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; + const GAMMA: f32 = 1.2; + let fd = rgb.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * (HDR_REFERENCE_WHITE_NITS / HLG_REFERENCE_PEAK_NITS)); + let yd = (K[0] * fd[0] + K[1] * fd[1] + K[2] * fd[2]).min(1.0); + let scale = if yd > 0.0 { yd.powf((1.0 - GAMMA) / GAMMA) } else { 0.0 }; + fd.map(|v| lightcraft_color::transfer::hlg_encode(f64::from((v * scale).clamp(0.0, 1.0))).clamp(0.0, 1.0) as f32) +} + +/// Encode an HDR AVIF with the PQ curve (see [`encode_avif_hdr`]). +pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { + encode_avif_hdr(width, height, rgb, quality, speed, meta, HdrTransfer::Pq) +} + /// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0 /// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the -/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box. +/// `transfer` curve: PQ with SDR white at [`HDR_REFERENCE_WHITE_NITS`] and a content light level +/// box, or HLG ([`hlg_signal`], no light level box: HLG is relative to the display). /// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only. -pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { +pub fn encode_avif_hdr(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta, transfer: HdrTransfer) -> Result> { let (w, h) = (width as usize, height as usize); let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?; if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) { @@ -420,7 +450,10 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]; max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2])); sum_nits += y_nits as f64; - let [r, g, b] = nits.map(pq_encode); + let [r, g, b] = match transfer { + HdrTransfer::Pq => nits.map(pq_encode), + HdrTransfer::Hlg => hlg_signal([c[0], c[1], c[2]]), + }; let y = K[0] * r + K[1] * g + K[2] * b; let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5; let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5; @@ -446,11 +479,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: ec.pixel_range = PixelRange::Full; ec.color_description = Some(ColorDescription { color_primaries: ColorPrimaries::BT2020, - transfer_characteristics: TransferCharacteristics::SMPTE2084, + transfer_characteristics: match transfer { + HdrTransfer::Pq => TransferCharacteristics::SMPTE2084, + HdrTransfer::Hlg => TransferCharacteristics::HLG, + }, matrix_coefficients: MatrixCoefficients::BT2020NCL, }); let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16); - ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 }); + ec.content_light = + (transfer == HdrTransfer::Pq).then_some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 }); ec.still_picture = true; ec.quantizer = quantizer; ec.min_quantizer = quantizer.min(255) as u8; @@ -488,10 +525,15 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: use avif_serialize::constants as c; let mut a = avif_serialize::Aviffy::new(); a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl) - .set_transfer_characteristics(c::TransferCharacteristics::Smpte2084) + .set_transfer_characteristics(match transfer { + HdrTransfer::Pq => c::TransferCharacteristics::Smpte2084, + HdrTransfer::Hlg => c::TransferCharacteristics::Hlg, + }) .set_color_primaries(c::ColorPrimaries::Bt2020) - .set_full_color_range(true) - .set_content_light_level(cll.0, cll.1); + .set_full_color_range(true); + if transfer == HdrTransfer::Pq { + a.set_content_light_level(cll.0, cll.1); + } if let Some(exif) = meta.exif { a.set_exif(exif.to_vec()); } @@ -499,7 +541,7 @@ pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: } #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] { - let _ = (quality, speed, meta); + let _ = (quality, speed, meta, transfer); Err(Error::Encode("AVIF encoding is not available in this build".into())) } } @@ -586,3 +628,37 @@ fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result= prev); + prev = v; + } + } + + #[test] + fn hdr_avif_rejects_short_buffers() { + let m = EncodeMeta::default(); + assert!(encode_avif_hdr(4, 4, &[0.5; 10], 80, 10, &m, HdrTransfer::Hlg).is_err()); + assert!(encode_avif_hdr(0, 4, &[], 80, 10, &m, HdrTransfer::Hlg).is_err()); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334a..e524cffe8 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -39,8 +39,8 @@ mod tiff_codec; mod webp; pub use encode::{ - ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, - encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, + ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, HLG_REFERENCE_PEAK_NITS, HdrTransfer, Samples, TiffCompression, + encode_avif, encode_avif_hdr, encode_avif_pq, encode_jpeg, encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, hlg_signal, }; pub use jxl_encode::{JxlColour, JxlEffort, JxlOptions, encode_jxl}; pub use sniff::{Format, sniff}; @@ -244,12 +244,24 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R } /// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space. +/// +/// The working space has no alpha: transparency is flattened onto white, in linear light like the rest +/// of the pipeline (issue #1212). pub fn to_working(d: &Decoded) -> Rgb32f { - if d.space.is_working() { - return d.image.clone(); + let mut img = if d.space.is_working() { + d.image.clone() + } else { + let m = d.space.to_working().to_f32(); + d.image.map(|p| mat_apply(&m, p)) + }; + // white is (1, 1, 1) in the working space too: the conversion is white-preserving + if let Some(a) = d.alpha.as_ref().filter(|a| a.width == img.width && a.height == img.height) { + for (p, &av) in img.data.iter_mut().zip(&a.data) { + let av = av.clamp(0.0, 1.0); + *p = p.map(|c| c * av + (1.0 - av)); + } } - let m = d.space.to_working().to_f32(); - d.image.map(|p| mat_apply(&m, p)) + img } /// Where a thumbnail came from. diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 121b83e60..af689ae5d 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -140,6 +140,24 @@ fn png_metadata_and_gray_alpha() { assert!((a - 128.0 / 255.0).abs() < 1e-6); } +/// The working image has no alpha: transparency is flattened onto white, in linear light (issue #1212: +/// transparent areas, stored black, rendered black). +#[test] +fn png_transparency_develops_on_white() { + // opaque red, transparent black (as most encoders store it), half-transparent red + let data = [200, 40, 40, 255, 0, 0, 0, 0, 200, 40, 40, 128]; + let bytes = encode_png(&EncodeImage::new(3, 1, 4, Samples::U8(&data)), &EncodeMeta::default()).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(d.has_alpha); + let opaque = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&[200, 40, 40])), &EncodeMeta::default()).unwrap(); + let red = decode(&opaque, DecodeOptions::default()).unwrap().to_working().get(0, 0); + let w = d.to_working(); + let a = 128.0 / 255.0; + for (got, want) in [(w.get(0, 0), red), (w.get(1, 0), [1.0; 3]), (w.get(2, 0), red.map(|c| a * c + (1.0 - a)))] { + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}"); + } +} + #[test] fn p3_icc_to_working() { let icc = write_named(NamedSpace::DisplayP3); diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index 83b26403d..715e8ac33 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -99,8 +99,45 @@ impl ControlSpec { let s = format!("{:.*}", self.decimals as usize, v); if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s } } + /// Where `v` sits on the slider track, from 0 (left end) to 1 (right end). Linear, except for the Kelvin + /// temperature, which follows Lightroom's scale: 2000–7000 K fill the left half evenly (100 K per 1 %), and + /// the right half grows logarithmically up to 50000 K, so everyday temperatures aren't crowded into the + /// left sixth of the track. The relative temperature of rendered files (−100…+100, already in mireds) stays + /// linear. + pub fn position(&self, v: f64) -> f64 { + let v = self.clamp(v); + let p = match self.kelvin_scale() { + Some((centre, right)) if v > centre => 0.5 + 0.5 * (1.0 + (v - centre) / KELVIN_KNEE).ln() / right, + Some((centre, _)) => 0.5 * (v - self.min) / (centre - self.min), + None => (v - self.min) / (self.max - self.min).max(1e-9), + }; + if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 } + } + /// The value at track position `p` (0…1): the inverse of [`ControlSpec::position`], not yet on the steps. + pub fn value_at(&self, p: f64) -> f64 { + let p = if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }; + let v = match self.kelvin_scale() { + Some(_) if p >= 1.0 => self.max, + Some((centre, right)) if p > 0.5 => centre + KELVIN_KNEE * (((2.0 * p - 1.0) * right).exp() - 1.0), + Some((centre, _)) => self.min + 2.0 * p * (centre - self.min), + None => self.min + p * (self.max - self.min), + }; + self.clamp(v) + } + /// The Kelvin temperature's track centre and `ln` of its right half's span (see [`ControlSpec::position`]); + /// `None` for every other control, or a range the centre isn't inside. + fn kelvin_scale(&self) -> Option<(f64, f64)> { + let centre = KELVIN_CENTRE; + (self.id == "wb.temp" && self.min < centre && centre < self.max).then(|| (centre, (1.0 + (self.max - centre) / KELVIN_KNEE).ln())) + } } +/// The Kelvin temperature at the middle of its track, as in Lightroom. +const KELVIN_CENTRE: f64 = 7000.0; +/// Where the right half of the Kelvin track turns logarithmic: 1000 K gives it close to the left half's slope +/// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it. +const KELVIN_KNEE: f64 = 1000.0; + macro_rules! controls { ($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => { /// All numeric develop controls, in panel order. @@ -508,4 +545,46 @@ mod tests { assert_eq!(e.format(-1.0), "-1.00"); assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500"); } + + /// The Kelvin temperature's track follows Lightroom's (positions measured in Lightroom Classic against + /// the linear Tint slider; the model is within 2 % of them); every other control stays linear. + #[test] + fn the_kelvin_track_is_lightrooms_and_others_stay_linear() { + let temp = find("wb.temp").unwrap(); + for (k, lightroom, model) in [ + (2000.0, 0.0, 0.0), + (3650.0, 0.167, 0.165), + (5400.0, 0.333, 0.34), + (7000.0, 0.5, 0.5), + (15000.0, 0.79, 0.79), + (20000.0, 0.853, 0.849), + (30000.0, 0.90, 0.92), + (50000.0, 1.0, 1.0), + ] { + let p = temp.position(k); + assert!((p - model).abs() < 0.001, "{k} K at {p}, the model says {model}"); + assert!((p - lightroom).abs() <= 0.021, "{k} K at {p}, Lightroom at {lightroom}"); + } + // every 50 K step survives value → position → value, and the track never runs backwards + let mut last = -1.0; + for k in (2000..=50000).step_by(50).map(f64::from) { + let p = temp.position(k); + assert!(p > last, "{k} K"); + last = p; + let back = (temp.value_at(p) / temp.step).round() * temp.step; + assert_eq!(back, k, "{k} K → {p} → {back}"); + } + assert_eq!((temp.value_at(-1.0), temp.value_at(2.0), temp.value_at(f64::NAN)), (2000.0, 50000.0, 2000.0)); + assert_eq!((temp.position(1.0), temp.position(1e9), temp.position(f64::NAN)), (0.0, 1.0, temp.position(temp.default))); + let exposure = find("light.exposure").unwrap(); + for (v, p) in [(-5.0, 0.0), (-2.5, 0.25), (0.0, 0.5), (1.0, 0.6), (5.0, 1.0)] { + assert!((exposure.position(v) - p).abs() < 1e-12 && (exposure.value_at(p) - v).abs() < 1e-12, "{v}"); + } + // the relative temperature of rendered files (its own spec, centred on 0) isn't a Kelvin scale + let relative = ControlSpec { id: "wb.tempRel", min: -100.0, max: 100.0, default: 0.0, step: 1.0, ..*temp }; + assert_eq!((relative.position(-50.0), relative.value_at(0.75)), (0.25, 50.0)); + // a Kelvin range that doesn't contain the centre falls back to linear rather than dividing by zero + let narrow = ControlSpec { min: 7000.0, max: 9000.0, ..*temp }; + assert_eq!((narrow.position(8000.0), narrow.value_at(0.5)), (0.5, 8000.0)); + } } diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..741fc391d 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -10,7 +10,11 @@ use lightcraft_raster::{ Rgb32f, resample::{Filter, fit, resize}, }; -use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable}; +use lightcraft_raw::{ + ColorData, RawFormat, RawImage, + color::CameraTransform, + profile::{HsvTable, ProfileLook, ToneCurve}, +}; /// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone /// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because @@ -25,7 +29,8 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa /// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF /// states, for every make (#778). /// 4: a tone curve from black-and-white camera JPEGs (#577). -pub const LOOK_VERSION: u32 = 4; +/// 5: dark JPEG luminance pairs for the camera toe (#1307). +pub const LOOK_VERSION: u32 = 5; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -107,6 +112,69 @@ fn starting_colour_with( (xy, t, look) } +/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo +/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered +/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them). +pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option { + if lightcraft_raw::color::has_matrix(&raw.color) { + return None; + } + let (_, t, look) = starting_colour(raw, bytes); + dng_colour(&raw.color, &t, look.as_ref()) +} + +/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward +/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation +/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table +/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two +/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo +/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). +/// `None` when the white balance or the matrix can't give tags. +fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option { + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes + // into the baseline exposure + let white = m.apply([1.0; 3]); + let k = (white[0] + white[1] + white[2]) / 3.0; + if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) { + return None; + } + let m = Mat3(m.0.map(|r| r.map(|v| v / k))); + let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); + let wb = t.wb.map(f64::from); + let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); + let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2])); + Some(ColorData { + illuminant: [21, 0], + color_matrix: [Some(camera_to_xyz.inverse()?), None], + forward_matrix: [Some(forward), None], + camera_calibration: [None, None], + analog_balance: None, + as_shot_neutral: Some(neutral), + as_shot_white_xy: None, + baseline_exposure: t.baseline_exposure + k.log2(), + baseline_sharpness: tags.baseline_sharpness, + kelvin_presets: None, + profile: ProfileLook { + hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None], + tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)), + ..Default::default() + }, + }) +} + +/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the +/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). +fn dng_tone_curve(tone: &CameraTone) -> Option { + const N: usize = 256; + let samples = (0..=N).map(|i| { + let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32); + [x, tone.apply(x).min(1.0)] + }); + let flat: Vec = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect(); + ToneCurve::from_tag(&flat) +} + /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { @@ -265,12 +333,19 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { }) } +/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and +/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); +/// 128 MP also admits 100 MP medium-format previews. +const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; - let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; + let decoded = crate::files::decode_raw_preview( + bytes, + lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, + )?; let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { @@ -569,7 +644,7 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi if (0.015..=1.0).contains(&y) { bright.push((input.map(f64::from), output.map(f64::from))); } - if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) { + if !colour_pair(*input, *output) { continue; } let min = output.iter().copied().fold(f32::INFINITY, f32::min); @@ -580,6 +655,77 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi (pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright)) } +/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. +fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { + input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) + && output.iter().all(|v| *v > 0.004 && *v < 0.98) + && (0.015..0.85).contains(&luminance_2020(output)) +} + +/// Fit tone independently of colour eligibility when enough dark JPEG signal is available. +/// The original pairs and their split still own the colour fit and its acceptance gates. +/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and +/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise. +fn fit_tone_with_shadows( + fit: ToneFit, + pairs: &[([f64; 3], [f64; 3])], + sensor: &Rgb32f, + reference: &Rgb32f, + edge_limit: Option, + colour: impl Fn([f64; 3]) -> [f64; 3], +) -> Option { + let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y)); + let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect()); + let mut eligible = Vec::new(); + let (mut colour_index, mut extra_index) = (0, 0); + for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() { + if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) { + continue; + } + // About one 8-bit sRGB code above black: don't learn a flat black segment from + // JPEG compression. The sensor proxy averages many samples before this test. + if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5) + || !output.iter().all(|v| v.is_finite() && *v >= 0.0) + || !(0.0003..0.85).contains(&luminance_2020(*output)) + { + continue; + } + // A colour holdout must never enter tone training just because the wider sample set + // changes its index. Split the extra samples separately, keeping every colour index. + let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index }; + let held = *index % 3 == 0; + *index += 1; + let (x, y) = luminances(input.map(f64::from), output.map(f64::from)); + if x.is_finite() && x > 0.0 { + eligible.push((x, y, held)); + } + } + let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count(); + let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect(); + // Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank, + // so a single quantized grey also keeps the original curve. + if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION { + return original; + } + let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect(); + let Some(candidate) = fit.fit(training) else { return original }; + let error = |tone: &CameraTone| -> f64 { + held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum() + }; + // A wider histogram must also help independent dark pixels, not just redistribute the + // knots. The usual held-out gates still check the completed look on the colour pairs. + if let Some(old) = original { + let (before, after) = (error(&old), error(&candidate)); + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}"); + } + if !after.is_finite() || after >= before * 0.99 { + return original; + } + } + Some(candidate) +} + /// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs. fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { let mut gram = [[0.0; 3]; 3]; @@ -705,9 +851,7 @@ fn fit_pairs_on( let p = matrix.apply(x); correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) }; - let tone_pairs: Vec<_> = - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect(); - let Some(curve) = tone_fit.fit(tone_pairs) else { continue }; + let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue }; let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() }; if let Some(tone) = fit_chroma(&bright, &look) { look.tone = tone; @@ -849,9 +993,8 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) })), None => Mat3::IDENTITY, }; - let tone_pairs: Vec<_> = - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect(); - let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None }; + let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?; + let mut look = CameraLook { matrix, tone, hue_sat: None }; if let Some(tone) = fit_chroma(&bright, &look) { look.tone = tone; } @@ -957,9 +1100,8 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, let p = matrix.apply(x); correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) }; - let tone_pairs: Vec<_> = - pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(); - let look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: hue_sat.clone() }; + let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?; + let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); @@ -1317,6 +1459,167 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { mod tests { use super::*; + fn steep_toe_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + // A broad scene with a steep camera toe below the old JPEG floor of 0.015. + // Permute brightness so the held-out pixels span the same range independently. + let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25; + let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025]; + *src = rgb.map(|v| v * y / luminance_2020(rgb)); + let display = 3.0 * y * (y / (y + 0.02)).powi(2); + *dst = src.map(|v| v * display / y); + } + (sensor, reference) + } + + #[test] + fn camera_tone_follows_the_jpegs_deep_shadows() { + let (sensor, reference) = steep_toe_scene(); + let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + assert!(dark.len() > 256); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "deep-shadow luminance error {error}"); + assert_eq!(look.matrix, Mat3::IDENTITY); + } + + #[test] + fn colour_holdouts_stay_out_of_the_wider_tone_training() { + let (sensor, reference) = steep_toe_scene(); + let fit = |reference: &Rgb32f| { + let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap(); + fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() + }; + let expected = fit(&reference); + let mut changed = reference.clone(); + let mut index = 0; + for (x, y) in sensor.data.iter().zip(&mut changed.data) { + if colour_pair(*x, *y) { + if index % 3 == 0 { + *y = y.map(|v| v * 1.1); + assert!(colour_pair(*x, *y)); + } + index += 1; + } + } + assert_eq!(fit(&changed), expected); + } + + #[test] + fn camera_toe_is_robust_to_shadow_noise() { + let (sensor, clean) = steep_toe_scene(); + let mut noisy = clean.clone(); + for (i, p) in noisy.data.iter_mut().enumerate() { + if luminance_2020(*p) < 0.015 { + // Quantized JPEG levels with bounded noise and occasional compression outliers. + let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004; + *p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003); + if i % 23 == 0 { + *p = [0.014; 3]; + } + } + } + let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap(); + let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect(); + let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::() / dark.len() as f32; + assert!(error < 0.001, "noisy shadow luminance error {error}"); + assert_eq!(look.tone.apply(0.0), 0.0); + assert_eq!(look.matrix, Mat3::IDENTITY); + for i in 1..1000 { + assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0)); + } + } + + #[test] + fn camera_toe_requires_lower_independent_dark_error() { + for holdout_matches_original in [true, false] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 }; + *src = [y; 3]; + // Midtones teach the original slope of 3. Dark training teaches 0.3, + // but the independent dark holdout can agree with either slope. + let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 }; + *dst = [y * slope; 3]; + assert_eq!(colour_pair(*src, *dst), i >= 600); + } + let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap(); + let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); + // Both sample sets start at a multiple of three, so this split matches + // the helper's separate colour and extra-sample indices. + let candidate = fit_tone( + sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, _)| i % 3 != 0) + .map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from)))) + .collect(), + ) + .unwrap(); + let held: Pairs = + sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect(); + assert_eq!(held.len(), 200); + assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y)))); + assert!(rank_correlation_ties(&held) > 0.99); + let error = |tone: &CameraTone| { + held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::() + }; + assert_ne!(candidate, original); + let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap(); + if holdout_matches_original { + assert!(error(&candidate) > error(&original) + 0.1); + assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve"); + } else { + assert!(error(&candidate) < error(&original) * 0.99); + assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate"); + } + let mut previous = 0.0; + assert_eq!(selected.apply(0.0), 0.0); + for i in 0..10_000 { + let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0)); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + assert_eq!(selected.apply(f32::NAN), 0.0); + assert_eq!(selected.apply(f32::INFINITY), 0.0); + } + } + + #[test] + fn unreliable_dark_pairs_keep_the_original_tone_and_colour() { + let (sensor, reference) = steep_toe_scene(); + let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect(); + let mut excluded = sensor.clone(); + for &i in &dark { + excluded.data[i] = [f32::NAN; 3]; + } + let colour = Some((Mat3::IDENTITY, None)); + let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap(); + for case in 0..6 { + let (mut x, mut y) = (sensor.clone(), reference.clone()); + for (j, &i) in dark.iter().enumerate() { + match case { + 0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal + 1 => x.data[i] = [0.00001; 3], // below the sensor noise floor + 2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs + 3 => x.data[i] = [0.003; 3], // no scene luminance spread + 4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe + 5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise + _ => {} + } + } + let look = fit_pairs_with(&x, &y, colour.clone()).unwrap(); + assert_eq!(look.tone, original.tone, "case {case}"); + assert_eq!(look.matrix, original.matrix, "case {case}"); + assert!(look.hue_sat.is_none()); + } + } + #[test] fn camera_jpeg_matches_the_sensor_proxy_size_exactly() { // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy @@ -1834,17 +2137,19 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let own = lightcraft_raw::color::has_matrix(&raw.color); - let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some(); + let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); + let measured = lightcraft_raw::spectral::find(make, model).is_some(); + let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); let before = raw.color.clone(); let (_, t, fitted) = starting_colour(&mut raw, &bytes); - let spectral = !own && fitted.is_none() && covered; + let spectral = !own && fitted.is_none() && measured; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); - // white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the - // spectral matrices, whichever look it got (issue #730) + // white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin, + // through the spectral matrices or its presets, whichever look it got (issue #730) assert_eq!(info.relative_wb, !own && !covered, "{name}"); assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); if covered && !own { @@ -2062,6 +2367,43 @@ mod tests { assert!(info.camera_tone.is_some(), "no camera look fitted"); } + /// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same + /// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation + /// table and its tone curve; without a look, those of the camera transform. + #[test] + fn dng_colour_renders_like_the_raws_starting_colour() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37)); + assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb); + let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); + for look in [Some(&look), None] { + let dng = dng_colour(&ColorData::default(), &t, look).unwrap(); + let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); + for i in 0..3 { + assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); + } + let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); + for c in samples { + let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c)); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}"); + } + assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone())); + let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve); + match look { + Some(look) => { + let tone = tone.expect("the look's tone curve"); + let worst = + (0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max); + // (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here) + assert!(worst < 4e-3, "{worst}"); + } + None => assert!(tone.is_none()), + } + } + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { @@ -2317,6 +2659,36 @@ mod tests { } } + /// Sony ARWs without measured matrices read Kelvin from their white-balance presets + /// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and + /// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its + /// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files. + #[test] + fn corpus_arw_white_balance_in_kelvin_through_the_presets() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let header = crate::files::probe_bytes(name, &bytes).unwrap(); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); + assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}"); + assert!( + (info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, + "{name}: {} {}", + info.as_shot_temp, + info.as_shot_tint + ); + let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default()); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}"); + } + } + #[test] fn rejects_monochrome_invalid_and_unrelated_previews() { let mut sensor = Rgb32f::new(32, 32); @@ -2810,4 +3182,20 @@ mod tests { reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); } + + /// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted. + #[test] + fn corpus_arw6_full_frame_gets_a_camera_look() { + const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) }; + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/arw6-sony-ilce7rm6-ff-land-c.arw"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + } } diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index 8802460a1..f93a2434a 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -303,9 +303,20 @@ fn inspect(s: &mut Session, p: &Value) -> Result { if !src.extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx")) { return Err(bad(C, "choose an .onnx file with denoise-model.json beside it")); } - let bytes = read_capped(&src.with_file_name("denoise-model.json"), MAX_MANIFEST_BYTES) - .ok_or_else(|| bad(C, "denoise-model.json is missing or too large"))?; - let m = manifest::parse(&bytes).map_err(|e| bad(C, e.to_string()))?; + let sibling = src.with_file_name("denoise-model.json"); + let Some(bytes) = read_capped(&sibling, MAX_MANIFEST_BYTES) else { + let why = if sibling.is_file() { + "denoise-model.json could not be read or is too large" + } else if src.with_file_name("model.json").is_file() { + // other apps ship their models with a model.json of their own (#1381) + "No denoise-model.json beside this .onnx file. The model.json there is another app's description, which LightCraft \ + does not read: it needs its own denoise-model.json (see docs/denoise.md)" + } else { + "No denoise-model.json beside this .onnx file: LightCraft needs one that says how to use the model (see docs/denoise.md)" + }; + return Err(bad(C, why)); + }; + let m = manifest::parse(&bytes).map_err(|e| bad(C, format!("denoise-model.json: {e} (see docs/denoise.md)")))?; let dir = models_dir(s, C)?; Ok(json!({"kind": "model", "domain": "denoise", "path": path, "fileName": src.file_name().map(|n| n.to_string_lossy()), "model": m, "sizeBytes": std::fs::metadata(src).ok().map(|m| m.len()), "alreadyInstalled": dir.join(&m.id).join("model.onnx").is_file()})) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 42f59fe72..1ae72e48e 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -1079,15 +1079,47 @@ pub fn specs() -> Vec { let id = active(s, "history.clear")?; let d = s.develop_of(id).unwrap_or_default(); let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }]; - s.commit("Clear History", Op::SetHistory { id, history })?; + s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?; ok() }), - cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| { - let id = active(s, "history.restore")?; - let i = f64_req(p, "index", "history.restore")? as usize; - let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?; - s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?; - ok() + cmd!( + "history.restore", + "Go to History Step", + [], + None, + "{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit", + has_active, + |s, p| { + let c = "history.restore"; + let id = active(s, c)?; + let i = f64_req(p, "index", c)?; + if !(i.is_finite() && i >= 0.0) { + return Err(bad(c, "no such step")); + } + go_to_history_step(s, c, id, i as usize) + } + ), + cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| { + let c = "history.previous"; + let id = active(s, c)?; + let i = s + .catalog + .photo(id) + .and_then(|ph| ph.history_index()) + .and_then(|i| i.checked_sub(1)) + .ok_or_else(|| bad(c, "at the first History step"))?; + go_to_history_step(s, c, id, i) + }), + cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| { + let c = "history.next"; + let id = active(s, c)?; + let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; + let i = ph + .history_index() + .map(|i| i.saturating_add(1)) + .filter(|i| *i < ph.history.len()) + .ok_or_else(|| bad(c, "at the latest History step"))?; + go_to_history_step(s, c, id, i) }), // ---- interactions (slider drags, brush strokes) cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| { @@ -1104,3 +1136,32 @@ pub fn specs() -> Vec { }), ] } + +/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later +/// steps stay listed (one undo step). +fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result { + let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; + let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?; + let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i); + let label = format!("History: {}", st.label); + let edited = Some((s.clock)()); + s.commit( + &label, + Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] }, + )?; + ok() +} + +fn has_history_before(s: &Session) -> std::result::Result<(), String> { + let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index()); + if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) } +} + +fn has_history_after(s: &Session) -> std::result::Result<(), String> { + let photo = s.active().and_then(|id| s.catalog.photo(id)); + if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) { + Ok(()) + } else { + Err("at the latest History step".into()) + } +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 2d06d73f6..3a5575ea8 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -219,6 +219,12 @@ fn step(s: &mut Session, d: isize) -> Result { Ok(json!({"active": vis[next].0})) } +/// `library.up` / `library.down`: how many photos to move (at least 1, capped so hostile input +/// can't overflow). +fn row_step(p: &Value) -> isize { + p.get("rows").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1 << 20) as isize +} + fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { let targets = s.targets(p); let mut ops = Vec::new(); @@ -641,6 +647,24 @@ pub fn specs() -> Vec { }), cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)), cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)), + cmd!( + "library.down", + "Photo Below", + [], + Some("Down"), + "{rows?: n (photos to move forward; default 1, the grid passes the distance to the cell below)}", + always, + |s, p| { step(s, row_step(p)) } + ), + cmd!( + "library.up", + "Photo Above", + [], + Some("Up"), + "{rows?: n (photos to move back; default 1, the grid passes the distance to the cell above)}", + always, + |s, p| { step(s, -row_step(p)) } + ), // ---- rating / flags / labels cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| { let r = super::f64_req(p, "rating", "photo.rate")? as i64; diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 6c232860d..2e0b6f157 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -195,20 +195,21 @@ pub fn specs() -> Vec { cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| { Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect())) }), - cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| { - Ok(Value::Array( - crate::presets::PROFILES - .iter() - .map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)})) - .collect(), - )) + cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile (built-in, then imported LUTs marked `imported`) with its group and favourite flag", always, |s, _| { + let favorite = |id: &str| s.profile_favorites.iter().any(|f| f == id); + let builtin = crate::presets::PROFILES.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(p.id)})); + // imported LUT profiles too (#618), marked as in `profiles.menu` + let imported = + s.lut_profiles.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": favorite(&p.id), "imported": true})); + Ok(Value::Array(builtin.chain(imported).collect())) }), cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())), - cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| { + cmd!(query "history.list", "List History", [], None, "{id?} — returns {history: labels oldest first, cursor: index of the step the settings are at (later steps can be returned to), versions}", has_active, |s, p| { let id = photo_arg(s, p, "history.list")?; let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?; Ok(json!({ "history": ph.history.iter().map(|h| h.label.clone()).collect::>(), + "cursor": ph.history_index(), "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index 317a87aca..6af62637f 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -49,6 +49,10 @@ fn read(s: &mut Session, p: &Value) -> Result { } if !ops.is_empty() { s.commit("Read Metadata from File", Op::Batch { ops })?; + // Don't auto-write the sidecars just read: that would add our `lc:settings` to one that had + // only another app's `crs:` settings, and later reads prefer `lc:settings`, so that app's + // next edits would be ignored (issue #934). The next edit, or Save Metadata, writes them. + s.skip_auto_write = true; } let mut result = json!({"read": read, "failed": failed}); if !warnings.is_empty() { @@ -90,7 +94,7 @@ pub fn specs() -> Vec { "Read Metadata from File", ["Photo"], None, - "{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}", + "{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}", has_selection, read ), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..e53dfcbc4 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -28,6 +28,8 @@ pub enum ExportFormat { Original, /// A DNG of the raw data (raw photos only), the edits embedded as XMP. Dng, + /// Render to DNG: a linear DNG (16-bit float) of the rendered photo, the edit baked in. + RenderedDng, } impl ExportFormat { @@ -42,6 +44,7 @@ impl ExportFormat { "jxl" | "jpegxl" | "jpeg-xl" | "jpeg_xl" => Self::Jxl, "original" => Self::Original, "dng" => Self::Dng, + "rendereddng" | "renderdng" | "render-dng" | "lineardng" => Self::RenderedDng, _ => return None, }) } @@ -56,7 +59,7 @@ impl ExportFormat { Self::Jxl => "jxl", // the original keeps its own extension (see `ExportOptions::file_name_for`) Self::Original => "", - Self::Dng => "dng", + Self::Dng | Self::RenderedDng => "dng", } } @@ -623,6 +626,32 @@ pub enum MetadataPolicy { None, } +/// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or +/// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HdrCurve { + #[default] + Pq, + Hlg, +} + +impl HdrCurve { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "pq" => Some(Self::Pq), + "hlg" => Some(Self::Hlg), + _ => None, + } + } + fn codec(self) -> lightcraft_codecs::HdrTransfer { + match self { + Self::Pq => lightcraft_codecs::HdrTransfer::Pq, + Self::Hlg => lightcraft_codecs::HdrTransfer::Hlg, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ExportOptions { @@ -681,6 +710,8 @@ pub struct ExportOptions { pub add_to_photos: bool, /// The Photos album they go into, made when missing (empty = none). pub photos_album: String, + /// The curve of an HDR AVIF: PQ (default) or HLG. + pub hdr_transfer: HdrCurve, } impl Default for ExportOptions { @@ -710,6 +741,7 @@ impl Default for ExportOptions { jxl_progressive: false, add_to_photos: false, photos_album: String::new(), + hdr_transfer: HdrCurve::Pq, } } } @@ -751,6 +783,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "jxlProgressive", "addToPhotos", "photosAlbum", + "hdrTransfer", ]; /// Why an export can't add its files to Apple Photos on this platform (`None`: it can, when the @@ -923,6 +956,7 @@ impl ExportOptions { } "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, + "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, @@ -1061,6 +1095,7 @@ impl ExportOptions { jxl_progressive: p.get("jxlProgressive").and_then(Value::as_bool).unwrap_or(d.jxl_progressive), add_to_photos: p.get("addToPhotos").and_then(Value::as_bool).unwrap_or(d.add_to_photos), photos_album: s("photosAlbum").map_or(d.photos_album, |a| a.trim().to_string()), + hdr_transfer: s("hdrTransfer").and_then(HdrCurve::parse).unwrap_or(d.hdr_transfer), } } @@ -1133,6 +1168,8 @@ impl ExportOptions { (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::RenderedDng, _) if self.hdr => OutputDepth::F32Hdr, + (ExportFormat::RenderedDng, _) => OutputDepth::F32Linear, (ExportFormat::Psd, Some(8)) => OutputDepth::U8, (ExportFormat::Psd, _) => OutputDepth::U16, (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, @@ -1151,6 +1188,7 @@ impl ExportOptions { ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], + ExportFormat::RenderedDng => &[(16, "16-bit float")], ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], ExportFormat::Jxl => &[(8, "8-bit"), (16, "16-bit")], } @@ -1341,6 +1379,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), ExportFormat::Jxl => lightcraft_codecs::encode_jxl(&e, &o.jxl(space, false), &meta), f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), + ExportFormat::RenderedDng => return Err("Render to DNG needs a float render".into()), }; r.map_err(|e| e.to_string()) } @@ -1431,6 +1470,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met /// Encode a high-bit-depth image (see [`encode_rendered`]). pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + let meta_src = meta; let img = finish_deep(img, o); let profile = match img.samples { DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), @@ -1449,8 +1489,12 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) (ExportFormat::Png, _) => encode::encode_png(&e, &meta), (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), + (ExportFormat::RenderedDng, DeepSamples::F32(v)) => { + let m = meta_src.cloned().unwrap_or_default(); + return lightcraft_raw::write_rendered_dng(img.width, img.height, v, &img.space.rgb_space(), &m, xmp.clone()).map_err(|e| e.to_string()); + } (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { - lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) + lightcraft_codecs::encode_avif_hdr(w, h, v, o.quality, 8, &meta, o.hdr_transfer.codec()) } (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), (ExportFormat::Jxl, DeepSamples::F32(_)) if img.space == OutputSpace::Rec2020 => { @@ -1536,9 +1580,11 @@ pub struct Exported { /// Output size of photo `p` under `o` (its cropped full size when `o.resize` is `None`). pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, usize) { let (w, h) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop); + // whole pixels first: a crop's float noise (5999.999… for 6000) must not decide a half-pixel tie + let (w, h) = (w.round().max(1.0), h.round().max(1.0)); match &o.resize { Some(r) => r.apply(w, h), - None => ((w.round() as usize).max(1), (h.round() as usize).max(1)), + None => (w as usize, h as usize), } } @@ -2696,6 +2742,47 @@ mod tests { assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1)); } + #[test] + fn resize_size_survives_the_render() { + // the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312) + let cropped = |r: Resize, w: u32, h: u32, x1: f64| { + let mut p = named("IMG.ARW"); + (p.width, p.height) = (w, h); + std::sync::Arc::make_mut(&mut p.develop).crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, x1, 1.0); + let (bw, bh) = output_size(&p, &ExportOptions { resize: Some(r), ..Default::default() }); + lightcraft_pipeline::output_size(w as usize, h as usize, &p.develop, &lightcraft_pipeline::RenderRequest::fit(bw, bh)) + }; + let rendered = |r: Resize, w: u32, h: u32| cropped(r, w, h, 1.0); + let mode = |mode: ResizeMode, value: f32, height: u32| Resize { mode, value, height, dont_enlarge: true }; + // a frame a hair narrower than 3:2 (a raw's default crop in floats) puts 1333 × 1.5 just under 1999.5 + let hair = 1.0 - 1e-9; + assert_eq!(cropped(Resize::long_edge(2000), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(Resize::long_edge(2048), 6000, 4000, hair), (2048, 1365)); + assert_eq!(cropped(mode(ResizeMode::Width, 2000.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::ShortEdge, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(cropped(mode(ResizeMode::Height, 1333.0, 0), 6000, 4000, hair), (2000, 1333)); + assert_eq!(rendered(Resize::long_edge(2000), 4000, 6000), (1333, 2000)); + // 3:2, 4:3, 16:9, 1:1 and their portraits, every mode: the render keeps what `apply` says + for (w, h) in [(6000u32, 4000u32), (6240, 4160), (4032, 3024), (5184, 3888), (3840, 2160), (6000, 3375), (4000, 4000)] { + for (w, h) in [(w, h), (h, w)] { + for value in [1000.0, 1080.0, 1333.0, 2000.0, 2048.0, 2500.0, 3000.0] { + for r in [ + Resize::long_edge(value as u32), + mode(ResizeMode::ShortEdge, value, 0), + mode(ResizeMode::Width, value, 0), + mode(ResizeMode::Height, value, 0), + mode(ResizeMode::Dimensions, value, 1365), + mode(ResizeMode::Megapixels, value / 1000.0, 0), + mode(ResizeMode::Percent, value / 100.0, 0), + ] { + let want = r.apply(w.into(), h.into()); + assert_eq!(rendered(r, w, h), want, "{r:?} on {w}×{h}"); + } + } + } + } + } + /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. fn p3_green(space: OutputSpace) -> Rgba8 { use lightcraft_color::{DISPLAY_P3, REC2020}; @@ -3109,6 +3196,26 @@ mod tests { } // Issue #496: photos rendered side by side give the same files, names, order and progress as + // Render to DNG: the rendered photo as a linear float DNG that reads back as the same picture + #[test] + fn rendered_dng_matches_the_rendered_tiff() { + let mut s = crate::Session::with_demo(); + let id = s.visible()[0]; + let dng = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "rendereddng", "width": 64})), 1).unwrap(); + assert!(dng.file_name.ends_with(".dng"), "{}", dng.file_name); + let png = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "png", "width": 64})), 1).unwrap(); + let raw = lightcraft_raw::decode(&dng.bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (dng.width, dng.height, 3)); + assert!(raw.color.profile.tone_curve.is_some(), "identity tone curve keeps converters from adding one"); + let back = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let reference = lightcraft_codecs::decode_thumbnail(&png.bytes, 4096).unwrap().image; + assert_eq!((reference.width, reference.height), (back.width, back.height)); + // mean brightness: the PNG holds 8-bit sRGB, the DNG linear sRGB + let mean_dng: f32 = back.data.iter().map(|p| lightcraft_color::transfer::linear_to_srgb(p[1].min(1.0))).sum::() / back.data.len() as f32; + let mean_tif: f32 = reference.data.iter().map(|p| p[1] as f32 / 255.0).sum::() / reference.data.len() as f32; + assert!((mean_dng - mean_tif).abs() < 0.02, "{mean_dng} vs {mean_tif}"); + } + // one at a time, and a cancel stops at the same photo #[test] fn side_by_side_batches_match_one_at_a_time() { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..dc79b8b31 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -176,6 +176,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { kind: MediaKind::Raw, file_size: bytes.len() as u64, captured, + file_modified: None, meta, as_shot_wb, content_hash, @@ -207,6 +208,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { kind: MediaKind::Image, file_size: bytes.len() as u64, captured, + file_modified: None, meta, as_shot_wb: None, content_hash, @@ -508,15 +510,16 @@ fn load_bytes_now( /// /// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without /// (`camera_preview::file_local_look`) has one when its camera's matrices are measured -/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look -/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). +/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one +/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the +/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). struct WbScaleOf { own_matrix: bool, relative: bool, - /// Kelvin through the camera's measured matrices: edited on the relative scale before those were used. + /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. legacy_relative: bool, - /// The as-shot white through the measured matrices, when that is where the scale comes from. - spectral_white: Option, + /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. + scale_white: Option, } impl WbScaleOf { @@ -528,18 +531,26 @@ impl WbScaleOf { ) -> Self { let own_matrix = lightcraft_raw::color::has_matrix(color); let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; - let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten(); - let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers)); - WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white } + let model = meta.model.as_deref().filter(|_| fitted); + let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model)); + let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model)); + let scale_white = match camera { + Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers), + None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers), + None => None, + }; + let covered = camera.is_some() || presets; + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white } } /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through - /// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog - /// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65. + /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the + /// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to + /// `own`, D65. fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { if self.relative { return (6500.0, 0.0); } - let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own)); + let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own)); (t.round(), tint.round()) } } @@ -643,9 +654,16 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp /// Oriented size of the embedded preview of a raw file. fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { let jpeg = stand_in_image(bytes)?; - let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; - let (mut w, mut h) = (d.source_width, d.source_height); - if preview_orientation(bytes, d.orientation).swaps_axes() { + // headers only, as for other images (issue #367): decoding a full-size preview just for its + // size took about a second per file in an import; a decode remains the fallback + let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) { + Ok(header) => (header.width, header.height, header.orientation), + Err(_) => { + let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; + (d.source_width, d.source_height, d.orientation) + } + }; + if preview_orientation(bytes, orientation).swaps_axes() { std::mem::swap(&mut w, &mut h); } Some((w, h)) @@ -681,9 +699,10 @@ fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions { /// Filesystem-backed embedded-preview hook (native). /// /// The quick path reads the file's header and its embedded preview and nothing else (see -/// [`preview_window`]); a format whose preview the header cannot point at falls back to the -/// whole-file path, which is what every format used to cost. The memory gate still counts the whole -/// file, which is what the decode of one can hold. +/// [`preview_window`]); a container the header cannot point into is planned from its tags +/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a +/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts +/// the whole file, which is what the decode of one can hold. pub fn fs_preview_loader() -> PreviewLoader { Arc::new(|path: &str, max_edge: usize| { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); @@ -706,6 +725,10 @@ pub struct PreviewRead { /// Whether the preview came from a bounded read (`false`: the whole file was read, which is what /// every preview cost before the header path existed). pub header_path: bool, + /// Whether the sparse planner named the ranges, rather than a format's own header fields: the + /// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on + /// the header path, and on the whole-file fallback). + pub planned: bool, } impl PreviewRead { @@ -745,7 +768,13 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea { prefix = read_prefix_at(&mut file, path, extent.min(total))?; } - let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { return Ok(None) }; + let Some(range) = lightcraft_raw::bounded_preview(&prefix) else { + // The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose + // preview tag sits past the prefix, the containers whose preview only a scan finds: ask the + // sparse planner which ranges `embedded_preview` reads before handing the caller the + // whole-file fallback. + return planned_preview(&mut file, path, total); + }; // A preview at the file's end may have been padded past it: clamp rather than fail. let end = range.offset.saturating_add(range.window_len).min(total); let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?; @@ -767,7 +796,7 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() { return Ok(None); } - let read = PreviewRead { read: bytes_read, total, header_path: true }; + let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false }; // On a network share the bytes are the wait, so say what this preview actually cost. A window // taken from the prefix cost nothing beyond the prefix itself. if lightcraft_pipeline::profiling() { @@ -776,6 +805,107 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea Ok(Some((prefix, window, read))) } +/// The most times the sparse planner may name ranges before the preview gives up: the plan is +/// monotone (it never asks for a range twice), so a real file needs a handful. +const MAX_PREVIEW_PLAN_ROUNDS: usize = 8; +/// Tenths of a file the planner may have read before the preview gives up and the whole-file path +/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the +/// file has stopped being one. +const MAX_PREVIEW_PLAN_SHARE: u64 = 6; +/// The largest file the planner path builds its buffer for. The raws it serves are far below this; +/// a file past it is read whole as before rather than allocating its length up front. +const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30; +/// The header the quick path is handed on the planner route. The header route grows its prefix to +/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out +/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough +/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes. +const PLAN_PREFIX: u64 = 64 * 1024; + +/// The preview of a container the header-only path cannot answer for, from the byte ranges +/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads. +/// +/// The planner is asked about a buffer of the file's whole length in which only the ranges named so +/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The +/// buffer is the file's length, which the loader's memory gate already counts, and its untouched +/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in +/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so +/// this path's cost is the bytes read, not a promise about RSS. +/// +/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the +/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it +/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when +/// the quick path could not reproduce what the whole-file path shows (the two container facts +/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded +/// gives what the whole file gives), so a window that still fails to decode ends in the same +/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture. +fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result, Vec, PreviewRead)>, String> { + use std::io::{Read, Seek, SeekFrom}; + + if total > MAX_PREVIEW_PLAN_BYTES { + return Ok(None); + } + let Ok(len) = usize::try_from(total) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?; + let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) }; + head.copy_from_slice(&header); + let mut loaded = lightcraft_raw::sparse::Ranges::new(); + loaded.insert(0, header.len() as u64); + for _ in 0..MAX_PREVIEW_PLAN_ROUNDS { + match lightcraft_raw::sparse::preview_plan(&buf, &loaded) { + lightcraft_raw::sparse::HeaderPlan::Need(ranges) => { + let wanted = loaded.total().saturating_add(ranges.total()); + if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) { + return Ok(None); + } + for &(start, end) in ranges.spans() { + let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(a..b) else { return Ok(None) }; + file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?; + file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?; + loaded.insert(start, end); + } + } + lightcraft_raw::sparse::HeaderPlan::Ready => { + let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) }; + // The quick path hands the JPEG to the decoder alone, so what the *container* says + // about the preview has to be something the bytes it is handed can show. Two container + // facts change the pixels and are not in the JPEG: the preview's colour space (which + // the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries + // no orientation of its own, the file's EXIF orientation. Where either is present and + // the header cannot answer for it, the whole-file path keeps the preview — exactly as + // it already does for a container-declared colour space. A container that says sRGB + // agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other + // space declines. + if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) { + return Ok(None); + } + // `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side + // first: a JPEG that carries its own orientation needs nothing from the container. + if lightcraft_meta::extract(&window).orientation.is_none() + && lightcraft_meta::extract(&header).orientation.is_none() + && lightcraft_meta::extract(&buf).orientation.is_some() + { + return Ok(None); + } + let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true }; + // A plan-driven preview is the one that used to cost the whole file, so it is worth + // saying what it cost now too. + if lightcraft_pipeline::profiling() { + eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len()); + } + // The planner's buffer is the file's length, so give its pages back the way a decode + // does (see `fs_pair_loader`). + drop(buf); + crate::memory::release(); + return Ok(Some((header, window, read))); + } + lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None), + } + } + Ok(None) +} + /// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter), /// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in. fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result, String> { @@ -856,11 +986,21 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); let _permit = crate::memory::work_gate().acquire(len); let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - probe_bytes(path, &bytes) + let mut info = probe_bytes(path, &bytes)?; + info.file_modified = file_modified(path); + Ok(info) }); (loader, probe) } +/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the +/// filesystem doesn't say or the time is before 1970. +fn file_modified(path: &str) -> Option { + let t = std::fs::metadata(path).ok()?.modified().ok()?; + let secs = i64::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()?; + Some(crate::import::civil(secs)) +} + impl crate::Session { /// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos /// runner ([`crate::apple_photos::osascript`]; installed, not run). @@ -908,28 +1048,49 @@ mod tests { let d65 = lightcraft_color::D65; // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); - assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some()); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some()); let (t, tint) = a7m3.as_shot(d65); assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); - assert!(bare.legacy_relative && bare.spectral_white.is_none()); + assert!(bare.legacy_relative && bare.scale_white.is_none()); assert_eq!(bare.as_shot(d65), { let (t, tint) = xy_to_temp_tint(d65); (t.round(), tint.round()) }); // a camera without measured matrices: relative, whatever the multipliers let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); - assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none()); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none()); assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + // a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as + // 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65) + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with = ColorData { kelvin_presets: presets, ..Default::default() }; + let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2")); + assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some()); + let (t, tint) = a99m2.as_shot(d65); + assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); + assert!(bare.legacy_relative && bare.scale_white.is_none()); + // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone + let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1")); + assert!(other.relative && other.scale_white.is_none()); let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); // a file with matrices of its own (DNG): absolute from them, no matter whose camera let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); - assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none()); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none()); let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); @@ -967,6 +1128,7 @@ mod tests { let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview"); assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded"); assert!(read.header_path, "the header path is what answered"); + assert!(!read.planned, "a RAF pointer in the header needs no planner"); assert_eq!(read.total, file.len() as u64); assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}"); assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len()); @@ -985,6 +1147,179 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the + /// file's own orientation, and the EXIF colour space the container declares for its preview. + #[derive(Clone, Copy, Default)] + struct Fixture { + dng: bool, + sony: bool, + orientation: Option, + /// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB. + color_space: Option, + } + + /// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the + /// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of + /// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than + /// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach. + fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut preview = IfdBuilder::new(); + if what.dng { + preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + } + if what.sony { + preview.set(t::MAKE, Value::Ascii("SONY".into())); + } + if let Some(o) = what.orientation { + preview.set(t::ORIENTATION, Value::Short(vec![o])); + } + if let Some(space) = what.color_space { + let mut exif = IfdBuilder::new(); + exif.set(0xA001, Value::Short(vec![space])); + if space == 0xFFFF { + let mut interop = IfdBuilder::new(); + interop.set(0x0001, Value::Undefined(b"R03".to_vec())); + exif.set_child(t::INTEROP_IFD, interop); + } + preview.set_child(t::EXIF_IFD, exif); + } + preview.set(t::COMPRESSION, Value::Short(vec![7])); + preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + preview.set(t::IMAGE_WIDTH, Value::Long(vec![48])); + preview.set(t::IMAGE_LENGTH, Value::Long(vec![32])); + preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] }); + let mut raw = IfdBuilder::new(); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000])); + raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] }); + TiffWriter::default().write(&[preview, raw]).expect("the fixture writes") + } + + /// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview + /// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges + /// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels + /// must equal the whole-file path's, exactly; a file the planner cannot describe must decline + /// rather than guess, so the caller keeps reading it whole. + #[test] + fn preview_loading_plans_the_ranges_it_needs() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan"); + let path = dir.join("preview.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + let max_edge = 512; + + let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview"); + assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded"); + assert!(read.planned, "the planner is what answered"); + assert!(read.header_path, "and it is still a bounded read"); + assert_eq!(read.total, file.len() as u64); + assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len()); + assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len()); + + // The plan path and the whole-file path agree byte for byte. + let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview"); + let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too"); + assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size"); + assert_eq!(quick.data, whole.data, "byte for byte"); + + // The same structure without the tag that makes it a raw: the planner declines, and the + // caller's whole-file read is what answers (no guessing at what a file might hold). + let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default()); + std::fs::write(&path, ¬_a_raw).unwrap(); + assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A file whose preview the quick path could not show the way the whole-file path does is left to + /// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is + /// bigger than that header) and the preview JPEG carries none of its own, so a window read would + /// come out unrotated where the whole-file path rotates it — the plan must decline instead of + /// handing on a wrong picture. + #[test] + fn preview_loading_declines_an_orientation_only_the_whole_file_shows() { + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + // the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts + // IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image + let mut strip = jpeg.clone(); + strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize)); + let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() }); + let dir = crate::tests_xmp::temp_dir("preview-plan-orientation"); + let path = dir.join("rotated.dng"); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().into_owned(); + + assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation"); + assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see"); + assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own"); + assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A container can declare what its preview JPEG is in, and the whole-file path applies that + /// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so + /// a container that declares Adobe RGB must keep the whole-file path — while one that declares + /// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression + /// test, since a guard that declined every declared space did exactly that to a real Sony ARW. + #[test] + fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() { + use lightcraft_raw::PreviewColorSpace; + let (w, h) = (48u32, 32u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let dir = crate::tests_xmp::temp_dir("preview-plan-space"); + let path = dir.join("space.arw"); + let path = path.to_string_lossy().into_owned(); + + let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB"); + std::fs::write(&path, &adobe).unwrap(); + assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space"); + + let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() }); + assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB"); + std::fs::write(&path, &srgb).unwrap(); + let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes"); + assert!(read.planned, "the plan still answers"); + assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG"); + } + + /// A JPEG with no date in its metadata (an export saved without it) takes the file's date, as + /// in Lightroom, instead of grouping under "Unknown Date"; a dated one keeps its capture time. + #[test] + fn undated_files_take_the_file_date() { + let dir = std::env::temp_dir().join(format!("lightcraft-undated-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let rgb = vec![128u8; 16 * 16 * 3]; + let img = EncodeImage::new(16, 16, 3, Samples::U8(&rgb)); + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let path = dir.join("export.jpg"); + std::fs::write(&path, &jpeg).unwrap(); + let path = path.to_string_lossy().to_string(); + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let want = crate::import::civil(mtime as i64); + let (_, probe) = fs_hooks(); + let info = probe(&path).unwrap(); + assert_eq!(info.captured, None, "the file itself has no date"); + assert_eq!(info.file_modified.as_deref(), Some(want.as_str())); + let mut s = crate::Session::new().with_fs(); + let r = crate::import::import(&mut s, &[path], crate::import::ImportMode::Add).unwrap(); + let id = lightcraft_catalog::PhotoId(r.imported[0]); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some(want.as_str())); + let _ = std::fs::remove_dir_all(&dir); + } + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated /// file or one that isn't an image. @@ -1016,6 +1351,17 @@ mod tests { assert!(probe_bytes("x.jpg", b"not an image").is_err()); } + /// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets + /// start from), not on the black its transparent pixels store. + #[test] + fn transparent_png_develops_on_white() { + let rgba = [200, 40, 40, 255, 0, 0, 0, 0]; + let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap(); + let (img, _) = load_bytes(&png, 64).unwrap(); + assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0)); + assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0)); + } + /// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample /// of a block on a grey surface must not become green speckles (the block's green used to take the /// clip level, which highlight reconstruction then kept). @@ -1846,6 +2192,37 @@ mod tests { assert!(e.contains("without an embedded preview"), "{e}"); } + /// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606` + /// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic. + #[test] + fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); + let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mos = |leaf_data: bool| { + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] }); + if leaf_data { + ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat())); + } + TiffWriter::default().write(&[ifd0]).unwrap() + }; + let p = probe_bytes("aptus22.mos", &mos(true)).unwrap(); + assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30)); + assert!(p.preview_only.is_some_and(|why| !why.is_empty())); + let (img, src) = load_bytes(&mos(true), 20).unwrap(); + assert_eq!((img.width, img.height, src.raw), (20, 15, false)); + // the same grey image without Leaf's block is an ordinary 16-bit TIFF + let plain = probe_bytes("grey.tif", &mos(false)).unwrap(); + assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12)); + } + /// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its /// headers; loading it says what is wrong instead of naming a bare TIFF compression number. #[test] diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 20d1983e1..1f008e766 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -466,10 +466,18 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp /// Probe files (headers + content hash) as an import would. pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { - probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default()) + let progress = ScanProgress::default(); + probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) } -fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec> { +/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once +/// `cancel` is set: the files not reached come back as errors. +fn probe_all( + probe: Option<&crate::media::FileProbe>, + paths: &[String], + done: &std::sync::atomic::AtomicUsize, + cancel: &std::sync::atomic::AtomicBool, +) -> Vec> { use std::sync::atomic::Ordering::Relaxed; let Some(probe) = probe.cloned() else { return paths @@ -497,11 +505,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress if i >= paths.len() { break; } - if progress.cancel.load(Relaxed) { + if cancel.load(Relaxed) { break; } let r = probe(&paths[i]); - progress.done.fetch_add(1, Relaxed); + done.fetch_add(1, Relaxed); out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); } }); @@ -513,11 +521,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress paths .iter() .map(|p| { - if progress.cancel.load(Relaxed) { + if cancel.load(Relaxed) { return Err("cancelled".into()); } let r = probe(p); - progress.done.fetch_add(1, Relaxed); + done.fetch_add(1, Relaxed); r }) .collect() @@ -620,7 +628,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress .collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); progress.done.store(todo.len() - missing.len(), Relaxed); - let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter(); + let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); @@ -641,6 +649,8 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress Some(Ok(info)) => { (c.format, c.kind, c.width, c.height, c.file_size, c.captured) = (info.format.clone(), info.kind, info.width, info.height, info.file_size, info.captured.clone()); + // as the import will date it (a sidecar's date, when there is one, still wins there) + c.captured = c.captured.or_else(|| info.file_modified.clone()); c.preview_only = info.preview_only.clone(); if let Some(h) = &info.content_hash { match seen_hash.get(h) { @@ -705,6 +715,10 @@ pub struct ImportJob { /// Files found so far (folders expanded) and handled so far, for progress. pub total: std::sync::Arc, pub done: std::sync::Arc, + /// Of those, the files that have to be read first (not probed by a preceding scan) and the + /// ones read so far: the step before they are handled. + pub read_total: std::sync::Arc, + pub read_done: std::sync::Arc, } /// The outcome of [`ImportJob::prepare`] for one batch, in file order. @@ -857,6 +871,8 @@ impl ImportJob { now: (s.clock)(), total: Default::default(), done: Default::default(), + read_total: Default::default(), + read_done: Default::default(), }) } @@ -917,8 +933,10 @@ impl ImportJob { }) .collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); - let progress = ScanProgress::default(); - let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter(); + // Reading the files is the slow part of adding them where they are: it is counted on its + // own, and `cancel` stops it too (a large library takes long enough to want both). + self.read_total.fetch_add(missing.len(), Relaxed); + let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter(); let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); @@ -974,6 +992,10 @@ impl ImportJob { if info.captured.is_none() { info.captured = sidecar.as_ref().and_then(|sc| sc.captured.clone()); } + // no date in the file or its sidecar: the file's own date, as Lightroom does + if info.captured.is_none() { + info.captured = info.file_modified.clone(); + } let stored = match (mode, &self.copy_root) { (ImportMode::Copy | ImportMode::Move, Some(root)) if !crate::import_move::inside(Path::new(&path), root) @@ -1498,4 +1520,51 @@ mod prepared_tests { assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); } + + fn unread_paths(n: usize) -> Vec { + (0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect() + } + + /// Reading the files of a large import (a Lightroom catalog's originals) reported nothing: + /// the progress stood still at "14 of 24554" for as long as it took. + #[test] + fn prepare_counts_the_files_it_reads() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (done, last) = (job.read_done.clone(), done_in_probe.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + last.fetch_max(done.load(Relaxed), Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false)); + assert_eq!(prepared.items.len(), 40); + assert_eq!(seen.load(Relaxed), 40); + assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read"); + assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted"); + assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own"); + } + + /// Cancel had no effect until every file had been read. + #[test] + fn cancel_stops_reading_files() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + cancel_in_probe.store(true, Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(400), &cancel); + // at most the files the (up to 8) readers had already started + assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); + assert!(prepared.items.is_empty()); + } } diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 8a3d846fd..1894f7189 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -41,14 +41,25 @@ pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orient let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?; let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default()); let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?; - // the crop factor: the camera's, else what the 35 mm equivalent focal length implies - let crop = - camera.map(|c| c.crop_factor).or_else(|| m.focal_length_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c))); + let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm); let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h }; let corr = lens.correction(&shot)?; Some(lightcraft_pipeline::optics::reorient_lens(&corr, orientation, w, h)) } +/// The crop factor of the recorded frame, which scales the profile's radii: the body's (`camera`), else what the +/// 35 mm equivalent focal length implies. A body shooting a crop of its sensor (a full-frame camera in APS-C / +/// Super 35 mode, or switched there by an APS-C lens) records a larger 35 mm equivalent than its own crop factor +/// gives; the frame's corners are then nearer the optical axis, so that crop wins. Within 10 % of the body's, the +/// difference is the tag's whole-millimetre rounding and the body's value stands. +fn frame_crop_factor(camera: Option, focal: f64, focal_35mm: Option) -> Option { + let frame = focal_35mm.map(|f35| f35 / focal).filter(|c| c.is_finite() && (0.1..=20.0).contains(c)); + match (camera, frame) { + (Some(body), Some(frame)) if frame > body * 1.1 => Some(frame), + (body, frame) => body.or(frame), + } +} + /// [`crate::files::embedded_lens`], else the database profile ([`lookup`]) for a raw with metadata `meta`. pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option { crate::files::embedded_lens(raw).or_else(|| { @@ -98,6 +109,46 @@ mod tests { assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none()); } + /// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the + /// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's + /// corners don't get the full frame's corner correction. + #[test] + fn in_camera_crop_mode_uses_the_frames_crop_factor() { + let db = LensDb::parse( + r#" + SonyILCE-1M2Sony E1 + Sigma24-70mm F2.8 DG DN | Art 019Sony E1.0 + + + + + "#, + ) + .unwrap(); + let m = |f35: Option| lightcraft_meta::Metadata { + make: Some("SONY".into()), + model: Some("ILCE-1M2".into()), + lens_model: Some("24-70mm F2.8 DG DN | Art 019".into()), + focal_length: Some(70.0), + focal_length_35mm: f35, + f_number: Some(2.8), + ..Default::default() + }; + let at = |crop: f64, w: f64, h: f64| { + let shot = Shot { focal: 70.0, aperture: Some(2.8), distance: None, crop_factor: Some(crop), width: w, height: h }; + db.lenses[0].correction(&shot).unwrap() + }; + let aps_c = lookup(&db, &m(Some(105.0)), 5616.0, 3744.0, Orientation::Normal).unwrap(); + assert_eq!(aps_c, at(1.5, 5616.0, 3744.0)); + assert_ne!(aps_c, at(1.0, 5616.0, 3744.0), "the crop is not corrected as a full frame"); + // full frame, a 35 mm equivalent rounded off by a millimetre, or none at all: the body's crop factor + for f35 in [Some(70.0), Some(71.0), None] { + assert_eq!(lookup(&db, &m(f35), 8640.0, 5760.0, Orientation::Normal).unwrap(), at(1.0, 8640.0, 5760.0), "{f35:?}"); + } + assert_eq!(frame_crop_factor(None, 70.0, Some(105.0)), Some(1.5), "no body in the database: the 35 mm equivalent's"); + assert_eq!(frame_crop_factor(Some(1.0), 0.0, Some(105.0)), Some(1.0), "a hostile focal length is ignored"); + } + /// A synthetic grid recorded through the profile's barrel distortion comes out of the pipeline's geometry /// stage with straight lines. #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..9631b7d84 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -130,7 +130,8 @@ fn single_photo(op: &Op, depth: usize) -> Option { | Op::SetMeta { id, .. } | Op::SetVersions { id, .. } | Op::SetHistory { id, .. } - | Op::PushHistory { id, .. } => Some(*id), + | Op::PushHistory { id, .. } + | Op::SetHistoryCursor { id, .. } => Some(*id), Op::Batch { ops } if depth < 8 => { let mut ids = ops.iter().map(|o| single_photo(o, depth + 1)); let first = ids.next()??; @@ -221,7 +222,8 @@ pub struct Session { pub previous_active: Option, /// Groups last used for Copy (Lightroom remembers them). pub copy_groups: Vec, - pub presets: Vec, + /// Built-in then user presets; its generation tells savers and caches when it changed. + pub presets: presets::PresetList, /// Favourite profile ids (persisted with the library, like preset favourites). pub profile_favorites: Vec, /// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]). @@ -354,7 +356,7 @@ impl Session { library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), - presets: presets::builtin(), + presets: presets::PresetList::new(presets::builtin()), profile_favorites: Vec::new(), profile_recent: Vec::new(), journal: Vec::new(), @@ -881,11 +883,17 @@ impl Session { } } if self.source == LibrarySource::RecentlyAdded { - // newest import first, whatever the sort (the grid groups by import day) + // newest import first, whatever the sort direction (the grid groups by import day); + // a batch stays in file order (name, then id) let cat = &self.catalog; visible.sort_by(|a, b| { let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default(); - key(b).cmp(&key(a)).then(a.cmp(b)) + let name = |id: &PhotoId| cat.photo(*id).map(|p| p.file_name.as_str()); + let by_name = match (name(a), name(b)) { + (Some(x), Some(y)) => lightcraft_catalog::query::cmp_name(x, y), + _ => std::cmp::Ordering::Equal, + }; + key(b).cmp(&key(a)).then(by_name).then(a.cmp(b)) }); } if self.source == LibrarySource::Missing { @@ -999,6 +1007,8 @@ mod tests_folders; #[cfg(test)] mod tests_forget_local; #[cfg(test)] +mod tests_history_cursor; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; @@ -1019,6 +1029,8 @@ mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_presets_scale; +#[cfg(test)] mod tests_process; #[cfg(test)] mod tests_segment; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c2..0305623f2 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -88,7 +88,8 @@ pub struct Library { retry_at: Option, /// What forgetting untouched Local records did when the library opened. pub forgot_local: Option, - presets_written: String, + /// What presets.json holds (see [`PresetsSaved`]). + presets_saved: PresetsSaved, view_written: Vec, /// Settings files that were unreadable or damaged when the library opened (`library.info` → /// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]). @@ -123,6 +124,53 @@ struct PresetsFile { profile_recent: Vec, } +/// The state presets.json was last written (or read) from. Saving checks the preset list's +/// generation and the small profile lists before serializing anything: with a 10 000-preset +/// collection presets.json is ~40 MB, and serializing it after every command made each slider +/// tick take seconds. +#[derive(Default)] +struct PresetsSaved { + generation: u64, + profile_favorites: Vec, + profile_recent: Vec, + /// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten. + hash: u64, +} + +impl PresetsSaved { + fn state_of(s: &Session) -> PresetsSaved { + PresetsSaved { + generation: s.presets.generation(), + profile_favorites: s.profile_favorites.clone(), + profile_recent: s.profile_recent.clone(), + hash: 0, + } + } + + /// `s` changed its presets or profile lists since this state. + fn differs(&self, s: &Session) -> bool { + self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent + } +} + +fn bytes_hash(b: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + b.hash(&mut h); + h.finish() +} + +#[cfg(test)] +thread_local! { + static PRESETS_SERIALIZED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread serialized the preset list (tests). +#[cfg(test)] +pub(crate) fn presets_serialized() -> usize { + PRESETS_SERIALIZED.with(|c| c.get()) +} + #[derive(Default, Serialize, Deserialize)] #[serde(default)] struct ViewFile { @@ -187,6 +235,8 @@ struct PrefsFile { } fn presets_json(s: &Session) -> String { + #[cfg(test)] + PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1)); let presets = &s.presets; let f = PresetsFile { user: presets.iter().filter(|p| !p.builtin).cloned().collect(), @@ -414,8 +464,9 @@ impl Session { for p in &mut self.presets { p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite); } + let mut ids: std::collections::HashSet = self.presets.iter().map(|p| p.id.clone()).collect(); for u in f.user { - if !self.presets.iter().any(|p| p.id == u.id) { + if ids.insert(u.id.clone()) { self.presets.push(u); } } @@ -447,7 +498,8 @@ impl Session { { self.selection = Selection::single(*first); } - let presets_written = presets_json(self); + // presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes) + let presets_saved = PresetsSaved::state_of(self); // photo ids are per library: drop decoded sources of the previous one self.media.clear_sources(); if on_disk { @@ -464,7 +516,7 @@ impl Session { unsaved_error: None, retry_at: None, forgot_local: None, - presets_written, + presets_saved, view_written, settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(), warnings_reported: 0, @@ -526,14 +578,21 @@ impl Session { log::error!("library: compaction: {e}"); lib.last_error = Some(format!("compaction: {e}")); } + let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json")); + if !due { + return Ok(()); + } let presets = presets_json(self); + let mut state = PresetsSaved::state_of(self); + state.hash = bytes_hash(presets.as_bytes()); let Some(lib) = self.library.as_mut() else { return Ok(()) }; - if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") { - if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - log::error!("library: presets: {e}"); - } else { - lib.presets_written = presets; - } + if state.hash == lib.presets_saved.hash { + lib.presets_saved = state; + } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { + // the state stays stale: the next save tries again + log::error!("library: presets: {e}"); + } else { + lib.presets_saved = state; } Ok(()) } diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 33ed64a2d..ffd6fc183 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -129,6 +129,65 @@ pub struct LightroomCompletion { /// Alias used by hosts that call the result a commit. pub type LightroomCommit = LightroomCompletion; +/// The step of preparing a Lightroom import that is under way ([`LightroomProgress::step`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LightroomStep { + /// Reading the catalog's tables. + Catalog, + /// Reading the photos the catalog names (headers, content hash): nearly all of the time. + ReadPhotos, + /// Readying the photos that were read for the catalog. + AddPhotos, +} + +/// Progress of [`LightroomJob::prepare`] step by step, shared with the thread that runs it. +#[derive(Clone, Default)] +pub struct LightroomProgress { + /// Catalog tables to read and read so far ([`lightroom_catalog::read_with_progress`]). + pub catalog_total: Arc, + pub catalog_done: Arc, + read_total: Arc, + read_done: Arc, + add_total: Arc, + add_done: Arc, + /// The catalog is read: the photos are next. + photos: Arc, +} + +impl LightroomProgress { + /// Reading the photos is nearly all of an import's time; the catalog and readying the photos + /// take the rest of [`LightroomProgress::fraction`]. + const CATALOG_SHARE: f32 = 0.05; + const READ_SHARE: f32 = 0.85; + + /// The step under way, with how much of it is done and its total (0 while not yet known). + pub fn step(&self) -> (LightroomStep, usize, usize) { + let load = |n: &AtomicUsize| n.load(Ordering::Relaxed); + if !self.photos.load(Ordering::Relaxed) { + return (LightroomStep::Catalog, load(&self.catalog_done), load(&self.catalog_total)); + } + let (read_done, read_total) = (load(&self.read_done), load(&self.read_total)); + let (add_done, add_total) = (load(&self.add_done), load(&self.add_total)); + // nothing to read (every photo is already in the library) goes straight to adding + if read_done < read_total || (read_total == 0 && add_done == 0) { + (LightroomStep::ReadPhotos, read_done, read_total) + } else { + (LightroomStep::AddPhotos, add_done, add_total) + } + } + + /// The whole preparation, 0..=1. + pub fn fraction(&self) -> f32 { + let (step, done, total) = self.step(); + let part = if total == 0 { 0.0 } else { (done as f32 / total as f32).clamp(0.0, 1.0) }; + match step { + LightroomStep::Catalog => Self::CATALOG_SHARE * part, + LightroomStep::ReadPhotos => Self::CATALOG_SHARE + Self::READ_SHARE * part, + LightroomStep::AddPhotos => Self::CATALOG_SHARE + Self::READ_SHARE + (1.0 - Self::CATALOG_SHARE - Self::READ_SHARE) * part, + } + } +} + /// A Lightroom import detached from the owner thread. pub struct LightroomJob { path: PathBuf, @@ -136,6 +195,7 @@ pub struct LightroomJob { import: ImportJob, token: LightroomLibraryToken, archive_dir: Option, + progress: LightroomProgress, } impl LightroomJob { @@ -145,33 +205,25 @@ impl LightroomJob { let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() }; let import = ImportJob::new(s, opts)?; let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop")); - Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir }) + let progress = LightroomProgress { + read_total: Arc::clone(&import.read_total), + read_done: Arc::clone(&import.read_done), + add_total: Arc::clone(&import.total), + add_done: Arc::clone(&import.done), + ..LightroomProgress::default() + }; + Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir, progress }) } - pub fn total(&self) -> usize { - self.import.total.load(Ordering::Relaxed) - } - - pub fn done(&self) -> usize { - self.import.done.load(Ordering::Relaxed) - } - - pub fn progress(&self) -> (usize, usize) { - (self.done(), self.total()) + /// The job's progress, to watch from another thread while [`LightroomJob::prepare`] runs. + pub fn progress(&self) -> LightroomProgress { + self.progress.clone() } pub fn token(&self) -> &LightroomLibraryToken { &self.token } - pub fn total_atomic(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.import.total) - } - - pub fn done_atomic(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.import.done) - } - /// Finish an owner commit's deferred archive-index write on a worker. pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> { finalization.finish() @@ -182,14 +234,12 @@ impl LightroomJob { if cancel.load(Ordering::Relaxed) { return Err(error("Lightroom import cancelled")); } - self.import.total.store(1, Ordering::Relaxed); - self.import.done.store(0, Ordering::Relaxed); - let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?; + let mut data = + lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?; lightroom_catalog::validate_for_job(&data).map_err(error)?; if cancel.load(Ordering::Relaxed) { return Err(error("Lightroom import cancelled")); } - self.import.done.fetch_add(1, Ordering::Relaxed); let paths: Vec = data .photos .iter() @@ -203,6 +253,9 @@ impl LightroomJob { .filter(|photo| !Path::new(&photo.path).is_file()) .map(|photo| normalize(&photo.path)) .collect(); + // the catalog step's last unit (validation, and looking for each original), then the photos + self.progress.catalog_done.fetch_add(1, Ordering::Relaxed); + self.progress.photos.store(true, Ordering::Relaxed); let normal = self.import.prepare(&paths, cancel); if cancel.load(Ordering::Relaxed) { normal.rollback(); @@ -335,6 +388,48 @@ mod tests { } } + /// One count for the whole import read "14 of 24554" while the photos were read, then twice + /// the number of photos: each step has its own count, and the whole only moves forward. + #[test] + fn progress_names_each_step_with_its_own_count() { + let p = LightroomProgress::default(); + let set = |n: &AtomicUsize, v: usize| n.store(v, Ordering::Relaxed); + assert_eq!(p.step(), (LightroomStep::Catalog, 0, 0)); + assert_eq!(p.fraction(), 0.0); + set(&p.catalog_total, 14); + set(&p.catalog_done, 7); + assert_eq!(p.step(), (LightroomStep::Catalog, 7, 14)); + let mut last = p.fraction(); + assert!(last > 0.0); + let mut forward = |p: &LightroomProgress| { + let f = p.fraction(); + assert!(f >= last && f <= 1.0, "{f} after {last}"); + last = f; + }; + set(&p.catalog_done, 14); + p.photos.store(true, Ordering::Relaxed); + assert_eq!(p.step(), (LightroomStep::ReadPhotos, 0, 0), "the photos are not counted yet"); + forward(&p); + set(&p.add_total, 100); + set(&p.read_total, 100); + set(&p.read_done, 40); + assert_eq!(p.step(), (LightroomStep::ReadPhotos, 40, 100)); + forward(&p); + set(&p.read_done, 100); + set(&p.add_done, 25); + assert_eq!(p.step(), (LightroomStep::AddPhotos, 25, 100)); + forward(&p); + set(&p.add_done, 100); + forward(&p); + assert!(p.fraction() > 0.999); + // every photo already in the library: nothing to read + let known = LightroomProgress::default(); + known.photos.store(true, Ordering::Relaxed); + set(&known.add_total, 5); + set(&known.add_done, 5); + assert_eq!(known.step(), (LightroomStep::AddPhotos, 5, 5)); + } + #[test] fn cancellation_before_commit_leaves_owner_catalog_unchanged() { let mut session = crate::Session::new(); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..cf17c3264 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; @@ -223,6 +223,13 @@ impl DecodedSource { #[derive(Clone)] pub enum SourceRef { Loaded(Box), + /// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of + /// the photo open in the loupe is cut from its preview-level source instead of decoding the + /// file again (a second or more for a 64 MP raw). + Fit { + from: Box, + max_edge: usize, + }, Demo { scene: Box, max_edge: usize, @@ -251,6 +258,13 @@ impl SourceRef { pub fn load_source(&self) -> Result { let image = match self { SourceRef::Loaded(a) => return Ok((**a).clone()), + SourceRef::Fit { from, max_edge } => { + if from.image.width.max(from.image.height) <= *max_edge { + return Ok((**from).clone()); + } + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); + } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, SourceRef::File { path, max_edge, loader, fallback, denoise } => { let plain = || match loader { @@ -314,6 +328,8 @@ pub struct MediaCache { full: Vec<(PhotoId, DecodedSource, u64)>, /// How many full-resolution originals to keep (LRU, at least one). full_capacity: usize, + /// The photo the loupe shows ([`Self::set_on_screen`]): its original is not evicted for the budget. + on_screen: Option, /// How many previews to keep (LRU). pub preview_capacity: usize, /// Bytes all decoded sources and rendered previews in memory may take together (the cache @@ -391,6 +407,7 @@ impl Default for MediaCache { previews: Vec::new(), full: Vec::new(), full_capacity: 1, + on_screen: None, preview_capacity: 0, budget, file_loader: None, @@ -447,6 +464,16 @@ impl MediaCache { self.trim_full(); } + /// The photo the loupe shows (the frontend sets it every frame; `None` in other views). Its original stays + /// even over budget: a 64 MP original (797 MB as linear float) alone exceeds the default share, and evicting it + /// made every pan of a zoomed view decode the raw again. Showing another photo lets it go at once. + pub fn set_on_screen(&mut self, id: Option) { + if self.on_screen != id { + self.on_screen = id; + self.enforce_budget(); + } + } + fn trim_full(&mut self) { while self.full.len() > self.full_capacity { let oldest = self.full.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); @@ -475,15 +502,20 @@ impl MediaCache { /// Evict least recently used entries across the source caches and the rendered previews /// until they fit the budget. The newest preview-level source (the photo on screen, or the - /// one just decoded) is never evicted here. + /// one just decoded) is never evicted here, nor is the original of the photo on screen + /// ([`Self::set_on_screen`]). pub fn enforce_budget(&mut self) { - let mut held = self.held(); + // The on-screen original is never evicted here, so it does not count either: counted, a 64 MP original (760 MiB) + // nearly fills the default share (768 MiB) alone and everything else went instead — the photo's preview and + // thumbnail sources and the neighbours' were decoded again from the raw on every edit, zoom-out and step. + let pinned: usize = self.full.iter().filter(|e| Some(e.0) == self.on_screen).map(|e| e.1.bytes()).sum(); + let mut held = self.held().saturating_sub(pinned); while held > self.budget { let newest_preview = self.previews.iter().map(|e| e.2).max(); let candidates = [ self.thumbs.oldest_tick().map(|t| (t, 0)), self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), - self.full.iter().map(|e| e.2).min().map(|t| (t, 2)), + self.full.iter().filter(|e| Some(e.0) != self.on_screen).map(|e| e.2).min().map(|t| (t, 2)), self.rendered.oldest_tick().map(|t| (t, 3)), ]; let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; @@ -625,6 +657,15 @@ impl MediaCache { if cached && let Some(a) = self.get_source(p.id, level) { return SourceRef::Loaded(Box::new(a)); } + // a thumbnail of a photo whose preview is decoded (the one in the loupe) is cut from it; not + // with a denoised picture, which the thumbnail would lose + if cached + && level == SourceLevel::Thumb + && self.denoise.spec(p.id).is_none() + && let Some(big) = self.get_source(p.id, SourceLevel::Preview) + { + return SourceRef::Fit { from: Box::new(big), max_edge: level.max_edge() }; + } // Procedural scenes have a nominal size: "full" is that size, not unbounded. let max_edge = level.max_edge().min(match (&p.source, level) { (Source::Demo { .. }, SourceLevel::Full) => p.width.max(p.height).max(1) as usize, @@ -803,7 +844,31 @@ impl RenderJob { self } + /// Render. A panic inside (a decoder or pipeline bug) is this job's failure: the view waiting + /// for it is told, instead of waiting forever on a worker that died (the pool doesn't replace + /// it, so a few such panics used to stop every render). pub fn run(self) -> RenderResult { + let failed = self.failed(String::new()); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { rendered: Err(e), ..failed }) + } + + /// This job's result when it fails with `e`. + fn failed(&self, e: String) -> RenderResult { + RenderResult { + request_id: self.request_id, + source_key: self.source_key, + photo: self.photo, + level: self.level, + key: self.key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + } + } + + fn run_unguarded(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) { @@ -877,18 +942,7 @@ impl RenderJob { display, } } - Err(e) => RenderResult { - request_id: self.request_id, - source_key: self.source_key, - photo: self.photo, - level: self.level, - key: self.key, - rendered: Err(e), - gpu_fallback: None, - loaded: None, - quick: None, - display: None, - }, + Err(e) => self.failed(e), } } } @@ -976,7 +1030,25 @@ pub struct QuickJob { } impl QuickJob { + /// The stand-in, from the first source that has one. A panic inside is this job's failure + /// (see [`RenderJob::run`]). pub fn run(self) -> RenderResult { + let (request_id, photo, key) = (self.request_id, self.photo, self.key); + crate::guard::catch("the render", || self.run_unguarded()).unwrap_or_else(|e| RenderResult { + request_id, + source_key: None, + photo, + level: SourceLevel::Thumb, + key, + rendered: Err(e), + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }) + } + + fn run_unguarded(self) -> RenderResult { let (photo, key) = (self.photo, self.key); let request_id = self.request_id; let done = |rendered: Result, quick| RenderResult { @@ -1479,6 +1551,10 @@ pub struct ProbeInfo { pub kind: MediaKind, pub file_size: u64, pub captured: Option, + /// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the + /// capture date of a file whose metadata has none, as Lightroom does (exports saved without + /// metadata would otherwise all land under "Unknown Date"). + pub file_modified: Option, pub meta: lightcraft_catalog::Meta, pub as_shot_wb: Option<(f64, f64)>, /// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit @@ -1588,6 +1664,31 @@ mod thumbnail_hash_tests { s.accept(&old); assert_eq!(s.media.source_usage().0, 0); } + /// A panic while rendering (here in the decoder) used to end the pool's worker thread without + /// an answer, so the view that asked showed "Rendering…" forever. It is the job's failure now, + /// for renders and for stand-ins, answering the request it came from. + #[test] + fn a_render_that_panics_fails_instead_of_never_answering() { + let mut s = session(); + s.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + let mut job = s.thumb_job(PhotoId(1), 256).unwrap(); + job.request_id = 42; + let (key, level, source_key) = (job.key, job.level, job.source_key); + let r = job.run(); + assert_eq!((r.request_id, r.photo, r.key, r.level, r.source_key), (42, PhotoId(1), key, level, source_key)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: decoder bug")); + let quick = QuickJob { + request_id: 7, + photo: PhotoId(1), + key: 9, + cached: Vec::new(), + embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), + small: None, + }; + let r = quick.run(); + assert_eq!((r.request_id, r.key), (7, 9)); + assert_eq!(r.rendered.err().as_deref(), Some("the render failed unexpectedly: preview reader bug")); + } } #[cfg(test)] @@ -1793,6 +1894,45 @@ mod tests { assert!(m.has_source(PhotoId(3), SourceLevel::Full)); } + // Given the on-screen original alone over the budget, the photo's other sources and its neighbour's stay + // (counted, it evicted them all: each edit decoded the raw again for the thumbnail and the whole-frame render) + #[test] + fn the_on_screen_original_does_not_evict_the_other_sources() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Thumb, img(200)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + for (id, level) in [(1, SourceLevel::Preview), (1, SourceLevel::Thumb), (1, SourceLevel::Full), (2, SourceLevel::Preview)] { + assert!(m.has_source(PhotoId(id), level), "{id} {level:?}"); + } + } + + // A 64 MP original (797 MB as linear float) is bigger than the default cache share (768 MiB): + // evicted on insert, every pan of a zoomed view decoded the raw again. The original of the photo + // on screen stays, whatever is decoded after it (prefetched neighbours), until another is shown. + #[test] + fn the_original_on_screen_stays_even_over_budget() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(20 * mb); + m.set_on_screen(Some(PhotoId(1))); + m.insert(PhotoId(1), SourceLevel::Preview, img(500)); + m.insert(PhotoId(1), SourceLevel::Full, img(1500)); // 27 MB + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.insert(PhotoId(2), SourceLevel::Preview, img(500)); // a prefetched neighbour + m.insert(PhotoId(3), SourceLevel::Thumb, img(100)); + assert!(m.has_source(PhotoId(1), SourceLevel::Full)); + m.set_on_screen(Some(PhotoId(2))); + assert!(!m.has_source(PhotoId(1), SourceLevel::Full)); + assert!(m.held() <= 20 * mb, "{}", m.held()); + } + #[test] fn work_gate_waits_for_room() { use crate::memory::WorkGate; @@ -1931,6 +2071,24 @@ mod tests { assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); } + /// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here + /// one nothing can decode) is not read again. + #[test] + fn a_thumbnail_is_cut_from_the_decoded_preview() { + let mut s = crate::Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: "/nowhere/a.ARW".into() }, "a.ARW", "ARW", 2400, 1600, ""); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + s.media.insert(p.id, SourceLevel::Preview, Arc::new(Rgb32f::from_fn(2400, 1600, |x, _| [x as f32 / 2400.0, 0.5, 0.25]))); + let job = s.thumb_job(p.id, 512).unwrap(); + assert!(matches!(job.source, SourceRef::Fit { .. })); + let r = job.run(); + let thumb = r.rendered.as_ref().unwrap(); + assert_eq!((thumb.image.width, thumb.image.height), (512, 341)); + s.accept(&r); + let cached = s.media.get(p.id, SourceLevel::Thumb).unwrap(); + assert_eq!((cached.width, cached.height), (512, 341), "a thumbnail-sized source is kept, not the preview"); + } + #[test] fn has_source_follows_what_the_session_accepted() { let mut s = crate::Session::with_demo(); diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 53c7f546e..6a51dc316 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -110,7 +110,9 @@ impl MergeJob { .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}")); + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .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 diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 8c36aa742..c3675e227 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -23,6 +23,73 @@ pub struct PresetFile { pub presets: Vec, } +/// The session's presets (built-in, then the user's) with a generation that changes on every +/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is +/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON. +/// +/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`, +/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed. +/// Generations are unique across all lists in the process. +#[derive(Clone, Debug)] +pub struct PresetList { + items: Vec, + generation: u64, +} + +static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_generation() -> u64 { + PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +impl PresetList { + pub fn new(items: Vec) -> PresetList { + PresetList { items, generation: next_generation() } + } + + /// Changes whenever the list may have changed. + pub fn generation(&self) -> u64 { + self.generation + } +} + +impl Default for PresetList { + fn default() -> PresetList { + PresetList::new(Vec::new()) + } +} + +impl std::ops::Deref for PresetList { + type Target = Vec; + fn deref(&self) -> &Vec { + &self.items + } +} + +impl std::ops::DerefMut for PresetList { + fn deref_mut(&mut self) -> &mut Vec { + self.generation = next_generation(); + &mut self.items + } +} + +impl<'a> IntoIterator for &'a PresetList { + type Item = &'a Preset; + type IntoIter = std::slice::Iter<'a, Preset>; + fn into_iter(self) -> Self::IntoIter { + self.items.iter() + } +} + +impl<'a> IntoIterator for &'a mut PresetList { + type Item = &'a mut Preset; + type IntoIter = std::slice::IterMut<'a, Preset>; + fn into_iter(self) -> Self::IntoIter { + self.generation = next_generation(); + self.items.iter_mut() + } +} + /// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`). pub fn slug(s: &str) -> String { let mut out = String::new(); @@ -107,22 +174,33 @@ impl Session { /// Add imported presets. Identical presets (same name, group and settings) already present are /// skipped; id clashes get a fresh id. Returns the ids added. pub fn add_presets(&mut self, presets: Vec) -> Vec { + use std::collections::{HashMap, HashSet}; + // indexed: a collection of 10 000+ presets was quadratic (minutes) to import + let mut ids: HashSet = self.presets.iter().map(|q| q.id.clone()).collect(); + let mut by_name: HashMap<(String, String), Vec> = HashMap::new(); + for (i, q) in self.presets.iter().enumerate() { + by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i); + } let mut added = Vec::new(); for mut p in presets { - if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) { + let key = (p.name.clone(), p.group.clone()); + let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings))); + if same { continue; } - if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) { + if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) { let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name))); let mut id = base.clone(); let mut n = 2; - while self.presets.iter().any(|q| q.id == id) { + while ids.contains(&id) { id = format!("{base}-{n}"); n += 1; } p.id = id; } p.builtin = false; + ids.insert(p.id.clone()); + by_name.entry(key).or_default().push(self.presets.len()); added.push(p.id.clone()); self.presets.push(p); } diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index c5ff9a0c4..e49355c08 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -319,6 +319,17 @@ pub(crate) trait MoveFs { /// Another photo file beside the stem-named sidecar `sidecar` that shares its stem (e.g. /// `IMG_1.JPG` for `IMG_1.xmp`), which still uses that sidecar. fn stem_sibling(&self, sidecar: &Path) -> Option; + /// The folder lists an entry spelled exactly like `p` (letter case included). On a + /// case-insensitive volume `a.XMP` "exists" when only `a.xmp` is there; this tells the two + /// apart. When the folder can't be listed: `true`, so nothing is skipped. + fn listed(&self, p: &Path) -> bool { + let (Some(dir), Some(name)) = (p.parent(), p.file_name()) else { return true }; + let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir }; + match std::fs::read_dir(dir) { + Ok(entries) => entries.flatten().any(|e| e.file_name() == name), + Err(_) => true, + } + } /// Content observed in a packet, independent of its pathname or inode. Model volumes /// override this; disk implementations stream a bounded buffer without retaining the XML. fn packet_version(&self, path: &Path) -> std::io::Result { @@ -366,15 +377,23 @@ impl MoveFs for RealFs { } #[cfg(not(unix))] { - // no stable file id in std here: `b` is `a` under another spelling when they look - // alike and the folder has no entry spelled exactly like `b` (on a case-sensitive - // folder holding both `IMG.JPG` and `img.jpg`, both entries are listed) - let (Some(dir), Some(name)) = (b.parent(), b.file_name()) else { return false }; - if a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { + // no stable file id in std here: `a` and `b` are one file under two spellings when + // they look alike and the folder doesn't list both (on a case-sensitive folder + // holding both `IMG.JPG` and `img.jpg`, both entries are listed). Either may be the + // listed one: the answer must not depend on the argument order. + let (Some(dir), Some(name_a), Some(name_b)) = (b.parent(), a.file_name(), b.file_name()) else { return false }; + let respelled = name_a.to_string_lossy().to_lowercase() == name_b.to_string_lossy().to_lowercase(); + if !respelled || a.parent() != Some(dir) || ma.len() != mb.len() || ma.modified().ok() != mb.modified().ok() { return false; } let Ok(entries) = std::fs::read_dir(dir) else { return false }; - !entries.flatten().any(|e| e.file_name() == name) + let (mut listed_a, mut listed_b) = (false, false); + for e in entries.flatten() { + let name = e.file_name(); + listed_a |= name == name_a; + listed_b |= name == name_b; + } + !(listed_a && listed_b) } } @@ -699,15 +718,14 @@ pub(crate) fn move_file_with(fs: &dyn MoveFs, from: &str, to: &str) -> std::resu if fs.exists(t) && !is_respelling(fs, f, t) { return Err(fail(format!("{to} already exists"))); } - let mut packets: Vec = sidecars(from) - .into_iter() - .map(|(p, _, _)| p) - .filter(|p| fs.is_file(p)) - .map(|path| RetainedPacket { version: fs.packet_version(&path).ok(), path }) - .collect(); + // An upper-case `.XMP` spelling that only answers through a case-insensitive volume is the + // lower-case sidecar: track and move it under its own name, so `a.xmp` doesn't become `b.XMP`. + let present = |(sc, _, upper): &(PathBuf, SidecarNaming, bool)| fs.is_file(sc) && (!*upper || fs.listed(sc)); + let mut packets: Vec = + sidecars(from).into_iter().filter(present).map(|(path, _, _)| RetainedPacket { version: fs.packet_version(&path).ok(), path }).collect(); move_one(fs, f, t).map_err(|e| fail(format!("rename {from}: {e}")))?; let mut done: Vec = Vec::new(); - let mut remaining = sidecars(from).into_iter().filter(|(sc, _, _)| fs.is_file(sc)).map(|(sc, naming, upper)| { + let mut remaining = sidecars(from).into_iter().filter(present).map(|(sc, naming, upper)| { let mut dst = crate::sidecar::sidecar_path(to, naming); if upper { dst = dst.with_extension("XMP"); @@ -1124,6 +1142,24 @@ pub(crate) mod tests { use super::*; use std::cell::RefCell; + /// Windows has no file id in std: two spellings of one file are the same file whichever is + /// asked about first (a save after a partial rename saw `Trip.XMP` as a collision with the + /// known `Trip.xmp`), and only the spelling on disk is listed. + #[cfg(windows)] + #[test] + fn same_file_spellings_are_symmetric_on_windows() { + let dir = std::env::temp_dir().join(format!("lc-same-file-spelling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (real, alias, other) = (dir.join("Trip.xmp"), dir.join("Trip.XMP"), dir.join("other.xmp")); + std::fs::write(&real, b"packet").unwrap(); + std::fs::write(&other, b"packet").unwrap(); + assert!(RealFs.same_file(&real, &alias) && RealFs.same_file(&alias, &real)); + assert!(!RealFs.same_file(&real, &other) && !RealFs.same_file(&other, &alias)); + assert!(RealFs.listed(&real) && !RealFs.listed(&alias)); + std::fs::remove_dir_all(&dir).unwrap(); + } + /// The cross-volume move's check compares files chunk by chunk: equal files across several /// chunks, a difference in the last chunk, different lengths, a missing file. #[test] @@ -1171,7 +1207,10 @@ pub(crate) mod tests { } fn idx(&self, p: &Path) -> Option { let p = Self::norm(p); - self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p }) + self.files.borrow().iter().position(|(f, _)| { + let f = f.replace('\\', "/"); + if self.ci { folded_path(&f) == folded_path(&p) } else { f == p } + }) } /// The listing: (exact path, contents), sorted. pub fn listing(&self) -> Vec<(String, String)> { @@ -1196,7 +1235,7 @@ pub(crate) mod tests { self.idx(a).is_some() && self.idx(a) == self.idx(b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { return Err(std::io::Error::other("injected failure")); } if self.exists(b) { @@ -1207,7 +1246,7 @@ pub(crate) mod tests { Ok(()) } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) { + if self.fail.borrow().iter().any(|f| Self::norm(a).contains(&f.replace('\\', "/"))) { return Err(std::io::Error::other("injected failure")); } if self.exists(b) { @@ -1223,6 +1262,10 @@ pub(crate) mod tests { self.files.borrow_mut().remove(i); Ok(()) } + fn listed(&self, p: &Path) -> bool { + let p = Self::norm(p); + self.files.borrow().iter().any(|(f, _)| f.replace('\\', "/") == p) + } fn stem_sibling(&self, sidecar: &Path) -> Option { let stem = Self::norm(&sidecar.with_extension("")); self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| { @@ -1293,7 +1336,7 @@ pub(crate) mod tests { } else { serde_json::json!({"path": from, "into": root.join("Archive")}) }; - let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive/Trip") }; + let to = if command == "folder.rename" { root.join("Holiday") } else { root.join("Archive").join("Trip") }; let before = s.catalog.to_snapshot(); let history = (s.undo.len(), s.redo.len()); assert!(s.execute(command, ¶ms).is_err()); @@ -1421,6 +1464,34 @@ pub(crate) mod tests { assert!(move_file_with(&fs, "/p/A.JPG", "/p/B.JPG").is_err()); } + /// On a case-insensitive volume (Windows, macOS) `a.XMP` answers for `a.xmp`: a renamed + /// photo's sidecars keep the spelling they have on disk, and a failure on the real spelling + /// is the one reported and queued. + #[test] + fn case_insensitive_renames_keep_each_sidecars_spelling() { + let fs = FakeFs::new(true, &[("/p/a.jpg", "A"), ("/p/a.xmp", "stem"), ("/p/a.jpg.XMP", "full"), ("/p/b.jpg", "B"), ("/p/b.Xmp", "mixed")]); + move_file_with(&fs, "/p/a.jpg", "/p/c.jpg").unwrap(); + move_file_with(&fs, "/p/b.jpg", "/p/d.jpg").unwrap(); + assert_eq!( + fs.listing(), + vec![ + ("/p/c.jpg".into(), "A".into()), + ("/p/c.jpg.XMP".into(), "full".into()), + ("/p/c.xmp".into(), "stem".into()), + ("/p/d.jpg".into(), "B".into()), + ("/p/d.xmp".into(), "mixed".into()) + ] + ); + // the move of the sidecar's real spelling is the one attempted (and here fails): the photo + // goes back and nothing changed + let before = fs.listing(); + *fs.fail.borrow_mut() = vec!["/p/c.xmp".into()]; + let e = move_file_with(&fs, "/p/c.jpg", "/p/a.jpg").unwrap_err(); + assert!(e.message.replace('\\', "/").contains("rename /p/c.xmp: injected failure"), "{e}"); + assert!(!e.moved && e.pending_sidecars.is_empty()); + assert_eq!(fs.listing(), before); + } + /// Issue #105: a rollback that fails is reported, and the library follows the files that kept /// their new names (an undoable partial rename), so nothing goes missing. #[test] diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 4f44780ec..9aaf7e937 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -566,7 +566,12 @@ impl Session { .map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?; // Keep an existing packet's spelling/style so foreign properties survive this save and // subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above. - let path = candidates.into_iter().find(|path| path.is_file()).unwrap_or(preferred); + // On a case-insensitive volume `a.xmp` also answers for `a.XMP`: the spelling the folder + // lists comes first, so the atomic write doesn't rename the packet. + use crate::rename::MoveFs; + let existing: Vec = candidates.into_iter().filter(|path| path.is_file()).collect(); + let listed = existing.iter().find(|path| crate::rename::RealFs.listed(path)).cloned(); + let path = listed.or_else(|| existing.into_iter().next()).unwrap_or(preferred); self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?; let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display())); diff --git a/crates/engine/src/sidecar/tests_repair_companions.rs b/crates/engine/src/sidecar/tests_repair_companions.rs index cefec46e8..94c7996a7 100644 --- a/crates/engine/src/sidecar/tests_repair_companions.rs +++ b/crates/engine/src/sidecar/tests_repair_companions.rs @@ -1,6 +1,7 @@ //! Real Session partial moves, sidecar serialization/merging, AutoWrite and history. use super::*; use crate::rename::{MoveFs, RealFs}; +use crate::tests_xmp::{same_path_for_volume, same_path_or_file}; use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -16,7 +17,7 @@ impl MoveFs for Faults { RealFs.same_file(a, b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.0.iter().any(|p| p == a) { + if self.0.iter().any(|p| same_path_or_file(p, a)) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected partial move")) } else { RealFs.rename_no_replace(a, b) @@ -261,8 +262,8 @@ fn queued_stem_retry_preserves_shared_packet_after_batch_rollback() { assert!(!second_packet.exists()); let pending = queues(&s); assert_eq!(pending.len(), 1); - assert_eq!(pending[0].from, shared); - assert_eq!(pending[0].to, second_packet); + assert!(same_path_for_volume(&pending[0].from, &shared), "{:?}", pending[0].from); + assert!(same_path_for_volume(&pending[0].to, &second_packet), "{:?}", pending[0].to); let catalog_before = s.catalog.to_snapshot(); let history_before = (s.undo.len(), s.redo.len(), s.commits()); diff --git a/crates/engine/src/sidecar/tests_repair_provenance.rs b/crates/engine/src/sidecar/tests_repair_provenance.rs index 63ff42002..86f7d1dd6 100644 --- a/crates/engine/src/sidecar/tests_repair_provenance.rs +++ b/crates/engine/src/sidecar/tests_repair_provenance.rs @@ -1,6 +1,7 @@ //! Native files and genuine Session entry points: packet ownership survives partial renames. use super::*; use crate::rename::{MoveFs, RealFs}; +use crate::tests_xmp::same_path_or_file; use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -16,7 +17,7 @@ impl MoveFs for Faults { RealFs.same_file(a, b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if self.0.iter().any(|p| p == a) { + if self.0.iter().any(|p| same_path_or_file(p, a)) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "injected provenance partial move")) } else { RealFs.rename_no_replace(a, b) diff --git a/crates/engine/src/sidecar/tests_repair_saves.rs b/crates/engine/src/sidecar/tests_repair_saves.rs index 6f9fa4aa4..4b622e657 100644 --- a/crates/engine/src/sidecar/tests_repair_saves.rs +++ b/crates/engine/src/sidecar/tests_repair_saves.rs @@ -1,6 +1,7 @@ //! Real Session/serializer regressions for saves that overlap retained physical repairs. use super::*; use crate::rename::{MoveFs, RealFs}; +use crate::tests_xmp::same_path_or_file; use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -19,14 +20,14 @@ impl MoveFs for PartialRename { RealFs.same_file(a, b) } fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if a == self.sidecar || a == self.photo { + if same_path_or_file(a, &self.sidecar) || same_path_or_file(a, &self.photo) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient rename fault")) } else { RealFs.rename_no_replace(a, b) } } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - if a == self.sidecar { + if same_path_or_file(a, &self.sidecar) { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "transient copy fault")) } else { RealFs.copy_no_replace(a, b) @@ -151,7 +152,9 @@ fn autowrite_repairs_partial_rename_before_merging_foreign_xmp_across_library_sw assert_eq!(parsed.title.as_deref(), Some("Final title")); assert!(parsed.keywords.as_ref().unwrap().contains(&"café".to_string())); if upper { - assert!(!preferred.exists(), "merge uses the repaired uppercase packet"); + // by listing: on a case-insensitive volume `preferred` answers for the upper-case packet + let listed = std::fs::read_dir(&root).unwrap().any(|e| Some(e.unwrap().file_name().as_os_str()) == preferred.file_name()); + assert!(!listed, "merge uses the repaired uppercase packet"); } for (step, title) in [("edit.undo", "Fresh title"), ("edit.redo", "Final title")] { s.execute(step, &json!({})).unwrap(); diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 3628f188c..46b18e0c9 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -55,6 +55,28 @@ const HEADER_MAX: u64 = 4096; /// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve. const LOOK_FIELD: &str = "look_version"; +/// The JPEG quality a smart preview's pixels are stored at. The original 92 left the 8×8 DCT's +/// quantised DC — each block's own mean, luma and chroma — visible as block steps across the frame +/// and as a small constant colour shift; that is the largest part of what a proxy gets wrong +/// against the original, and the only part a parameter can reach. 97 shrinks those quantisers to +/// roughly 1/2.6 of their steps, which is what removes the steps, at about twice the file size +/// (~1 MB per preview at 2560 px). What is left is the 8-bit sRGB quantisation itself, which only +/// a lossless proxy format can close. (ICED: the measured change is printed by the fidelity test +/// below; drop this to 95 for ~1.4× the size and roughly half the gain.) +const JPEG_QUALITY: u8 = 97; + +/// The header field that records which version of this module's own encoding wrote a proxy's pixels +/// (the source mapping above and [`JPEG_QUALITY`]). Not to be confused with [`LOOK_FIELD`], which +/// tracks the camera-look fit that is baked into those pixels. +const ENC_FIELD: &str = "enc"; + +/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG +/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which +/// makes every proxy from before this version stale. Version 2 is the first to record the field at +/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]). +/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal. +pub(crate) const ENCODE_VERSION: u64 = 2; + /// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview. pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String> { // scale so all but the brightest 0.05 % fit into 0..1 @@ -78,13 +100,19 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String let rgba = Rgba8 { width: img.width, height: img.height, data }; let jpg = lightcraft_codecs::encode_jpeg( &lightcraft_codecs::EncodeImage::rgba8(&rgba), - 92, + JPEG_QUALITY, lightcraft_codecs::ChromaSubsampling::S444, &Default::default(), ) .map_err(|e| e.to_string())?; let mut out = MAGIC.to_vec(); - let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + let mut head = serde_json::json!({ + "w": img.width, + "h": img.height, + "scale": scale, + LOOK_FIELD: crate::camera_preview::LOOK_VERSION, + ENC_FIELD: ENCODE_VERSION, + }); if let Some(t) = tone { head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; } @@ -265,10 +293,17 @@ fn header_look_version(head: &serde_json::Value) -> u64 { head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) } -/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's -/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a -/// newer build is left alone (rebuilding it here would only undo that build's work), as is one -/// that can't be read: those are [`is_valid`]'s business. +/// The encode version a header records: a missing, negative, fractional or otherwise malformed +/// value counts as 0 (older than any real version), like [`header_look_version`]. +fn header_encode_version(head: &serde_json::Value) -> u64 { + head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) +} + +/// Whether the complete proxy at `path` is out of date for this build: written by an older +/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are +/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG +/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that +/// build's work), as is one that can't be read: those are [`is_valid`]'s business. pub fn is_stale(path: &Path) -> bool { use std::io::Read; if !is_valid(path) { @@ -283,15 +318,18 @@ pub fn is_stale(path: &Path) -> bool { let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; rest.get(..nl) .and_then(|h| serde_json::from_slice::(h).ok()) - .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION)) + .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION) } -/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked -/// at ([`look_checked`]). +/// The marker file in a smart previews folder: the camera-look fit version and encode version its +/// proxies were last checked at ([`look_checked`]). const LOOK_MARKER: &str = "look-version"; -/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's -/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not. +/// Whether the smart previews folder `dir` has been checked for out-of-date proxies at this build's +/// camera-look fit version and encode version (or newer ones). A missing or unreadable marker counts +/// as not, and so does one written before the encode version was recorded — it only says the look +/// was checked, so this build scans the folder once more and rebuilds the proxies [`is_stale`] +/// finds. pub fn look_checked(dir: &Path) -> bool { use std::io::Read; let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false }; @@ -299,14 +337,20 @@ pub fn look_checked(dir: &Path) -> bool { if f.take(64).read_to_string(&mut text).is_err() { return false; } - text.trim().parse::().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION)) + let mut versions = text.split_whitespace(); + let (Some(look), Some(enc)) = (versions.next(), versions.next()) else { return false }; + match (look.parse::(), enc.parse::()) { + (Ok(look), Ok(enc)) => look >= u64::from(crate::camera_preview::LOOK_VERSION) && enc >= ENCODE_VERSION, + _ => false, + } } -/// Record that the proxies in `dir` were checked at this build's camera-look fit version. +/// Record that the proxies in `dir` were checked at this build's camera-look fit version and encode +/// version. pub fn mark_look_checked(dir: &Path) -> Result<(), String> { let path = dir.join(LOOK_MARKER); - lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes()) - .map_err(|e| format!("{}: {e}", path.display())) + let text = format!("{} {}\n", crate::camera_preview::LOOK_VERSION, ENCODE_VERSION); + lightcraft_catalog::safe_file::write_atomic(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display())) } /// Load the proxy at `path`. @@ -451,4 +495,186 @@ mod tests { assert!(mean < 0.02, "mean encoded error {mean}"); assert!(decode(b"nope").is_err()); } + + /// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into + /// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never + /// enters what is measured. Built from a rendered demo scene so the dimensions and the flat + /// row-major layout are the real ones. + fn smooth_scene(w: usize, h: usize) -> Rgb32f { + let mut img = lightcraft_scenes::demo_library()[0].render(w, h); + let (w, h) = (img.width.max(1), img.height.max(1)); + for (i, px) in img.data.iter_mut().enumerate() { + let u = (i % w) as f32 / (w - 1).max(1) as f32; + let v = (i / w) as f32 / (h - 1).max(1) as f32; + let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step + let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v]; + let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)]; + *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0)); + } + img + } + + /// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The + /// input is clamped into 0..1, the only range a proxy ever stores. + fn lab(c: [f32; 3]) -> [f64; 3] { + let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64); + let (x, y, z) = ( + (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047, + 0.2126 * r + 0.7152 * g + 0.0722 * b, + (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883, + ); + let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] + } + + /// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a + /// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders. + fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let n = a.data.len().min(b.data.len()); + if n == 0 { + return 0.0; + } + (0..n) + .map(|i| { + let (p, q) = (lab(a.data[i]), lab(b.data[i])); + ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt() + }) + .sum::() + / n as f64 + } + + /// A deliberately frozen copy of the pixel mapping and container this module used before the + /// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the + /// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on + /// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the + /// quality raise bought instead of trusting a written-down number. + fn encode_at_q92(img: &Rgb32f) -> Vec { + let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); + let scale = if lum.is_empty() { + 1.0 + } else { + let k = ((lum.len() as f64) * 0.9995) as usize; + let k = k.min(lum.len() - 1); + let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp); + v.max(1.0) + }; + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let rgba = Rgba8 { width: img.width, height: img.height, data }; + let jpg = lightcraft_codecs::encode_jpeg( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + 92, + lightcraft_codecs::ChromaSubsampling::S444, + &Default::default(), + ) + .unwrap(); + // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field + let mut out = MAGIC.to_vec(); + let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION}); + out.extend_from_slice(head.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(&jpg); + out + } + + /// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one + /// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its + /// mean, and rebuilds the whole block from it, so a proxy differs from the original by a + /// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels + /// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share + /// eightfold, which is what a per-pixel ΔE drowns in. + fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 { + let w = a.width.min(b.width).max(1); + let h = a.height.min(b.height).max(1); + let (mut sum, mut n) = (0.0f64, 0usize); + for by in (0..h).step_by(8) { + for bx in (0..w).step_by(8) { + let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32); + for y in by..(by + 8).min(h) { + for x in bx..(bx + 8).min(w) { + if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) { + for k in 0..3 { + sa[k] += f64::from(ca[k]); + sb[k] += f64::from(cb[k]); + } + count += 1; + } + } + } + let d = f64::from(count.max(1)); + let (pa, pb) = ( + lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]), + lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]), + ); + sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt(); + n += 1; + } + } + sum / n.max(1) as f64 + } + + /// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit + /// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene, + /// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the + /// quality raise is reverted and never trips on the honest result (measured 0.683). + const PROXY_BLOCK_GAIN: f64 = 0.8; + + #[test] + fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() { + let img = smooth_scene(256, 160); + let ours = decode(&encode(&img, None).unwrap()).unwrap().0; + let before = decode(&encode_at_q92(&img)).unwrap().0; + let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before)); + let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before)); + println!( + "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \ + (per-pixel {:.3} vs {:.3})", + block.0, block.1, pixel.0, pixel.1 + ); + assert!(block.1 > 0.0, "the old encoder must measure something to compare against"); + assert!( + block.0 <= PROXY_BLOCK_GAIN * block.1, + "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \ + (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)", + block.0, + PROXY_BLOCK_GAIN * block.1, + block.1 + ); + } + + #[test] + fn a_proxy_from_an_older_encode_is_stale() { + let dir = temp("encode-version"); + let img = lightcraft_scenes::demo_library()[0].render(64, 40); + let bytes = encode(&img, None).unwrap(); + let path = dir.join("p.lcsp"); + std::fs::write(&path, &bytes).unwrap(); + assert!(!is_stale(&path), "this build's own proxy is up to date"); + // one written before the encode version was recorded is not + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION)); + let mut older = head.as_object().cloned().unwrap_or_default(); + older.remove(ENC_FIELD); + let mut old = MAGIC.to_vec(); + old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes()); + old.extend_from_slice(&bytes[nl..]); + std::fs::write(&path, &old).unwrap(); + assert!(is_valid(&path), "losing the field keeps the file readable"); + assert!(is_stale(&path), "an older encoding is rebuilt"); + // the marker records both versions, and one from before the encode version forces a scan + assert!(!look_checked(&dir)); + mark_look_checked(&dir).unwrap(); + assert!(look_checked(&dir)); + std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap(); + assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests_crop_angle.rs index 47759d60c..90a12b2d0 100644 --- a/crates/engine/src/tests_crop_angle.rs +++ b/crates/engine/src/tests_crop_angle.rs @@ -81,11 +81,11 @@ fn quick_develop_reset_and_batch_paths_fit_too() { let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]})); assert!(close(got, want), "develop.set ids {got:?} vs {want:?}"); - // back to 0°: the crop that fitted at 12° fits at 0° too and is kept + // back to 0°: the crop shrunk to fit at 12° grows back to the full photo (issue #1388) s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap(); s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap(); let g = geometry(&s); - assert_eq!((g.rect, g.angle), (want.rect, 0.0)); + assert_eq!((g.rect, g.angle), (Rect::UNIT, 0.0)); assert!(g.is_within_image(w, h)); } @@ -106,3 +106,21 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert!(close(geometry(&s), start)); } + +#[test] +fn a_crop_shrunk_by_straightening_grows_back_towards_0() { + // issue #1388: slide the Straighten slider out and back, in one drag or in several + let mut s = Session::with_demo(); + let (w, h) = dims(&s); + s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap(); + for angle in [5.0, 12.0, 20.0, 8.0, -20.0, -3.0, 0.0] { + s.execute("crop.straighten", &json!({"angle": angle})).unwrap(); + let g = geometry(&s); + assert!(close(g, lightcraft_geom::crop_fit_angle(w, h, angle, None)), "the largest crop at {angle}°, got {g:?}"); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + s.execute("crop.straighten", &json!({"angle": 20.0})).unwrap(); + s.execute("crop.straighten", &json!({"angle": 0.0})).unwrap(); + let g = geometry(&s); + assert!(close(g, CropGeometry::default()), "two separate drags: back to the full photo at 0°, got {g:?}"); +} diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index ae202f192..1cba9f648 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -277,6 +277,29 @@ fn a_manifest_that_does_not_validate_or_name_the_file_is_refused() { let _ = std::fs::remove_dir_all(&x.dir); } +/// Another app's model folder (#1381): an `.onnx` with no manifest, then with that app's `model.json` beside it, then +/// with the `model.json` renamed. Each refusal says what is wrong and where LightCraft's manifest is described. +#[test] +fn inspecting_another_apps_model_folder_says_what_is_missing() { + let dir = temp_dir("denoise-foreign"); + let mut s = Session::new().with_fs(); + s.set_denoise_models_dir(Some(dir.join("denoise-models"))); + let src = dir.join("rawdenoise"); + std::fs::create_dir_all(&src).unwrap(); + let onnx = src.join("rawdenoise.onnx"); + std::fs::write(&onnx, b"not an onnx file").unwrap(); + let mut inspect = || s.execute("denoise.models.inspect", &json!({"path": onnx.to_string_lossy()})).unwrap_err().to_string(); + let e = inspect(); + assert!(e.contains("No denoise-model.json beside") && e.contains("docs/denoise.md") && !e.contains("model.json there"), "{e}"); + std::fs::write(src.join("model.json"), json!({"name": "rawdenoise", "input": {"channels": 4}, "scale": 2}).to_string()).unwrap(); + let e = inspect(); + assert!(e.contains("No denoise-model.json beside") && e.contains("The model.json there") && e.contains("docs/denoise.md"), "{e}"); + std::fs::rename(src.join("model.json"), src.join("denoise-model.json")).unwrap(); + let e = inspect(); + assert!(e.contains("not a denoise-model manifest") && e.contains("docs/denoise.md"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_photo_is_denoised_in_the_background_and_the_amount_mixes_it_in() { let mut x = setup("background", true); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index b57bd471b..592f7349d 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -455,6 +455,21 @@ fn hdr_avif_export_is_pq_rec2020() { assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16)); } +#[test] +fn hdr_avif_export_hlg() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "hdrTransfer": "hlg", "quality": 70})); + assert_eq!(o.hdr_transfer, crate::export::HdrCurve::Hlg); + assert_eq!(o.to_json()["hdrTransfer"], "hlg"); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let hdr = export_photo(&mut s, id, &o, 1).unwrap(); + // nclx: BT.2020 primaries (9), HLG (18), BT.2020 NCL (9), full range; no content light level box + let i = hdr.bytes.windows(4).position(|w| w == b"nclx").expect("colr nclx box"); + assert_eq!(hdr.bytes[i + 4..i + 11].to_vec(), vec![0, 9, 0, 18, 0, 9, 0x80]); + assert!(!hdr.bytes.windows(4).any(|w| w == b"clli")); +} + /// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's /// packet carry no location, and an original that embeds GPS is refused rather than copied. #[test] diff --git a/crates/engine/src/tests_filesystem_history.rs b/crates/engine/src/tests_filesystem_history.rs index c071d995b..e4e150c25 100644 --- a/crates/engine/src/tests_filesystem_history.rs +++ b/crates/engine/src/tests_filesystem_history.rs @@ -31,11 +31,21 @@ fn file_history_step(s: &mut Session, fs: &dyn crate::rename::MoveFs, redo: bool }) } +/// The model volume spells paths with `/`; the code under test joins them with the host's separator. +fn slash(path: &str) -> String { + path.replace('\\', "/") +} + +fn file_source(s: &Session, id: u64) -> String { + let Source::File { path } = &s.catalog.photo(PhotoId(id)).unwrap().source else { panic!("synthetic file fixture") }; + slash(path) +} + fn assert_file_catalog_and_journal(s: &Session, fs: &crate::rename::tests::FakeFs, store: &lightcraft_catalog::MemStore) { let files = fs.listing(); for photo in s.catalog.photos() { let Source::File { path } = &photo.source else { panic!("synthetic file fixture") }; - assert!(files.iter().any(|(file, _)| file == path), "photo {} points to missing {path}: {files:?}", photo.id.0); + assert!(files.iter().any(|(file, _)| *file == slash(path)), "photo {} points to missing {path}: {files:?}", photo.id.0); assert_eq!(std::path::Path::new(path).file_name().unwrap().to_string_lossy(), photo.file_name.as_str()); } let replayed = memory_library(store); @@ -102,7 +112,8 @@ fn merged_renames_coalesce_physical_moves_and_keep_retry_inverses_and_journal() let renamed = fs.listing(); s.merge_undo(2, "Rename Twice"); assert_eq!(s.undo.len(), 1); - assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/final.jpg".into(), "/p/a.jpg".into())]); + let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect(); + assert_eq!(moves, vec![("/p/final.jpg".to_string(), "/p/a.jpg".to_string())]); // First exercise the ordinary merged step, before any corrective paths exist. for redo in [false, true] { file_history_step(&mut s, &fs, redo).unwrap(); @@ -274,7 +285,7 @@ fn history_retries_both_sidecar_styles_after_following_a_photo_with_failed_rollb file_history_step(&mut s, &fs, redo).unwrap(); let mut after = before .iter() - .map(|(path, bytes)| (path.replace(from.trim_end_matches(".jpg"), to.trim_end_matches(".jpg")), bytes.clone())) + .map(|(path, bytes)| (path.replace(slash(&from).trim_end_matches(".jpg"), slash(&to).trim_end_matches(".jpg")), bytes.clone())) .collect::>(); after.sort(); assert_eq!(fs.listing(), after); @@ -343,7 +354,7 @@ fn initial_partial_rename_keeps_sidecar_repairs_for_noop_retry_and_history() { let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "Trip", 1); *fs.fail.borrow_mut() = vec!["/p/a.xmp".into(), "/p/Trip.jpg".into()]; assert!(s.execute_fn("photo.rename", |s| Ok(json!({"renamed": s.apply_rename_with(&fs, &plans)?}))).is_err()); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/Trip.jpg".into() }); + assert_eq!(file_source(&s, 1), "/p/Trip.jpg"); assert_eq!(s.undo.len(), 1); assert_eq!(s.undo.last().unwrap().retry_sidecars.len(), 2); assert_file_catalog_and_journal(&s, &fs, &store); @@ -513,10 +524,11 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() { ); s.merge_undo(3, "Rename Three Times"); assert_eq!((s.undo.len(), s.redo.len()), (1, 0)); - assert_eq!(s.file_moves(&s.undo.last().unwrap().op), vec![("/p/d.jpg".into(), "/p/b.jpg".into()), ("/p/c.jpg".into(), "/p/a.jpg".into())]); + let moves: Vec<_> = s.file_moves(&s.undo.last().unwrap().op).iter().map(|(from, to)| (slash(from), slash(to))).collect(); + assert_eq!(moves, vec![("/p/d.jpg".to_string(), "/p/b.jpg".to_string()), ("/p/c.jpg".to_string(), "/p/a.jpg".to_string())]); // The second photo move sticks; the preceding d -> b move must roll back to d. *fs.fail.borrow_mut() = vec!["/p/c.xmp".into(), "/p/a.jpg".into()]; - let error = file_history_step(&mut s, &fs, false).unwrap_err().to_string(); + let error = slash(&file_history_step(&mut s, &fs, false).unwrap_err().to_string()); assert!(error.contains("rename /p/c.xmp: injected failure"), "{error}"); assert!(error.contains("/p/c.jpg could not be moved back"), "{error}"); let files = fs.listing(); @@ -529,9 +541,8 @@ fn merged_rename_failure_keeps_rolled_back_photo_identity() { ] ); assert_eq!((s.undo.len(), s.redo.len()), (1, 0)); - assert_eq!(s.catalog.photo(PhotoId(1)).unwrap().source, Source::File { path: "/p/a.jpg".into() }); - let photo2 = s.catalog.photo(PhotoId(2)).unwrap(); - assert_eq!(photo2.source, Source::File { path: "/p/d.jpg".into() }, "photo 2 rolled back physically and must still identify d.jpg"); - let Source::File { path } = &photo2.source else { panic!("file fixture") }; - assert_eq!(files.iter().find(|(file, _)| file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2")); + assert_eq!(file_source(&s, 1), "/p/a.jpg"); + let path = file_source(&s, 2); + assert_eq!(path, "/p/d.jpg", "photo 2 rolled back physically and must still identify d.jpg"); + assert_eq!(files.iter().find(|(file, _)| *file == path).map(|(_, bytes)| bytes.as_str()), Some("Photo 2")); } diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index b55e88c8a..697c98087 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -40,7 +40,8 @@ impl Scratch { Scratch(dir) } fn path(&self, rel: &str) -> String { - self.0.join(rel).to_string_lossy().to_string() + // the host's separator throughout, as the code under test spells joined paths + self.0.join(rel).components().collect::().to_string_lossy().to_string() } } diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs new file mode 100644 index 000000000..55c265403 --- /dev/null +++ b/crates/engine/src/tests_history_cursor.rs @@ -0,0 +1,100 @@ +//! Feature: the History panel has a cursor. Going to a step loads its settings without adding a +//! step; later steps stay listed until a new edit drops them; undo/redo stay coherent. + +use serde_json::json; + +use crate::Session; + +fn exposure(s: &Session) -> f64 { + s.develop_of(s.active().unwrap()).unwrap().light.exposure +} + +fn list(s: &mut Session) -> (usize, Option) { + let h = s.execute("history.list", &json!({})).unwrap(); + (h["history"].as_array().unwrap().len(), h["cursor"].as_u64().map(|c| c as usize)) +} + +/// A demo session with three exposure edits (1, 2, 3) as its latest History steps. +fn three_edits() -> (Session, usize) { + let mut s = Session::with_demo(); + for v in [1.0, 2.0, 3.0] { + s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap(); + } + let (n, cursor) = list(&mut s); + assert_eq!(cursor, Some(n - 1), "the cursor starts at the latest step"); + (s, n) +} + +#[test] +fn going_back_adds_no_step_and_forward_returns() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + assert_eq!(exposure(&s), 1.0); + assert_eq!(list(&mut s), (n, Some(n - 3)), "no new step; later steps kept"); + s.execute("history.next", &json!({})).unwrap(); + assert_eq!(exposure(&s), 2.0); + s.execute("history.restore", &json!({"index": n - 1})).unwrap(); + assert_eq!(exposure(&s), 3.0); + assert_eq!(list(&mut s), (n, Some(n - 1))); + assert!(s.execute("history.next", &json!({})).is_err(), "nothing after the latest step"); + s.execute("history.previous", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (2.0, (n, Some(n - 2)))); + assert!(s.execute("history.restore", &json!({"index": n})).is_err()); + assert!(s.execute("history.restore", &json!({"index": -1})).is_err()); +} + +#[test] +fn a_new_edit_behind_the_end_drops_the_later_steps() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap(); + assert_eq!(list(&mut s), (n - 1, Some(n - 2)), "steps 2 and 3 dropped, the new one appended"); + assert_eq!(exposure(&s), -1.0); + // undo brings the dropped steps and the cursor back + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (-1.0, (n - 1, Some(n - 2)))); +} + +#[test] +fn undo_and_redo_move_the_cursor_with_the_settings() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (3.0, (n, Some(n - 1))), "undoing a step move"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); +} + +#[test] +fn clearing_history_resets_the_cursor() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 2})).unwrap(); + s.execute("history.clear", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (2.0, (1, Some(0)))); + assert_eq!(s.catalog.photo(s.active().unwrap()).unwrap().history_cursor, None); +} + +#[test] +fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() { + let mut s = Session::with_demo(); + let snap = s.catalog.to_snapshot(); + let _ = s.drain_log(); + for v in [1.0, 2.0] { + s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap(); + } + let (n, _) = list(&mut s); + s.execute("history.restore", &json!({"index": n - 2})).unwrap(); + let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect(); + let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap(); + c.replay(&log).unwrap(); + assert_eq!(c.to_snapshot(), s.catalog.to_snapshot()); + let id = s.active().unwrap(); + assert_eq!(c.photo(id).unwrap().history_cursor, Some(n - 2)); + // a photo written before the cursor existed has no `history_cursor` key: at the latest step + let mut v = serde_json::to_value(c.photo(id).unwrap()).unwrap(); + v.as_object_mut().unwrap().remove("history_cursor"); + let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap(); + assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1))); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 185993494..ed4cc9162 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -21,6 +21,9 @@ fn write_png(path: &Path, seed: u8) { let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); + // undated files take the file's date: the tests' clock time (2026-01-14T05:58:48) + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); + std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); } fn ids(v: &Value, key: &str) -> usize { @@ -309,6 +312,27 @@ fn recently_added_covers_recent_imports_newest_first() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn recently_added_keeps_an_import_batch_in_file_order() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut s = Session::new(); + // one import batch: one `imported` string, ids running opposite to the names + let mut by_name = Vec::new(); + for (n, name) in ["IMG_0001.png", "IMG_0002.png", "IMG_0003.png", "IMG_0004.png"].iter().enumerate() { + let id = PhotoId(5000 - n as u64); + let p = Photo::new(id, Source::File { path: name.to_string() }, name, "PNG", 16, 12, "2026-09-30T09:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + by_name.push(name.to_string()); + } + s.clock = Box::new(|| "2026-10-01T09:00:00".to_string()); + for ascending in [true, false] { + s.execute("library.sort", &serde_json::json!({"key": "captureDate", "ascending": ascending})).unwrap(); + s.execute("library.source", &serde_json::json!({"kind": "recentlyAdded"})).unwrap(); + let names: Vec = s.visible_cloned().iter().map(|id| s.catalog.photo(*id).unwrap().file_name.clone()).collect(); + assert_eq!(names, by_name, "ascending: {ascending}"); + } +} + /// Copy imports: a destination folder, flat / by-month folders, renamed copies (numbered in /// import order), and a metadata preset on every photo. #[test] @@ -560,6 +584,14 @@ fn cube_luts_become_profiles() { assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); s.execute("profile.favorite", &json!({"id": id})).unwrap(); assert!(s.profile_favorites.contains(&id)); + // `profiles.list` lists it next to the built-in profiles (#618) + let listed = |s: &mut Session| { + let list = s.execute("profiles.list", &json!({})).unwrap(); + let list = list.as_array().unwrap(); + assert!(list.iter().any(|p| p["id"] == "lc.color"), "built-in profiles stay listed"); + list.iter().find(|p| p["id"] == id.as_str()).cloned() + }; + assert_eq!(listed(&mut s), Some(json!({"id": id, "name": "Warm Test", "group": "Film Looks", "favorite": true, "imported": true}))); // the library keeps it drop(s); lightcraft_pipeline::lut::unregister(&id); @@ -569,8 +601,10 @@ fn cube_luts_become_profiles() { assert!(s.profile_favorites.contains(&id), "imported LUT favorite survives reopen"); assert_eq!(s.profile_info(&id), Some(("Warm Test", "Film Looks"))); assert!(lightcraft_pipeline::lut::get(&id).is_some(), "registered again on open"); + assert!(listed(&mut s).is_some_and(|p| p["favorite"] == true), "listed after reopen"); s.execute("profile.deleteImported", &json!({"id": id})).unwrap(); assert!(lightcraft_pipeline::lut::get(&id).is_none()); + assert_eq!(listed(&mut s), None, "a deleted profile is no longer listed"); let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } @@ -1404,14 +1438,20 @@ fn smart_preview_look_marker_is_read_safely() { let dir = temp_dir("smartlook-marker"); assert!(!crate::smart::look_checked(&dir), "no marker"); let cur = crate::camera_preview::LOOK_VERSION; + let enc = crate::smart::ENCODE_VERSION; for (text, checked) in [ (String::new(), false), ("banana".into(), false), ("-1".into(), false), (format!("{}", cur.saturating_sub(1)), false), ("9".repeat(200), false), - (format!("{cur}\n"), true), - (format!(" {}\n", cur + 1), true), + // Written before the encode version was recorded: only says the look was checked, so the + // folder is scanned once more and the old-encoding proxies are rebuilt. + (format!("{cur}\n"), false), + (format!("{cur} {}\n", enc.saturating_sub(1)), false), + (format!("{cur} banana\n"), false), + (format!("{cur} {enc}\n"), true), + (format!(" {} {}\n", cur + 1, enc + 1), true), ] { std::fs::write(dir.join("look-version"), &text).unwrap(); assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}"); diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs index 6a19a5e5a..3e6e9208b 100644 --- a/crates/engine/src/tests_import_move.rs +++ b/crates/engine/src/tests_import_move.rs @@ -22,6 +22,9 @@ fn write_png(path: &Path, seed: u8) { let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); + // undated files take the file's date: the tests' clock time (2026-01-14T05:58:48) + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_370_328); + std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap(); } fn xmp(rating: u8) -> String { diff --git a/crates/engine/src/tests_lightroom_folder.rs b/crates/engine/src/tests_lightroom_folder.rs index cdf9905c4..eccff23ac 100644 --- a/crates/engine/src/tests_lightroom_folder.rs +++ b/crates/engine/src/tests_lightroom_folder.rs @@ -155,13 +155,22 @@ fn odd_folders_never_fail_the_check() { #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; - let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata")); - std::fs::create_dir_all(&bad).unwrap(); - std::fs::write(root.join("Cat.lrcat"), b"x").unwrap(); - assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name"); - let lr = root.join(std::ffi::OsStr::from_bytes(b"\xff\xfe")); + // APFS (macOS) refuses names that are not valid UTF-8 (EILSEQ): there the non-UTF-8 cases are skipped and + // the symlink cases below still run on a plain-named catalog folder + let probe = root.join(std::ffi::OsStr::from_bytes(b"\xff")); + let utf8_only = std::fs::create_dir(&probe).is_err(); + let _ = std::fs::remove_dir(&probe); + if !utf8_only { + let bad = root.join(std::ffi::OsStr::from_bytes(b"caf\xe9 Previews.lrdata")); + std::fs::create_dir_all(&bad).unwrap(); + std::fs::write(root.join("Cat.lrcat"), b"x").unwrap(); + assert_eq!(lightroom_catalog_at(&bad), Some(LightroomPlace { catalog: Some(root.join("Cat.lrcat")) }), "non-UTF-8 name"); + } + let lr = root.join(if utf8_only { std::ffi::OsStr::new("lr") } else { std::ffi::OsStr::from_bytes(b"\xff\xfe") }); std::fs::create_dir_all(&lr).unwrap(); - std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap(); + if !utf8_only { + std::fs::write(lr.join(std::ffi::OsStr::from_bytes(b"\xff.lrcat")), b"x").unwrap(); + } std::fs::write(lr.join("b.lrcat"), b"x").unwrap(); assert_eq!(lightroom_catalog_at(&lr), Some(LightroomPlace { catalog: Some(lr.join("b.lrcat")) }), "non-UTF-8 entries skipped"); // a symlink to a catalog folder is one too; a dangling one is not diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index a0784dd15..27a82fbed 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -67,6 +67,31 @@ fn hdr_merge_command_creates_and_imports_a_dng() { let _ = std::fs::remove_dir_all(&dir); } +/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with, +/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark). +#[test] +fn a_merge_of_raws_without_colour_matrices_keeps_their_colour() { + let dir = temp_dir("hdr-colour"); + let neutral = [0.5, 1.0, 0.7]; + let mut paths = Vec::new(); + for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &[-1.0, 0.0, 1.0]).unwrap().into_iter().enumerate() { + // the same pixels with a white balance but no colour matrices + let mut raw = lightcraft_raw::decode(&b).unwrap(); + raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some(neutral), ..Default::default() }; + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).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.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap(); + let merged = lightcraft_raw::decode(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap(); + assert!(lightcraft_raw::color::has_matrix(&merged.color), "{:?}", merged.color); + let got = merged.color.as_shot_neutral.expect("the sources' white balance"); + assert!(got.iter().zip(neutral).all(|(a, b)| (a - b).abs() < 1e-3), "{got:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn panorama_command_with_auto_crop() { let dir = temp_dir("pano"); diff --git a/crates/engine/src/tests_presets_scale.rs b/crates/engine/src/tests_presets_scale.rs new file mode 100644 index 000000000..866b7deb3 --- /dev/null +++ b/crates/engine/src/tests_presets_scale.rs @@ -0,0 +1,126 @@ +//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays +//! responsive: commands that don't touch presets never re-serialize them, and opening or importing +//! doesn't compare every preset with every other. + +use std::path::PathBuf; +use std::time::Instant; + +use lightcraft_develop::Preset; +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn many(n: usize) -> Vec { + (0..n) + .map(|i| Preset { + id: format!("user.coll-{}.look-{i}", i / 100), + name: format!("Look {i}"), + group: format!("Collection {}", i / 100), + settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}), + favorite: false, + builtin: false, + }) + .collect() +} + +#[test] +fn develop_commands_do_not_reserialize_unchanged_presets() { + let lib = temp_dir("no-reserialize"); + let mut s = Session::new(); + s.open_library(&lib, true).unwrap(); + let added = s.add_presets(many(2000)); + assert_eq!(added.len(), 2000); + s.persist().unwrap(); + assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved"); + let before = crate::library::presets_serialized(); + for i in 0..20 { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap(); + } + assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone"); + // a preset change is still saved + let pid = added[7].clone(); + s.execute("preset.favorite", &json!({"id": pid})).unwrap(); + assert!(crate::library::presets_serialized() > before); + s.close_library().unwrap(); + drop(s); + let mut t = Session::new(); + t.open_library(&lib, false).unwrap(); + assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted"); + assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn preset_list_generation_follows_mutable_access_only() { + let mut s = Session::new(); + let g0 = s.presets.generation(); + let _ = s.presets.iter().filter(|p| p.builtin).count(); + let _ = s.presets.len(); + assert_eq!(s.presets.generation(), g0, "reads keep the generation"); + s.presets.push(many(1).remove(0)); + let g1 = s.presets.generation(); + assert_ne!(g1, g0); + for p in &mut s.presets { + p.favorite = false; + } + assert_ne!(s.presets.generation(), g1); + // generations are unique across sessions (UI caches key on them alone) + let other = Session::new(); + assert_ne!(other.presets.generation(), s.presets.generation()); +} + +#[test] +fn importing_thousands_of_presets_is_not_quadratic() { + let mut s = Session::new(); + let t = Instant::now(); + let added = s.add_presets(many(20_000)); + assert_eq!(added.len(), 20_000); + // the same presets again are all skipped as duplicates + assert!(s.add_presets(many(20_000)).is_empty()); + // a clashing id with different settings gets a fresh, unique one + let mut clash = many(1).remove(0); + clash.settings = json!({"light": {"exposure": 3.0}}); + let id = s.add_presets(vec![clash]).remove(0); + assert_ne!(id, "user.coll-0.look-0"); + assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1); + // quadratic was ~200 M comparisons here (tens of seconds in debug builds) + assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed()); +} + +/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test +/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied +/// into a temporary library; the original is only read. +#[test] +#[ignore] +fn presets_scale_bench() { + let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else { + eprintln!("set LIGHTCRAFT_BENCH_PRESETS"); + return; + }; + let lib = temp_dir("bench"); + std::fs::copy(&src, lib.join("presets.json")).unwrap(); + let mut s = Session::new(); + let t = Instant::now(); + s.open_library(&lib, true).unwrap(); + eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len()); + let t = Instant::now(); + for i in 0..20 { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap(); + } + eprintln!("develop.set: {:?} per command", t.elapsed() / 20); + let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap(); + let t = Instant::now(); + s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap(); + eprintln!("preset.apply: {:?}", t.elapsed()); + let t = Instant::now(); + s.execute("preset.favorite", &json!({"id": pid})).unwrap(); + eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs index 25fd482b7..988e7aac1 100644 --- a/crates/engine/src/tests_spots.rs +++ b/crates/engine/src/tests_spots.rs @@ -4,6 +4,23 @@ use serde_json::json; use crate::Session; +/// `library.up` / `library.down` (#1171): move by the given number of photos, clamped at the ends. +#[test] +fn up_and_down_move_by_rows_and_stop_at_the_ends() { + let mut s = Session::with_demo(); + let vis = s.visible_cloned(); + assert!(vis.len() >= 6); + s.selection = crate::Selection::single(vis[0]); + s.execute("library.down", &json!({"rows": 4})).unwrap(); + assert_eq!(s.selection.active, Some(vis[4])); + s.execute("library.up", &json!({})).unwrap(); + assert_eq!(s.selection.active, Some(vis[3]), "default is one photo (the filmstrip)"); + s.execute("library.up", &json!({"rows": 99})).unwrap(); + assert_eq!(s.selection.active, Some(vis[0]), "clamped at the first photo"); + s.execute("library.down", &json!({"rows": u64::MAX})).unwrap(); + assert_eq!(s.selection.active, vis.last().copied(), "hostile counts clamp, never overflow"); +} + fn spots(s: &Session) -> Vec { s.develop_of(s.active().unwrap()).unwrap().spots.clone() } diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..58d0d7a14 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -7,6 +7,26 @@ use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; use serde_json::json; use crate::Session; +use crate::rename::{MoveFs, RealFs}; + +/// Whether `dir` is on a case-insensitive volume (the default on macOS and Windows): its path in upper case still resolves. +pub(crate) fn case_insensitive(dir: &Path) -> bool { + Path::new(&dir.to_string_lossy().to_uppercase()).exists() +} + +/// The same path, or the same file under another spelling (`a.XMP` and `a.xmp` on a case-insensitive volume). Fault +/// injectors match this way, so an injected failure fires whichever spelling the code under test uses for a file; on a +/// case-sensitive volume only identical paths match. +pub(crate) fn same_path_or_file(a: &Path, b: &Path) -> bool { + a == b || RealFs.same_file(a, b) +} + +/// The same path, or on a case-insensitive volume a path that differs only in ASCII case: the code under test may spell +/// a sidecar `.XMP` for a file stored as `.xmp` there, while on a case-sensitive volume it offers only the spelling that +/// exists. +pub(crate) fn same_path_for_volume(a: &Path, b: &Path) -> bool { + a == b || (a.parent().is_some_and(case_insensitive) && a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) +} pub(crate) fn temp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("lc-xmp-{tag}-{}", std::process::id())); @@ -175,6 +195,71 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() { let _ = std::fs::remove_dir_all(&src); } +/// A sidecar with only `crs:` develop settings, as LRTimelapse writes them (issue #934). +fn crs_exposure_sidecar(exposure: &str) -> String { + format!( + r#""# + ) +} + +/// Issue #934: with auto-write on, Read Metadata from File wrote what it had just read straight back +/// into the sidecar, adding our `lc:settings`. Reading leaves the file byte for byte; an edit made +/// after it is still written. +#[test] +fn reading_metadata_leaves_the_sidecar_alone_with_auto_write_on() { + let src = temp_dir("read-no-autowrite"); + write_png(&src.join("frame.png"), 6); + let sidecar = src.join("frame.xmp"); + std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = only(&s); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5); + // something for the read to change back (auto-write is still off, so the file stays as it is) + s.execute("develop.reset", &json!({"ids": [id.0]})).unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let before = std::fs::read(&sidecar).unwrap(); + let r = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["read"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5, "the sidecar was read"); + let now = std::fs::read(&sidecar).unwrap(); + assert!(now == before, "reading leaves the sidecar byte for byte, but it is now:\n{}", String::from_utf8_lossy(&now)); + // a develop edit afterwards is still written (only the read is exempt) + s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 0.9})).unwrap(); + let after = std::fs::read_to_string(&sidecar).unwrap(); + match crate::sidecar::parse_sidecar(&after, crate::crs::Target::Rendered).unwrap().develop { + Some(crate::sidecar::DevelopPatch::Full(d)) => assert_eq!(d.light.exposure, 0.9), + other => panic!("the edit wasn't auto-written: {other:?}\n{after}"), + } + assert!(after.contains(r#"crs:Exposure2012="0.5""#), "the other app's settings are kept: {after}"); + let _ = std::fs::remove_dir_all(&src); +} + +/// Issue #934, the round trip the write-back broke: after a read with auto-write on, another app +/// changes `crs:Exposure2012` from 0.5 to 1.2 (keeping the rest of the file), and the next read +/// takes 1.2. +#[test] +fn a_crs_change_after_a_read_is_taken_with_auto_write_on() { + let src = temp_dir("read-then-crs-change"); + write_png(&src.join("frame.png"), 7); + let sidecar = src.join("frame.xmp"); + std::fs::write(&sidecar, crs_exposure_sidecar("0.5")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = only(&s); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 0.5); + // the other app edits the file as it is now + let text = std::fs::read_to_string(&sidecar).unwrap(); + let changed = text.replace(r#"crs:Exposure2012="0.5""#, r#"crs:Exposure2012="1.2""#); + assert_ne!(changed, text); + std::fs::write(&sidecar, changed).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().develop.light.exposure, 1.2, "the second read takes the other app's exposure"); + let _ = std::fs::remove_dir_all(&src); +} + /// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar /// and our own starting look stands in for them: importing one gives no camera-profile warning. #[test] diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 479c877cf..1c1b53b4a 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -57,6 +57,8 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl if let Ok(c) = std::fs::canonicalize(root) { linked.insert(c); } + // Include aliases to the library in the exclusion, not just its lexical path. + let skip_canonical = skip.and_then(|p| std::fs::canonicalize(p).ok()); // (path, depth of the folder it is in, is a folder); popped in path order let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)]; while let Some((path, depth, is_dir)) = stack.pop() { @@ -66,7 +68,7 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl } continue; } - if skip.is_some_and(|s| path == s) { + if skip.is_some_and(|s| path == s) || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) { continue; } if depth >= limits.max_depth { @@ -165,6 +167,31 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Folder imports must not revisit the user's own library through symlinks + /// or through an alias to the library's parent. + #[cfg(unix)] + #[test] + fn skipped_library_stays_skipped_through_symlinks_and_ancestors() { + let d = tmp("skip-alias"); + let root = d.join("incoming"); + let library = d.join("library"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(library.join("Originals")).unwrap(); + std::fs::write(root.join("ordinary.jpg"), b"photo").unwrap(); + std::fs::write(library.join("Originals").join("library-only.jpg"), b"original").unwrap(); + std::os::unix::fs::symlink(&library, root.join("library-link")).unwrap(); + std::os::unix::fs::symlink(&d, root.join("parent-link")).unwrap(); + + let photos = files_in(&root, Some(&library), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg")); + assert!(photos.files.contains(&root.join("ordinary.jpg"))); + assert!(!photos.files.iter().any(|p| p.ends_with("library-only.jpg")), "{photos:?}"); + + // Scanning a subfolder already inside the excluded library is also forbidden. + let inside = files_in(&library.join("Originals"), Some(&library), Limits::default(), |_| true); + assert!(inside.files.is_empty(), "{inside:?}"); + let _ = std::fs::remove_dir_all(&d); + } + /// A link back up the tree (a loop) is followed at most once: the walk ends. #[cfg(unix)] #[test] diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs index 11ffec44e..548e4b753 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/preview_read.rs @@ -32,10 +32,16 @@ fn a_preview_reads_only_the_header_and_the_preview() { read.total, frac * 100.0, elapsed.as_secs_f64(), - if read.header_path { " [header path]" } else { " [whole file]" } + if read.planned { + " [plan]" + } else if read.header_path { + " [header path]" + } else { + " [whole file]" + } ); - assert!(read.header_path, "the header path must answer for a raw whose pointer is in its header"); + assert!(read.header_path, "the header or the plan must answer for a raw whose preview is not the whole file"); assert_eq!(read.total, file_len, "the report covers the whole file"); assert!(frac < 0.5, "a preview must not cost most of the file: read {} of {} bytes", read.read, read.total); assert!(preview.starts_with(&[0xff, 0xd8]), "the preview bytes are a JPEG: {:?}", preview.get(..4)); diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs index 810e39cd4..857ea19b9 100644 --- a/crates/geom/src/crop.rs +++ b/crates/geom/src/crop.rs @@ -84,15 +84,21 @@ impl CropGeometry { /// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's /// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle, /// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so - /// they all give the same crop (issue #742). + /// they all give the same crop (issue #742). A crop that is the largest of its aspect at its + /// own angle (the full photo, or one an earlier turn shrank to fit) stays the largest at the new + /// angle, so it grows back as the angle returns towards 0° (issue #1388). pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry { - let cand = CropGeometry { rect: self.rect, angle: angle_deg }; - if cand.is_within_image(w, h) { - return cand; - } let r = self.rect_px(w, h); // a degenerate rect has no aspect to keep: fall back to the image's own let aspect = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0); + let largest = crop_fit_angle(w, h, self.angle, aspect).rect; + let is_largest = [(self.rect.x0, largest.x0), (self.rect.y0, largest.y0), (self.rect.x1, largest.x1), (self.rect.y1, largest.y1)] + .iter() + .all(|(a, b)| (a - b).abs() < 1e-9); + let cand = CropGeometry { rect: self.rect, angle: angle_deg }; + if !is_largest && cand.is_within_image(w, h) { + return cand; + } crop_fit_angle(w, h, angle_deg, aspect) } } @@ -172,6 +178,23 @@ mod tests { } } + #[test] + fn with_angle_grows_a_fitted_crop_back_as_the_angle_returns() { + // issue #1388: the crop a turn shrank to fit is the largest of its aspect; turning back + // gives the largest at the new angle, not the shrunk one again + for aspect in [None, Some(1.0), Some(16.0 / 9.0)] { + let mut c = crop_fit_angle(600.0, 400.0, 0.0, aspect); + for ang in [10.0, 25.0, -25.0, 7.0, 0.0] { + c = c.with_angle(ang, 600.0, 400.0); + let want = crop_fit_angle(600.0, 400.0, ang, aspect); + assert!( + (c.rect.width() - want.rect.width()).abs() < 1e-9 && (c.rect.height() - want.rect.height()).abs() < 1e-9, + "{aspect:?} at {ang}°: {c:?} vs {want:?}" + ); + } + } + } + #[test] fn with_angle_survives_a_degenerate_crop() { // zero height: no aspect to keep (width / height = ∞) diff --git a/crates/geom/src/homography.rs b/crates/geom/src/homography.rs index b27ecef24..daa11b05f 100644 --- a/crates/geom/src/homography.rs +++ b/crates/geom/src/homography.rs @@ -51,11 +51,11 @@ impl Homography { let c01 = m[5] * m[6] - m[3] * m[8]; let c02 = m[3] * m[7] - m[4] * m[6]; let det = m[0] * c00 + m[1] * c01 + m[2] * c02; - if det.abs() < 1e-300 { + if !det.is_finite() || det.abs() < 1e-300 { return None; } let inv = 1.0 / det; - Some(Homography([ + let coefficients = [ c00 * inv, (m[2] * m[7] - m[1] * m[8]) * inv, (m[1] * m[5] - m[2] * m[4]) * inv, @@ -65,11 +65,17 @@ impl Homography { c02 * inv, (m[1] * m[6] - m[0] * m[7]) * inv, (m[0] * m[4] - m[1] * m[3]) * inv, - ])) + ]; + coefficients.iter().all(|v| v.is_finite()).then_some(Homography(coefficients)) } /// The homography mapping `src[i]` → `dst[i]` (4 correspondences, DLT with h33 = 1). pub fn from_quads(src: &[Point; 4], dst: &[Point; 4]) -> Option { + // Failed feature matches and invalid perspective parameters can contain + // non-finite coordinates. Do not return a transform that poisons rendering. + if src.iter().chain(dst.iter()).any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return None; + } let mut a = [[0.0f64; 9]; 8]; for i in 0..4 { let (x, y) = (src[i].x, src[i].y); @@ -80,7 +86,7 @@ impl Homography { // Gaussian elimination with partial pivoting on the 8×8 system (last column = rhs). for col in 0..8 { let piv = (col..8).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?; - if a[piv][col].abs() < 1e-12 { + if !a[piv][col].is_finite() || a[piv][col].abs() < 1e-12 { return None; } a.swap(col, piv); @@ -94,7 +100,7 @@ impl Homography { } } let h: Vec = (0..8).map(|i| a[i][8] / a[i][i]).collect(); - Some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0])) + h.iter().all(|v| v.is_finite()).then_some(Homography([h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0])) } } @@ -121,4 +127,19 @@ mod tests { let src = [Point::new(0.0, 0.0); 4]; assert!(Homography::from_quads(&src, &src).is_none()); } + + #[test] + fn nonfinite_projective_and_affine_transforms_are_rejected() { + let src = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)]; + let mut invalid = src; + invalid[2].x = f64::NAN; + assert!(Homography::from_quads(&invalid, &src).is_none()); + invalid[2].x = f64::INFINITY; + assert!(Homography::from_quads(&src, &invalid).is_none()); + assert!(Homography([f64::NAN, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none()); + assert!(Homography([f64::INFINITY, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).inverse().is_none()); + assert!(Affine([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none()); + assert!(Affine([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0]).inverse().is_none()); + assert_eq!(Affine::IDENTITY.inverse(), Some(Affine::IDENTITY)); + } } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1d81279d9..4b8f623e2 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -234,12 +234,13 @@ impl Affine { } pub fn inverse(&self) -> Option { let det = self.determinant(); - if det.abs() < 1e-300 { + if !det.is_finite() || det.abs() < 1e-300 { return None; } let [a, b, c, d, e, f] = self.0; let inv = 1.0 / det; - Some(Affine([d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv])) + let coefficients = [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv]; + coefficients.iter().all(|v| v.is_finite()).then_some(Affine(coefficients)) } /// Bounding box of a transformed rectangle. pub fn transform_rect_bbox(&self, r: &Rect) -> Rect { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350f..04ae60958 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -229,10 +229,19 @@ pub(crate) fn rgb_words(img: &Rgb32f) -> &[f32] { // --------------------------------------------------------------------------------------------- // Filters (twins of `lightcraft_raster::blur` / `resample` and `lightcraft_pipeline::local`) -/// Pixels each blur thread slides its running sum over (at least; longer for large radii, so the +/// Rows each `box_v` thread slides its running sum over (at least; longer for large radii, so the /// priming sum stays a small share of the work). const CHUNK: usize = 16; +/// One box pass of radius `r` (clamped edges) over a `w × h` image of `nc` interleaved channels, +/// `src → dst`: horizontal or vertical. +fn box_pass(cx: &mut Cx<'_>, horizontal: bool, src: &Buf, dst: &Buf, (w, h): (usize, usize), nc: usize, r: usize) { + let chunk = (2 * r + 1).clamp(CHUNK, 128); + let p = [w as u32, h as u32, nc as u32, r as u32, chunk as u32]; + let (k, groups) = if horizontal { ("box_h", groups2(w, h, [64, 4])) } else { ("box_v", groups2(w, h.div_ceil(chunk), [64, 4])) }; + cx.run(k, &p, &[Some(src), Some(dst)], groups); +} + /// Gaussian blur of a `w × h` image of `nc` interleaved channels: three box passes each way /// (`lightcraft_raster::blur::gaussian`). pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize, sigma: f32) -> Buf { @@ -240,18 +249,15 @@ pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize return cx.copy(src); } let radii = lightcraft_raster::blur::box_radii(sigma); - let passes: Vec<(&str, usize)> = radii.iter().map(|r| ("box_h", *r)).chain(radii.iter().map(|r| ("box_v", *r))).filter(|p| p.1 > 0).collect(); + let passes: Vec<(bool, usize)> = radii.iter().map(|r| (true, *r)).chain(radii.iter().map(|r| (false, *r))).filter(|p| p.1 > 0).collect(); if passes.is_empty() { return cx.copy(src); } // ping-pong between two buffers let mut bufs = [cx.gpu.buffer(w * h * nc), cx.gpu.buffer(w * h * nc)]; - for (i, (k, r)) in passes.iter().enumerate() { - let chunk = (2 * r + 1).clamp(CHUNK, 128); - let groups = if *k == "box_h" { groups2(w.div_ceil(chunk), h, [64, 4]) } else { groups2(w, h.div_ceil(chunk), [64, 4]) }; - let p = [w as u32, h as u32, nc as u32, *r as u32, chunk as u32]; + for (i, (horizontal, r)) in passes.iter().enumerate() { let (out, prev) = (&bufs[i % 2], &bufs[(i + 1) % 2]); - cx.run(k, &p, &[Some(if i == 0 { src } else { prev }), Some(out)], groups); + box_pass(cx, *horizontal, if i == 0 { src } else { prev }, out, (w, h), nc, *r); } let last = (passes.len() - 1) % 2; let [a, b] = std::mem::replace(&mut bufs, [cx.gpu.buffer(0), cx.gpu.buffer(0)]); @@ -712,6 +718,27 @@ pub fn render( _ => Err(m), } }); + // every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once). + // The masks buffer holds each visible mask's alpha back-to-back in the same `list` order the + // single-mask overlay uses, so read the whole buffer back in ONE GPU->CPU transfer and split it + // on the CPU, instead of one synchronous readback per mask (which stalls the pipeline N times). + let all_overlay_alphas: Option> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) { + masks.as_ref().map(|all| { + let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); + if list.is_empty() { + return Vec::new(); + } + let raw: Vec = cx.read(all, n * list.len()); + let mut alphas = Vec::with_capacity(list.len()); + for mi in 0..list.len() { + let s = mi * n; + alphas.push(Plane { width: w, height: h, data: raw[s..s + n].to_vec() }); + } + alphas + }) + } else { + None + }; let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); @@ -747,7 +774,7 @@ pub fn render( lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref()) } }); - lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref()); // spots grew the window the render worked on: cut it back to the request if let Some(k) = plan.keep { image = image.crop(k.x, k.y, k.w, k.h); @@ -1132,6 +1159,33 @@ mod tests { assert!(covered > 0 && covered < w * h, "{covered} px covered"); } + /// The blur against `lightcraft_raster::blur::gaussian`: widths that do and don't fill the last + /// workgroup, windows wider than the image, and 1, 2 (as two planes) and 3 channels. + #[test] + fn gaussian_matches_cpu() { + use lightcraft_raster::blur; + let Some(gpu) = crate::device() else { return }; + let _scope = crate::ctx::RenderScope::new(gpu); + let v = |x: usize, y: usize, c: usize| ((x * 7 + y * 13 + c * 5) % 17) as f32 / 16.0 + if x % 61 < 3 { 2.0 } else { 0.0 }; + for (w, h) in [(1000, 5), (257, 3), (5, 4), (1, 9)] { + for sigma in [0.8f32, 2.0, 4.5, 30.0, 250.0, 1000.0] { + let planes: Vec = (0..3).map(|c| Plane::from_fn(w, h, |x, y| v(x, y, c))).collect(); + let want: Vec = planes.iter().map(|p| blur::gaussian(p, sigma)).collect(); + for nc in [1, 2, 3] { + let words: Vec = (0..w * h).flat_map(|i| (0..nc).map(move |c| (i, c))).map(|(i, c)| planes[c].data[i]).collect(); + let mut cx = Cx::new(gpu); + let src = gpu.upload(&words); + let out = gaussian(&mut cx, &src, w, h, nc, sigma); + let got: Vec = cx.read(&out, w * h * nc); + for (i, g) in got.iter().enumerate() { + let want = want[i % nc].data[i / nc]; + assert!((g - want).abs() < 1e-5 * (1.0 + sigma / 10.0), "{w}×{h} nc={nc} sigma={sigma}: word {i} {g} vs {want}"); + } + } + } + } + } + /// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`. #[test] #[ignore] @@ -1139,6 +1193,7 @@ mod tests { let Some(gpu) = crate::device() else { return }; let _scope = crate::ctx::RenderScope::new(gpu); let (w, h) = (6000, 4000); + eprintln!("{} ({:?})", gpu.info.name, gpu.info.backend); let src = gpu.upload(&vec![0.5f32; w * h * 3]); let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| { let mut best = f64::MAX; @@ -1152,9 +1207,19 @@ mod tests { } eprintln!("{name:<40} {best:.1} ms"); }; + for nc in [1, 2, 3] { + let out = gpu.buffer(w * h * nc); + // noise reduction's radii, Texture's and Dehaze's at 6000 px + for r in [2, 11, 120] { + for (k, horizontal) in [("box_h", true), ("box_v", false)] { + time(&format!("{k} nc={nc} r={r}"), &mut |cx| box_pass(cx, horizontal, &src, &out, (w, h), nc, r)); + } + } + } for nc in [1, 2, 3] { time(&format!("gaussian nc={nc} sigma=2"), &mut |cx| drop(gaussian(cx, &src, w, h, nc, 2.0))); } + time("gaussian nc=3 sigma=4.5 (colour NR)", &mut |cx| drop(gaussian(cx, &src, w, h, 3, 4.5))); time("map log_lum", &mut |cx| { let o = cx.gpu.buffer(w * h); map(cx, "log_lum_k", w * h, &[], [Some(&src), None, None], &o); diff --git a/crates/gpu/src/wgsl/blur.wgsl b/crates/gpu/src/wgsl/blur.wgsl index b0e28370d..375fb6d55 100644 --- a/crates/gpu/src/wgsl/blur.wgsl +++ b/crates/gpu/src/wgsl/blur.wgsl @@ -1,6 +1,6 @@ // Box passes (clamped edges) over an image of NC interleaved channels; three of each approximate a -// Gaussian (`lightcraft_raster::blur::gaussian`). Each thread slides a running sum over CH pixels. -// P: w, h, nc, r, ch. Bindings: src, dst. +// Gaussian (`lightcraft_raster::blur::gaussian`). +// P: w, h, nc, r, ch (box_v only). Bindings: src, dst. // Pixel `i` (all nc ≤ 3 channels at once: one contiguous read per pixel). fn ld(i: u32, nc: u32) -> vec3 { @@ -25,31 +25,27 @@ fn st(i: u32, nc: u32, v: vec3) { } } -// Horizontal: one thread per CH consecutive pixels of a row. +// Horizontal: one thread per pixel, summing its 2r + 1 window; adjacent threads read adjacent +// pixels, which the cache serves. (Threads that each slid a running sum over 16 pixels read pixels +// 16 apart across a workgroup: on a Radeon 780M that pass took 9–15× as long as `box_v`.) @compute @workgroup_size(64, 4) fn box_h(@builtin(global_invocation_id) g: vec3) { let w = pu(0u); let h = pu(1u); let nc = pu(2u); let r = i32(pu(3u)); - let ch = pu(4u); + let x = g.x; let y = g.y; - let x0 = g.x * ch; - if (y >= h || x0 >= w) { + if (x >= w || y >= h) { return; } let last = i32(w) - 1; - let inv = 1.0 / f32(2 * r + 1); let row = y * w; - let x1 = min(x0 + ch, w); var acc = vec3(0.0); for (var k = -r; k <= r; k++) { - acc += ld(row + u32(clamp(i32(x0) + k, 0, last)), nc); - } - for (var x = x0; x < x1; x++) { - st(row + x, nc, acc * inv); - acc = acc + ld(row + u32(min(i32(x) + r + 1, last)), nc) - ld(row + u32(max(i32(x) - r, 0)), nc); + acc += ld(row + u32(clamp(i32(x) + k, 0, last)), nc); } + st(row + x, nc, acc * (1.0 / f32(2 * r + 1))); } // Vertical: one thread per CH consecutive rows of a column (the CPU re-primes every BAND rows the diff --git a/crates/heif/Cargo.toml b/crates/heif/Cargo.toml index b3bc69d62..95641fb2f 100644 --- a/crates/heif/Cargo.toml +++ b/crates/heif/Cargo.toml @@ -11,11 +11,11 @@ license.workspace = true # Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the # other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL) # code structure; gamut-heic has no HEVC decoder of its own). -heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] } +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] } # The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] } +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] } rayon = { workspace = true } [features] diff --git a/crates/lensdb/src/lib.rs b/crates/lensdb/src/lib.rs index fca9f9dca..185ae09cc 100644 --- a/crates/lensdb/src/lib.rs +++ b/crates/lensdb/src/lib.rs @@ -100,7 +100,8 @@ pub struct Shot { pub aperture: Option, /// Focus distance, metres. pub distance: Option, - /// Crop factor of the camera (from [`LensDb::find_camera`]); the profile's own when unknown. + /// Crop factor of the recorded frame: the camera's (from [`LensDb::find_camera`]), or larger when the camera + /// recorded a crop of its sensor; the profile's own when unknown. pub crop_factor: Option, /// Image size in pixels (the frame the correction is expressed for; the optical centre is its centre). pub width: f64, diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 8678b1f7e..095cc4684 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -10,13 +10,6 @@ use serde_json::{Value, json}; use crate::backend::{Backend, ProgressHook}; -/// File extensions recognised as photos when expanding folders. -pub const PHOTO_EXTENSIONS: &[&str] = &[ - "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl", - "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", -]; - /// Headless backend: a [`Session`] with filesystem hooks. pub struct Headless { pub session: Session, @@ -175,15 +168,16 @@ impl Backend for Headless { } /// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files -/// and make paths absolute. +/// and make paths absolute. Folders take the extensions `library.import` takes +/// (`lightcraft_engine::import::EXTENSIONS`), so the two can't drift apart (issue #1080). pub fn expand_paths(paths: &[String]) -> Vec { let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string(); - let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())); let mut out = Vec::new(); for p in paths { let path = Path::new(p); if path.is_dir() { - let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo); + let w = + lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), lightcraft_engine::import::is_supported); out.extend(w.files.iter().map(|f| absolute(f))); } else { // Explicit files are kept even with unknown extensions (the probe decides). @@ -226,6 +220,27 @@ mod tests { assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}"); } + /// `--import ` picks up every extension `library.import` takes, HEIC included (issue #1080). + #[test] + fn expand_paths_takes_the_engine_extensions() { + let dir = std::env::temp_dir().join(format!("lc-mcp-expand-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for e in lightcraft_engine::import::EXTENSIONS { + std::fs::write(dir.join(format!("a.{e}")), b"x").unwrap(); + } + std::fs::write(dir.join("B.HEIC"), b"x").unwrap(); + std::fs::write(dir.join("notes.txt"), b"x").unwrap(); + let found = expand_paths(&[dir.to_string_lossy().to_string()]); + let _ = std::fs::remove_dir_all(&dir); + let ext = |f: &String| Path::new(f).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let mut got: Vec = found.iter().map(ext).collect(); + got.sort(); + let mut want: Vec = lightcraft_engine::import::EXTENSIONS.iter().map(|e| e.to_string()).chain(["HEIC".to_string()]).collect(); + want.sort(); + assert_eq!(got, want); + } + /// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock. #[test] fn headless_uses_the_system_clock() { diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index b2417fe03..f95dd7a53 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -23,7 +23,7 @@ mod tools; pub use backend::{Backend, ProgressHook, Remote}; pub use base64::{base64_decode, base64_encode}; -pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image}; +pub use headless::{Headless, encode_image, expand_paths, write_image}; pub use server::{PROTOCOL_VERSION, Server}; pub use tools::{COMMAND_TOOL_PREFIX, ToolResult, call_tool, command_tool_name, helper_tools, tool_definitions}; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index ba64ef45a..4565db76e 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -25,7 +25,7 @@ const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \ -Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \ +Photos have integer ids; most editing acts on the *active* photo (`set_develop`, `crop` and `export` with an explicit photo id end the current edit interaction, replace the selection with that one photo and make it active; `get_develop` and `render_photo` do not change the selection). \ Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \ render_preview to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \ coordinates 0..1 with the origin at the top-left. Every action is a command: command_list shows ids and parameter \ diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 9975ef062..99193b480 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -2,6 +2,13 @@ use serde_json::{Value, json}; use crate::{Backend, Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name}; +/// An undated file takes its own date (#1216): give the fixture the date its folder template expects (noon UTC, +/// the same day in every time zone the tests run in). +fn file_dated_2026_01_14(path: &std::path::Path) { + let noon = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_768_392_000); + std::fs::File::options().write(true).open(path).unwrap().set_modified(noon).unwrap(); +} + fn server() -> Server { Server::new(Box::new(Headless::demo())) } @@ -82,6 +89,7 @@ fn import_tool_copies_with_a_folder_template() { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join("a.png"), png).unwrap(); + file_dated_2026_01_14(&src.join("a.png")); let mut b = Headless::demo(); b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); let args = @@ -255,6 +263,7 @@ fn import_tool_moves_with_a_folder_template() { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join("sample.png"), png).unwrap(); + file_dated_2026_01_14(&src.join("sample.png")); let mut b = Headless::demo(); b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..b71e3d162 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -182,7 +182,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "get_develop", "Get develop settings", "The full develop settings JSON of a photo (white balance, light, color, curves, mixer, grading, effects, crop, masks, spots…).", - json!({"id": photo_id}), + json!({"id": {"type": "integer", "description": "Photo id (default: the active photo). Reads without changing the selection."}}), &[], ), tool( @@ -216,7 +216,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "Render photo", "Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.", json!({ - "id": photo_id, + "id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"}, "size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"}, "format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"}, "path": {"type": "string", "description": "Also save the rendered file here"} @@ -239,7 +239,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, - "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng"], "description": "psd = a flat PSD (no layers); original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, + "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, "width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"}, @@ -262,6 +262,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), PSD 8|16 (default 16), AVIF 8|10, JPEG XL 8|16; JPEG/WebP are 8-bit"}, "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, + "hdrTransfer": {"type": "string", "enum": ["pq", "hlg"], "description": "Transfer curve of HDR AVIF output (default pq): pq = SMPTE ST 2084, hlg = ITU-R BT.2100 hybrid log-gamma"}, "addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"}, "photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"}, "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} @@ -743,6 +744,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "colorSpace", "bitDepth", "hdr", + "hdrTransfer", "preset", "background", "resize", diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 360c7424e..69743a1b3 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -103,15 +103,28 @@ pub fn detect_clip(img: &Rgb32f) -> f32 { if maxv >= 1.0 || piled { level * 0.995 } else { 1.0 } } +/// The colour tags a raw source starts from when the caller knows better than the file (`None`: the +/// file's own tags). Called with the decoded raw, its samples still there, and the file's bytes. +pub type RawColour<'a> = dyn Fn(&mut lightcraft_raw::RawImage, &[u8]) -> Option + Sync + 'a; + /// 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 { + load_frame_with(bytes, max_edge, orient, &|_, _| None) +} + +/// [`load_frame`] with a raw's colour tags from `colour`: a raw without colour matrices of its own +/// (CR2, NEF, ARW…) starts from a colour the app works out (issue #965). +pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colour: &RawColour) -> 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| !op.is_lens_correction()); + if let Some(color) = colour(&mut raw, bytes) { + raw.color = color; + } 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()))?; diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index edfd19348..4b4e206e6 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -23,7 +23,7 @@ pub mod pano; pub mod ransac; pub mod synth; -pub use frame::{Frame, FrameColor, load_frame}; +pub use frame::{Frame, FrameColor, RawColour, load_frame, load_frame_with}; pub use hdr::{Deghost, HdrOptions, HdrResult, merge_hdr}; pub use output::{DngSamples, write_linear_dng}; pub use pano::{PanoOptions, PanoResult, Projection, stitch}; diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index e020ac45d..8128644b6 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -53,6 +53,7 @@ pub fn color_data(color: &FrameColor) -> ColorData { baseline_exposure: 0.0, baseline_sharpness: None, profile: Default::default(), + kelvin_presets: None, } } } diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs index 271aa67d5..ffd2edd76 100644 --- a/crates/meta/src/datetime.rs +++ b/crates/meta/src/datetime.rs @@ -25,8 +25,19 @@ fn num(s: &str) -> Option { impl DateTime { fn valid(self) -> Option { - let ok = (1..=12).contains(&self.month) - && (1..=31).contains(&self.day) + // A timestamp must represent a real Gregorian calendar day, not just + // numbers in the range 1..=31. Invalid dates would otherwise be + // accepted from EXIF/XMP and silently normalized during time arithmetic. + let leap = self.year.is_multiple_of(4) && (!self.year.is_multiple_of(100) || self.year.is_multiple_of(400)); + let max_day = match self.month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => 0, + }; + let ok = self.day >= 1 + && self.day <= max_day && self.hour < 24 && self.minute < 60 && self.second < 61 @@ -165,6 +176,16 @@ mod tests { assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none()); assert!(DateTime::parse_exif(" : : : : ").is_none()); assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none()); + // Invalid calendar days must never enter persisted capture metadata. + for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] { + assert!(DateTime::parse_exif(value).is_none(), "{value}"); + } + assert!(DateTime::parse_exif("2024:02:29").is_some()); + assert!(DateTime::parse_exif("2000:02:29").is_some()); + for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] { + assert!(DateTime::parse_iso(value).is_none(), "{value}"); + } + assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some()); assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0); assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500); assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12); diff --git a/crates/meta/src/gpx.rs b/crates/meta/src/gpx.rs index a8e8e9071..e63499033 100644 --- a/crates/meta/src/gpx.rs +++ b/crates/meta/src/gpx.rs @@ -96,7 +96,9 @@ struct Pending { /// Parse a GPX document. pub fn parse_gpx(s: &str) -> Result { let mut reader = quick_xml::Reader::from_str(s); - reader.config_mut().check_end_names = false; + // Mismatched end tags must be rejected; otherwise the path stack can + // attribute a point's time or segment to the wrong enclosing element. + reader.config_mut().check_end_names = true; let mut path: Vec> = Vec::new(); let mut seen_gpx = false; let mut log = Tracklog::default(); @@ -169,6 +171,9 @@ pub fn parse_gpx(s: &str) -> Result { if !seen_gpx { return Err(GpxError::NotGpx); } + if !path.is_empty() { + return Err(GpxError::Xml("unclosed GPX elements".into())); + } // stable: points logged at the same second keep their file order log.points.sort_by(|a, b| a.time.total_cmp(&b.time)); Ok(log) @@ -362,6 +367,12 @@ mod tests { fn rejects_non_gpx_and_bad_input() { assert_eq!(parse_gpx(""), Err(GpxError::NotGpx)); assert!(parse_gpx("").is_err()); + // A partial logger write must not be mistaken for a complete track, + // even if it contains a valid timed point. + let point = r#""#; + assert!(matches!(parse_gpx(point), Err(GpxError::Xml(_)))); + let wrong_close = r#""#; + assert!(matches!(parse_gpx(wrong_close), Err(GpxError::Xml(_)))); assert!( parse_gpx("") .unwrap() diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs index a02de1235..bea792c19 100644 --- a/crates/pipeline/src/geometry.rs +++ b/crates/pipeline/src/geometry.rs @@ -182,6 +182,11 @@ impl Frame { pub fn fit(&self, max_w: usize, max_h: usize) -> (usize, usize) { let a = self.aspect(); let (mw, mh) = (max_w.max(1) as f64, max_h.max(1) as f64); + // a box that is already this aspect up to rounding (an export size) is the size: fitting + // into it again rounds twice and can lose a pixel of the edge that was asked for + if (mw - 0.5) / a <= mh + 0.5 && (mw + 0.5) / a >= mh - 0.5 { + return (max_w.max(1), max_h.max(1)); + } let (w, h) = if mw / mh > a { (mh * a, mh) } else { (mw, mw / a) }; ((w.round() as usize).max(1), (h.round() as usize).max(1)) } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 6fbb156e8..d94d3a2fc 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -471,6 +471,10 @@ pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::bor /// Long edge (pixels) of the small HDR render [`hdr_histogram`] measures. pub const HDR_HISTOGRAM_EDGE: usize = 384; +/// Long edge of the render that gives a slider drag its live HDR histogram (drafts have none: a +/// second render per tick would slow them down), run beside the drafts by the UI. +pub const HDR_HISTOGRAM_DRAG_EDGE: usize = 192; + /// The histogram of an HDR edit's HDR rendition for a preview request `req`: a small float HDR /// render of the same framing ([`HDR_HISTOGRAM_EDGE`]), binned with SDR on the left half and the /// whole HDR range ([`Hdr::MAX_EV_LIMIT`](lightcraft_develop::Hdr::MAX_EV_LIMIT) stops above SDR @@ -596,7 +600,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("histogram", &mut t); let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); - visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); + let masks = overlay_alphas_all(req.overlay, &plan, &prep); + visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref()); let mut image = if plan.keep.is_some() { cut(&image) } else { image }; if req.overlay == Overlay::HdrRange && edit.hdr.enabled { // the HDR rendition of the same request says how far above SDR white each pixel goes @@ -648,6 +653,24 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref())) } +/// The alpha planes an `Overlay::AllMasks` shows: one per visible mask that has components, +/// reusing the render's own evaluation where available (a fresh evaluation otherwise). +fn overlay_alphas_all(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option> { + if !matches!(o, Overlay::AllMasks { .. }) { + return None; + } + let ev = plan.settings.light.exposure as f32; + let mut out = Vec::new(); + for m in plan.settings.masks.iter().filter(|m| m.visible && !m.components.is_empty()) { + if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { + out.push(e.alpha.clone()); + } else { + out.push(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref())); + } + } + Some(out) +} + /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" /// (default look, keeping the crop so framing matches, under the photo's own rendering process). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..3bb775df4 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,9 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + let (pixels, _) = out.as_chunks::<3>(); + for (d, v) in src.iter_mut().zip(pixels) { + *d = *v; } } diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index fc3d8390f..15c164bb1 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -33,6 +33,9 @@ pub enum Overlay { /// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in /// `color` at `opacity` (0..100; the colour views only). Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 }, + /// The evaluated alpha of every visible mask that has components, drawn as one `view` in + /// `color` at `opacity` (0..100; the colour views only) — every mask's region at once. + AllMasks { view: MaskView, color: [u8; 3], opacity: u8 }, /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). HdrRange, @@ -120,6 +123,7 @@ impl Overlay { Overlay::PointColorRange(i) => (1, i as f64), Overlay::Spots(t) => (2, t as f64), Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), + Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64), Overlay::HdrRange => (4, 0.0), } } @@ -131,6 +135,14 @@ impl Overlay { 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), 4 => Overlay::HdrRange, + 5 if v.is_finite() && v >= 0.0 => { + let rgb = (v as u64 >> 20) & 0xff_ffff; + Overlay::AllMasks { + view: MaskView::ALL.get(((v as u64 >> 16) & 0xf) as usize).copied().unwrap_or_default(), + color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8], + opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8, + } + } _ => Overlay::None, } } @@ -142,6 +154,7 @@ impl Overlay { Overlay::PointColorRange(i) => 0x1000 + i as u64, Overlay::Spots(t) => 0x2000 + t as u64, Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), + Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity), Overlay::HdrRange => 0x4000, } } @@ -168,8 +181,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } /// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask -/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { +/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of +/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components. +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} @@ -184,6 +198,15 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) mask_view(img, a, view, color, opacity); } } + Overlay::AllMasks { view, color, opacity } => { + if let Some(planes) = masks { + for a in planes { + if (a.width, a.height) == (img.width, img.height) { + mask_view(img, a, view, color, opacity); + } + } + } + } } } @@ -380,4 +403,43 @@ mod tests { assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color); assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); } + + #[test] + fn all_masks_overlay_shows_every_visible_mask_at_once() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = Rgb32f::from_fn(80, 40, |_, _| [0.1, 0.1, 0.1]); + let info = SourceInfo::default(); + let left = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let right = MaskShape::Radial { center: Point::new(0.75, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 1, + visible: true, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: left }], + ..Default::default() + }); + s.masks.push(Mask { + id: 2, + visible: true, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }], + ..Default::default() + }); + let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; + let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }; + let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40); + assert_eq!(img.data[in_l], [255; 4], "left mask region painted"); + assert_eq!(img.data[in_r], [255; 4], "right mask region painted"); + assert_eq!(img.data[mid], [0, 0, 0, 255], "between the masks: black"); + // a hidden mask is skipped + s.masks[1].visible = false; + let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted"); + assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted"); + // parts round trip and a stable, distinct cache key + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }.key()); + } } diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index 10a7166c6..26d180ec9 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -94,20 +94,20 @@ impl DiskCache { if std::fs::create_dir_all(parent).is_err() { return; } - // know the current total before adding (the first scan must not count this file twice) - let _ = self.size(); + // Serialize the file replacement and its size accounting. A repeated render of + // the same key replaces its JPEG; it must not count as another cache entry. + let mut total = self.total.lock().unwrap_or_else(|e| e.into_inner()); + let used = total.get_or_insert_with(|| self.scan().iter().map(|f| f.1).sum()); + let old_len = std::fs::metadata(&p).map_or(0, |m| m.len()); let tmp = p.with_extension(format!("tmp{:?}", std::thread::current().id()).replace(['(', ')'], "")); if std::fs::write(&tmp, &bytes).is_err() || std::fs::rename(&tmp, &p).is_err() { let _ = std::fs::remove_file(&tmp); return; } self.writes.fetch_add(1, Ordering::Relaxed); - let over = { - let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner()); - let total = t.get_or_insert(0); - *total += bytes.len() as u64; - *total > self.budget() - }; + *used = used.saturating_sub(old_len).saturating_add(bytes.len() as u64); + let over = *used > self.budget(); + drop(total); if over { self.prune(); } diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs index 4dbd35661..9e9664685 100644 --- a/crates/preview/src/pool.rs +++ b/crates/preview/src/pool.rs @@ -30,6 +30,11 @@ struct Entry { struct Shared { queue: Mutex>>, + /// Slots with a job running: their next job waits until it ends, so a slot never has two at + /// once (a slider drag submits faster than renders finish; overlapping renders of one view + /// were mostly thrown away, and took the GPU from the one that is shown). Locked inside + /// `queue`. + busy: Mutex>, cv: Condvar, shutdown: AtomicBool, /// The worker threads that have not ended yet, and the signal that one did. @@ -56,6 +61,8 @@ pub struct JobPool { shared: Arc>, tx: Sender>, rx: Receiver>, + /// A finished job [`Self::wait`] took off the channel, handed out first by [`Self::try_recv`]. + early: Option>, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] threads: usize, started: bool, @@ -73,6 +80,7 @@ impl JobPool { JobPool { shared: Arc::new(Shared { queue: Mutex::new(Vec::new()), + busy: Mutex::new(Vec::new()), cv: Condvar::new(), shutdown: AtomicBool::new(false), #[cfg(not(target_arch = "wasm32"))] @@ -82,6 +90,7 @@ impl JobPool { }), tx, rx, + early: None, threads, started: false, seq: 0, @@ -119,6 +128,8 @@ impl JobPool { let _ended = Ended(&shared); while let Some(e) = take(&shared, true) { let t0 = std::time::Instant::now(); + // the slot is free again when the job ends, also if it panics + let _free = Free(&shared, e.slot); let result = (e.job)(); let done = Done { slot: e.slot, key: e.key, result, ms: t0.elapsed().as_secs_f64() * 1000.0 }; if tx.send(done).is_err() { @@ -210,8 +221,16 @@ impl JobPool { } /// A finished job, if any. - pub fn try_recv(&self) -> Option> { - self.rx.try_recv().ok() + pub fn try_recv(&mut self) -> Option> { + self.early.take().or_else(|| self.rx.try_recv().ok()) + } + + /// Wait up to `timeout` for a job to finish; whether one has (it is the next [`Self::try_recv`]). + pub fn wait(&mut self, timeout: Duration) -> bool { + if self.early.is_none() { + self.early = self.rx.recv_timeout(timeout).ok(); + } + self.early.is_some() } /// Run up to `n` jobs on the calling thread (wasm, tests). Returns how many ran. @@ -219,6 +238,7 @@ impl JobPool { let mut ran = 0; while ran < n { let Some(e) = take(&self.shared, false) else { break }; + let _free = Free(&self.shared, e.slot); let result = (e.job)(); let _ = self.tx.send(Done { slot: e.slot, key: e.key, result, ms: 0.0 }); ran += 1; @@ -229,14 +249,21 @@ impl JobPool { /// Pop the best job (highest priority, newest first). Blocks when `wait` until one is queued or /// the pool shuts down. -fn take(shared: &Shared, wait: bool) -> Option> { +fn take(shared: &Shared, wait: bool) -> Option> { let mut q = shared.queue.lock().unwrap_or_else(|e| e.into_inner()); loop { if shared.shutdown.load(Ordering::Relaxed) { return None; } - if let Some(i) = q.iter().enumerate().max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i) { - return Some(q.swap_remove(i)); + // (a slot with a job running waits for it: see `Shared::busy`) + let best = { + let busy = shared.busy.lock().unwrap_or_else(|e| e.into_inner()); + q.iter().enumerate().filter(|(_, e)| !busy.contains(&e.slot)).max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i) + }; + if let Some(i) = best { + let e = q.swap_remove(i); + shared.busy.lock().unwrap_or_else(|e| e.into_inner()).push(e.slot); + return Some(e); } if !wait { return None; @@ -245,6 +272,28 @@ fn take(shared: &Shared, wait: bool) -> Option> { } } +/// The job for `slot` ended: a job queued for it may start. +fn finished(shared: &Shared, slot: &S) { + { + // under the queue lock: a worker is either before its look at `busy` or waiting + let _q = shared.queue.lock().unwrap_or_else(|e| e.into_inner()); + let mut busy = shared.busy.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(i) = busy.iter().position(|s| s == slot) { + busy.swap_remove(i); + } + } + shared.cv.notify_all(); +} + +/// Frees a job's slot when the job ends (see [`finished`]). +struct Free<'a, S: PartialEq, R>(&'a Shared, S); + +impl Drop for Free<'_, S, R> { + fn drop(&mut self) { + finished(self.0, &self.1); + } +} + /// Takes a worker off the live list when its thread ends (also when a job panicked). #[cfg(not(target_arch = "wasm32"))] struct Ended<'a, S, R>(&'a Shared); diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index d733ad829..3179b91ec 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -123,6 +123,34 @@ fn disk_cache_roundtrip_and_prune() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() { + let dir = temp_dir("overwrite"); + let cache = DiskCache::new(&dir, 1 << 20); + let (a, b) = (hash_bytes(b"overwrite-a"), hash_bytes(b"overwrite-b")); + let first = gradient(80, 60, 4); + cache.put(a, &first); + cache.put(b, &gradient(80, 60, 7)); + let initial = cache.size(); + + // Re-rendering the same key used to double-count bytes although the + // replacement occupied exactly the same pathname on disk. + cache.put(a, &first); + assert_eq!(cache.size(), initial); + assert!(cache.get(b).is_some()); + + // A replacement with a different encoded size must also update the total. + cache.put(a, &gradient(120, 90, 9)); + let path = |key: Hash128| { + let hex = key.to_string(); + dir.join(&hex[..2]).join(format!("{hex}.jpg")) + }; + let actual = std::fs::metadata(path(a)).unwrap().len() + std::fs::metadata(path(b)).unwrap().len(); + assert_eq!(cache.size(), actual); + cache.clear(); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn preview_cache_memory_then_disk() { let dir = temp_dir("pc"); @@ -351,3 +379,92 @@ fn disk_cache_never_touches_foreign_files() { assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); let _ = std::fs::remove_dir_all(&dir); } + +/// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or +/// after the timeout. +#[test] +fn pool_wait_keeps_the_finished_job_for_try_recv() { + let mut p: JobPool = JobPool::new(1); + assert!(!p.wait(std::time::Duration::from_millis(10)), "nothing runs"); + p.submit(1, 0, 1, Box::new(|| 7)); + assert!(p.wait(std::time::Duration::from_secs(10))); + assert!(p.wait(std::time::Duration::ZERO), "kept until taken"); + assert_eq!(p.try_recv().map(|d| d.result), Some(7)); + assert!(p.try_recv().is_none()); +} + +/// Wait up to `secs` for `f` to hold. +fn eventually(secs: u64, mut f: impl FnMut() -> bool) -> bool { + let t0 = std::time::Instant::now(); + while t0.elapsed() < std::time::Duration::from_secs(secs) { + if f() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false +} + +/// A slider drag submits for a view faster than its renders finish. A newer job for a slot used +/// to start beside the running one on another worker: one view's renders piled up on the GPU and +/// most were thrown away. Now a slot runs one job at a time: the newer one waits (replacing an +/// older one still queued), other slots keep going, and it starts when the slot's job ends. +#[test] +fn a_slot_runs_one_job_at_a_time() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let mut p: JobPool = JobPool::new(4); + let (release, released) = std::sync::mpsc::channel::<()>(); + let (started, has_started) = std::sync::mpsc::channel(); + let running = Arc::new(AtomicUsize::new(0)); + let most = Arc::new(AtomicUsize::new(0)); + let job = |n: u32, wait: Option>| -> crate::pool::Job { + let (running, most, started) = (running.clone(), most.clone(), started.clone()); + Box::new(move || { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + most.fetch_max(now, Ordering::SeqCst); + let _ = started.send(n); + if let Some(w) = wait { + let _ = w.recv_timeout(std::time::Duration::from_secs(30)); + } + running.fetch_sub(1, Ordering::SeqCst); + n + }) + }; + p.submit(1, 1, 9, job(1, Some(released))); + assert_eq!(has_started.recv().unwrap(), 1); + // two newer requests for the busy slot: the second replaces the first, neither starts + p.submit(1, 2, 9, job(2, None)); + p.submit(1, 3, 9, job(3, None)); + // another slot is not held up + p.submit(2, 9, 1, Box::new(|| 9)); + let mut done = Vec::new(); + assert!(eventually(10, || { + done.extend(std::iter::from_fn(|| p.try_recv()).map(|d| d.result)); + done.contains(&9) + })); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!(has_started.try_recv().is_err(), "a second job of slot 1 started beside the first"); + assert!(p.is_queued(1)); + drop(release); + assert!(eventually(10, || { + done.extend(std::iter::from_fn(|| p.try_recv()).map(|d| d.result)); + done.contains(&3) + })); + assert!(!done.contains(&2), "the replaced job never runs"); + assert_eq!(most.load(Ordering::SeqCst), 1, "slot 1 ran two jobs at once"); +} + +/// A job that panics still frees its slot: the slot's next job runs (on another worker). +#[test] +fn a_panicking_job_frees_its_slot() { + let mut p: JobPool = JobPool::new(2); + p.submit(1, 1, 9, Box::new(|| panic!("simulated render panic"))); + assert!(eventually(10, || !p.is_queued(1) && p.live_workers() == 1)); + p.submit(1, 2, 9, Box::new(|| 2)); + let mut got = None; + assert!(eventually(10, || { + got = got.or_else(|| p.try_recv().map(|d| d.result)); + got.is_some() + })); + assert_eq!(got, Some(2)); +} diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index aaf50bc6e..3f17413ff 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -77,10 +77,10 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> } let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]); let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25; - gh[i] = est.clamp(l.min(r), l.max(r)); + gh[i] = super::clamp_between(est, l, r); let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]); let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25; - gv[i] = est.clamp(u.min(d), u.max(d)); + gv[i] = super::clamp_between(est, u, d); } } // 2. full candidates diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic/mod.rs index 1ab3675cb..c868dc6aa 100644 --- a/crates/raw/src/demosaic/mod.rs +++ b/crates/raw/src/demosaic/mod.rs @@ -52,6 +52,14 @@ pub(crate) fn reflect(i: isize, n: usize) -> usize { i as usize } +/// `v` clamped between `a` and `b`: `v.clamp(a.min(b), a.max(b))`, except that it returns `v` as is when both +/// bounds are NaN (NaN samples in a float raw), where `f32::clamp` panics. +#[inline] +pub(crate) fn clamp_between(v: f32, a: f32, b: f32) -> f32 { + let (lo, hi) = (a.min(b), a.max(b)); + if lo <= hi { v.clamp(lo, hi) } else { v } +} + impl Mosaic<'_> { #[inline] pub fn get(&self, x: isize, y: isize) -> f32 { @@ -222,6 +230,37 @@ mod tests { assert_eq!(demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.3]]); } + #[test] + fn non_finite_samples_do_not_panic() { + // regression: NaN samples (a float DNG) gave AHD and PPG NaN bounds for `f32::clamp`, which panics + let cfa = Cfa::bayer("RGGB").unwrap(); + let mut patchy = mosaic_from_rgb(&edgy_scene(24, 20), &cfa); + for (i, v) in patchy.data.iter_mut().enumerate() { + let (x, y) = (i % 24, i / 24); + if (8..12).contains(&x) && (6..10).contains(&y) { + *v = f32::NAN; + } else if i % 7 == 0 { + *v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY }; + } + } + let all_nan = Normalized { width: 9, height: 7, cpp: 1, data: vec![f32::NAN; 63], cfa: Some(cfa) }; + for n in [&patchy, &all_nan] { + for m in [Method::Bilinear, Method::Ppg, Method::Ahd] { + let out = demosaic(n, m); + assert_eq!((out.width, out.height), (n.width, n.height), "{m:?}"); + } + } + // without NaN bounds it is the `f32::clamp` it replaced, bit for bit (finite results stay identical) + let vals = [f32::NEG_INFINITY, -3e38, -2.5, -0.0, 0.0, 1e-40, 0.5, 1.0, 3e38, f32::INFINITY]; + for v in vals { + for a in vals { + for b in vals { + assert_eq!(clamp_between(v, a, b).to_bits(), v.clamp(a.min(b), a.max(b)).to_bits(), "{v} {a} {b}"); + } + } + } + } + #[test] fn reflect_indices() { assert_eq!(reflect(-1, 5), 1); diff --git a/crates/raw/src/demosaic/ppg.rs b/crates/raw/src/demosaic/ppg.rs index 837796154..9efd3a867 100644 --- a/crates/raw/src/demosaic/ppg.rs +++ b/crates/raw/src/demosaic/ppg.rs @@ -42,7 +42,7 @@ pub(crate) fn green_plane(m: &Mosaic) -> Vec { _ => (ge, gw, ce), }; let est = (near * 3.0 + far + c - cfar) * 0.25; - *o = est.clamp(near.min(far), near.max(far)); + *o = super::clamp_between(est, near, far); } }); g diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 27e30002f..3c7ece61b 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -121,6 +121,7 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd, order: ByteOrder) -> ColorData { // DNG 1.7 BaselineSharpness (IFD 0 or the enhanced IFD); implausible values are ignored baseline_sharpness: get(t::BASELINE_SHARPNESS).and_then(|v| v.first().copied()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 16.0), profile: profile_look(ifd0, raw, order), + kelvin_presets: None, } } @@ -234,6 +235,16 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result 0.0 { white.get(i % cpp).or(white.first()).copied().unwrap_or(default_white) } else { 0.0 }; + } + } + } let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default(); let (br, bc) = match dim.as_slice() { @@ -290,7 +301,9 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Result> { if let Some(cfa) = &raw.cfa { ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![cfa.height as u16, cfa.width as u16])); - ifd.set(t::CFA_PATTERN_EP, Value::Byte(cfa.pattern.clone())); + // the tag is anchored at the ActiveArea's top-left, `raw.cfa` at the sensor origin + let a = raw.active_area; + ifd.set(t::CFA_PATTERN_EP, Value::Byte(cfa.shifted(a.x, a.y).pattern)); ifd.set(t::CFA_PLANE_COLOR, Value::Byte(vec![0, 1, 2])); ifd.set(t::CFA_LAYOUT, Value::Short(vec![1])); } @@ -371,3 +373,64 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { } Ok(TiffWriter::new(opts.order, false).write(&[ifd])?) } + +/// Exif `LightSource` code of a white point the rendered DNG writer knows (D65 = 21, D50 = 23). +fn illuminant_of(white: lightcraft_color::Xy) -> u16 { + if (white.x - lightcraft_color::D50.x).abs() < 1e-3 && (white.y - lightcraft_color::D50.y).abs() < 1e-3 { 23 } else { 21 } +} + +/// Render to DNG: a linear DNG (`LinearRaw`, 16-bit float Deflate tiles) of an already rendered, +/// oriented image. `rgb` is `width × height` interleaved linear RGB in `space` (white = 1; values +/// above 1 are kept up to the half-float maximum, negative and non-finite ones become 0). The colour tags describe `space` +/// exactly — `ColorMatrix1` = XYZ → space, `ForwardMatrix1` = space → XYZ D50, a neutral +/// `AsShotNeutral` — and an identity `ProfileToneCurve` keeps raw converters from adding their +/// default tone curve on top of the baked-in edit. +pub fn write_rendered_dng( + width: usize, + height: usize, + rgb: &[f32], + space: &lightcraft_color::RgbSpace, + metadata: &lightcraft_meta::Metadata, + xmp: Option, +) -> Result> { + let n = width.checked_mul(height).and_then(|p| p.checked_mul(3)).ok_or(RawError::Limit("image too large"))?; + if width == 0 || height == 0 || width > u32::MAX as usize || height > u32::MAX as usize { + return Err(RawError::Corrupt(format!("cannot write a {width}×{height} DNG"))); + } + let src = rgb.get(..n).ok_or_else(|| RawError::Corrupt(format!("{} samples for a {width}×{height} RGB image", rgb.len())))?; + let data: Vec = src.iter().map(|&v| if v.is_finite() { v.clamp(0.0, 65504.0) } else { 0.0 }).collect(); + let full = crate::Rect::new(0, 0, width, height); + let forward = lightcraft_color::bradford(space.white, lightcraft_color::D50).mul(&space.to_xyz()); + let color = crate::ColorData { + illuminant: [illuminant_of(space.white), 0], + color_matrix: [Some(space.from_xyz()), None], + forward_matrix: [Some(forward), None], + as_shot_neutral: Some([1.0; 3]), + profile: crate::profile::ProfileLook { + tone_curve: Some(crate::profile::ToneCurve { points: vec![[0.0, 0.0], [1.0, 1.0]] }), + ..Default::default() + }, + ..Default::default() + }; + let raw = RawImage { + format: crate::RawFormat::Dng, + width, + height, + cpp: 3, + data: RawData::F32(data), + cfa: None, + bits: 16, + black: crate::BlackLevel::uniform(0.0), + white: vec![1.0], + active_area: full, + crop: full, + orientation: lightcraft_geom::Orientation::Normal, + color, + wb_multipliers: None, + linearized: false, + opcodes: crate::OpcodeLists::default(), + metadata: metadata.clone(), + }; + let order = ByteOrder::Little; + write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) +} diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs new file mode 100644 index 000000000..d4e6f33f3 --- /dev/null +++ b/crates/raw/src/kelvin_presets.rs @@ -0,0 +1,227 @@ +//! A Kelvin scale from the camera's own white-balance presets, for raws whose camera has no colour matrices. +//! +//! Sony ARWs from the DSLR-A200 on store, in their encrypted `SR2SubIFD` (see `vendor::sr2`), the multipliers the +//! camera would apply for each of its white-balance presets, among them five at a stated colour temperature: +//! `WB_RGBLevels2500K` (`0x782d`), `3200K` (`0x782c`), `4500K` (`0x7824`), `6000K` (`0x782b`) and `8500K` (`0x782a`; +//! tag numbers from `exiftool -v3`, run as a black-box tool on the files). They are Sony's own reading of its sensor: +//! the as-shot multipliers placed on the curve through them give the temperature Sony's Kelvin dial names for that +//! white. That is all a Kelvin slider needs; the look stays the file's JPEG fit, and the picture is adapted from the +//! as-shot white with Bradford, as for a camera read through measured matrices (`crate::spectral`, issue #730). +//! +//! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]): +//! - The five presets are joined piecewise linearly, with mired linear along each segment and the end segments +//! extended. On every Sony body measured, mired is nearly linear in arc length along the whole curve (2500 K to +//! 8500 K within ±3 %), so the segments add no visible kinks. +//! - A white off the curve is placed by its perpendicular offset from the nearest segment. Tint is that offset scaled +//! by the segment's own spacing of the Planckian locus in CIE 1960 (u, v), per unit arc length. +//! - The isotherms are not perpendicular to the curve in these coordinates: an offset also changes mired by +//! [`ISOTHERM_TILT`] times the mired the same distance along the curve would. +//! +//! The two constants come from the 14 Sony bodies that have both measured matrices and the presets (one CC0 +//! raw.pixls.us sample each for the presets, a grid of multipliers through `crate::spectral` for the matrices): +//! the tilt measured 0.59–0.84 (mean 0.74), the tint spacing 0.90–1.04 of the locus's (mean 0.98, taken as 1). +//! On 150 ILCE-7SM2 photos read both ways, the temperatures agree to ±4 mired around a constant offset (Sony's +//! Kelvin is about 8 % above the matrices', as #730 found for the ILCE-7M3's dial) and the tint to −9 ± 4: the +//! matrices put Sony's presets slightly off the locus, the presets themselves are tint 0 here. +use crate::ColorData; +use lightcraft_color::{Xy, cct}; +use serde::{Deserialize, Serialize}; + +/// Mired change per unit offset from the preset curve, in units of the mired change per unit distance along it. +pub const ISOTHERM_TILT: f64 = 0.74; + +/// The slider's range: an as-shot reading is kept inside it. +const MIN_K: f64 = 2000.0; +const MAX_K: f64 = 50000.0; +const MAX_TINT: f64 = 150.0; + +/// Models whose white balance is edited in Kelvin through their presets, checked to carry them (on 80–150 own +/// photos each, or the public corpus samples for the ILCE-3500 and ILCE-7S): the Exif model as Sony writes it. A +/// camera with measured matrices (`crate::spectral`) is read through those instead. +const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"]; + +/// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour +/// matrices of its own or measured: see [`crate::spectral::find`], which comes first). +pub fn covers(make: Option<&str>, model: &str) -> bool { + let model = model.trim(); + let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model); + make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY"))) + && MODELS.iter().any(|m| m.eq_ignore_ascii_case(model)) +} + +/// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). +pub fn covers_camera(camera: &str) -> bool { + match camera.split_once(char::is_whitespace) { + Some((make, model)) => covers(Some(make), model), + None => covers(None, camera), + } +} + +/// A camera's multipliers at five stated temperatures, ascending, green = 1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct KelvinPresets { + points: [(f32, [f32; 3]); 5], +} + +fn usable(m: [f32; 3]) -> bool { + m.iter().all(|v| *v > 0.0 && v.is_finite()) +} + +fn log_point(m: [f32; 3]) -> [f64; 2] { + [(m[0] as f64 / m[1] as f64).ln(), (m[2] as f64 / m[1] as f64).ln()] +} + +fn locus_uv(t: f64) -> (f64, f64) { + cct::xy_to_uv(cct::temp_tint_to_xy(t, 0.0)) +} + +impl KelvinPresets { + /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive + /// multipliers, red rising and blue falling from one preset to the next. + pub fn new(points: [(f32, [f32; 3]); 5]) -> Option { + let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) + && points.windows(2).all(|w| { + let (a, b) = (log_point(w[0].1), log_point(w[1].1)); + w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1] + }); + ok.then(|| KelvinPresets { points: points.map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])) }) + } + + /// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable + /// multipliers. + pub fn temp_tint(&self, wb_multipliers: [f32; 3]) -> Option<(f64, f64)> { + if !usable(wb_multipliers) { + return None; + } + let q = log_point(wb_multipliers); + let last = self.points.len() - 2; + let mut best: Option<(f64, f64, f64)> = None; + for (i, w) in self.points.windows(2).enumerate() { + let ((k0, m0), (k1, m1)) = (w[0], w[1]); + let (p0, p1) = (log_point(m0), log_point(m1)); + let d = [p1[0] - p0[0], p1[1] - p0[1]]; + let len = d[0].hypot(d[1]); + let t = [d[0] / len, d[1] / len]; + // the normal towards higher red and blue: a green white + let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] }; + let r = [q[0] - p0[0], q[1] - p0[1]]; + let along = (r[0] * t[0] + r[1] * t[1]) / len; + let off = r[0] * n[0] + r[1] * n[1]; + let lo = if i == 0 { f64::NEG_INFINITY } else { 0.0 }; + let hi = if i == last { f64::INFINITY } else { 1.0 }; + let c = along.clamp(lo, hi); + let dist = (r[0] - c * d[0]).hypot(r[1] - c * d[1]); + let (mired0, mired1) = (1e6 / k0 as f64, 1e6 / k1 as f64); + let mired_per_len = (mired1 - mired0) / len; + let ((u0, v0), (u1, v1)) = (locus_uv(k0 as f64), locus_uv(k1 as f64)); + let tint_per_len = cct::TINT_SCALE * (u1 - u0).hypot(v1 - v0) / len; + let mired = mired0 + along * (mired1 - mired0) + ISOTHERM_TILT * mired_per_len * off; + if best.is_none_or(|b| dist < b.0) { + best = Some((dist, mired, tint_per_len * off)); + } + } + let (_, mired, tint) = best?; + let t = 1e6 / mired.clamp(1e6 / MAX_K, 1e6 / MIN_K); + Some((t, tint.clamp(-MAX_TINT, MAX_TINT))) + } + + /// The as-shot white of a file through its presets: [`Self::temp_tint`] of its multipliers as a chromaticity. + pub fn as_shot_white(&self, wb_multipliers: Option<[f32; 3]>) -> Option { + let (t, tint) = self.temp_tint(wb_multipliers?)?; + Some(cct::temp_tint_to_xy(t, tint)) + } +} + +/// The as-shot white of a file through its own presets ([`ColorData::kelvin_presets`]); `None` without presets or +/// usable multipliers. +pub fn as_shot_white(color: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Option { + color.kelvin_presets.as_ref()?.as_shot_white(wb_multipliers) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The ILCA-99M2's presets (every file of the body carries the same). + fn a99m2() -> KelvinPresets { + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]) + .unwrap() + } + + #[test] + fn a_preset_reads_as_its_own_temperature_with_no_tint() { + let p = a99m2(); + for (k, m) in p.points { + let (t, tint) = p.temp_tint(m).unwrap(); + assert!((t - k as f64).abs() < 0.5 && tint.abs() < 1e-6, "{k}: {t} {tint}"); + // the scale of the multipliers doesn't matter, only their ratios to green + let (t2, tint2) = p.temp_tint(m.map(|v| v * 2.3)).unwrap(); + assert!((t2 - t).abs() < 1e-3 && (tint2 - tint).abs() < 1e-4, "{t2} {tint2}"); + } + } + + #[test] + fn between_presets_the_temperature_follows_the_curve() { + let p = a99m2(); + let mid = |a: [f32; 3], b: [f32; 3]| [(a[0] * b[0]).sqrt(), 1.0, (a[2] * b[2]).sqrt()]; + let (t, tint) = p.temp_tint(mid(p.points[2].1, p.points[3].1)).unwrap(); + assert!((4500.0..6000.0).contains(&t) && tint.abs() < 1.0, "{t} {tint}"); + // beyond the ends: extended, then held to the slider's range + let (hot, _) = p.temp_tint([3.5, 1.0, 1.15]).unwrap(); + let (cold, _) = p.temp_tint([1.15, 1.0, 4.0]).unwrap(); + let (coldest, _) = p.temp_tint([0.3, 1.0, 20.0]).unwrap(); + assert!((8500.0..MAX_K).contains(&hot) && (MIN_K..2500.0).contains(&cold) && coldest == MIN_K, "{hot} {cold} {coldest}"); + } + + #[test] + fn less_green_in_the_multipliers_is_a_green_white_and_a_magenta_correction() { + let p = a99m2(); + let day = [2680.0 / 1024.0, 1.0, 1544.0 / 1024.0]; + let (t0, _) = p.temp_tint(day).unwrap(); + // a green light: the camera raises red and blue against green + let (t1, tint1) = p.temp_tint([day[0] * 1.08, 1.0, day[2] * 1.08]).unwrap(); + assert!(tint1 > 15.0 && tint1 < 40.0, "{tint1}"); + // the tilted isotherm: a higher temperature than the curve point it sits beside + assert!(t1 > t0 + 100.0, "{t1} {t0}"); + let (_, magenta) = p.temp_tint([day[0] / 1.08, 1.0, day[2] / 1.08]).unwrap(); + assert!(magenta < -15.0, "{magenta}"); + } + + #[test] + fn unusable_presets_or_multipliers_give_nothing() { + let p = a99m2(); + assert_eq!(p.temp_tint([0.0, 1.0, 1.0]), None); + assert_eq!(p.temp_tint([f32::NAN, 1.0, 1.0]), None); + assert_eq!(p.as_shot_white(None), None); + let mut pts = p.points; + pts.swap(1, 2); + assert_eq!(KelvinPresets::new(pts), None, "out of order"); + let mut pts = p.points; + pts[4].1 = pts[3].1; + assert_eq!(KelvinPresets::new(pts), None, "no step"); + let mut pts = p.points; + pts[0].1[0] = 0.0; + assert_eq!(KelvinPresets::new(pts), None); + assert_eq!(as_shot_white(&ColorData::default(), Some([2.0, 1.0, 1.5])), None); + let with = ColorData { kelvin_presets: Some(p), ..Default::default() }; + assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some()); + } + + #[test] + fn covered_models() { + for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] { + assert!(covers_camera(c), "{c}"); + } + for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] { + assert!(!covers_camera(c), "{c}"); + } + assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2")); + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9edb..120ce98e9 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -34,6 +34,7 @@ pub mod gaintable; pub mod highlight; #[cfg(feature = "jxl")] mod jxl; +pub mod kelvin_presets; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; @@ -48,7 +49,7 @@ mod unpack; mod vendor; pub use demosaic::{Method, demosaic}; -pub use dngwrite::{DngCompression, DngWriteOptions, write_dng}; +pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; @@ -189,7 +190,10 @@ pub fn probe(bytes: &[u8]) -> Option { if make.starts_with("NIKON") && has_cfa { return Some(if t.all_ifds().len() > 1 { RawFormat::Nef } else { RawFormat::Nrw }); } - if make.starts_with("SONY") && sony_raw { + // Hasselblad's Sony-built bodies (the Lunar is a NEX-7 inside) write a Sony ARW under their own Make. Sony's + // raw coding gives them away: compression 32767 or the raw IFD's tone-curve tag 0x7010, which no 3FR / FFF has + let sony_coded = || t.all_ifds().iter().any(|i| i.u16(lightcraft_tiff::tags::COMPRESSION) == Some(32767) || i.contains(0x7010)); + if (make.starts_with("SONY") && sony_raw) || (make.starts_with("HASSELBLAD") && sony_coded()) { return Some(RawFormat::Arw); } if (make.starts_with("PENTAX") || make.starts_with("RICOH")) && pentax_raw { @@ -208,12 +212,23 @@ pub fn probe(bytes: &[u8]) -> Option { if dng::is_plain_cfa_tiff(&t, bytes) { return Some(RawFormat::CfaTiff); } - if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) { + if has_cfa || thumbnail_shell(&t, bytes.len()).is_some() || is_preview_container(ifd0) || is_leaf_mosaic(ifd0) { return Some(RawFormat::OtherTiff); } None } +/// Leaf's private data block in IFD0, tag `0x8606` (`LeafData` in the ExifTool EXIF tag-name documentation, which +/// says the Leaf tags "are found in .MOS images from Leaf digital camera backs"). +const LEAF_DATA: u16 = 0x8606; + +/// An uncompressed Leaf MOS: IFD0 is the 16-bit mosaic itself (compression 1, BlackIsZero, one sample per pixel), +/// with no Make tag and no CFA photometric, so only Leaf's data block marks it as a raw (issue #1275). A rendered +/// RGB image that kept the block stays an ordinary image. +fn is_leaf_mosaic(ifd0: &lightcraft_tiff::Ifd) -> bool { + ifd0.contains(LEAF_DATA) && ifd0.u16(lightcraft_tiff::tags::SAMPLES_PER_PIXEL).unwrap_or(1) == 1 +} + /// Whether the Exif maker note starts with the signature of the Pentax layouts (`AOC\0` or `PENTAX \0`). fn has_pentax_maker_note(t: &Tiff, bytes: &[u8]) -> bool { let Some(e) = t.exif().and_then(|e| e.get(lightcraft_tiff::tags::MAKER_NOTE)) else { return false }; @@ -598,6 +613,10 @@ pub struct ColorData { /// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`). #[serde(default)] pub profile: profile::ProfileLook, + /// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour + /// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it. + #[serde(default)] + pub kelvin_presets: Option, } /// A decoded raw image. @@ -1066,6 +1085,66 @@ mod tests { assert_eq!(probe(&write(&[ifd])), Some(RawFormat::OtherTiff)); } + /// Issue #1275: an uncompressed Leaf MOS (Aptus 22) is a full-size 16-bit grey IFD0 with no Make tag; only Leaf's + /// data block (`0x8606`) says it is a raw. It must not import as a greyscale image of its mosaic. The same IFD + /// without the block, or an RGB image carrying it, stays an ordinary image. + #[test] + fn uncompressed_leaf_mos_is_a_raw() { + let mosaic = |leaf_data: bool| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![12])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::BLACK_IS_ZERO])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x40; 16 * 12 * 2]] }); + if leaf_data { + ifd.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + } + ifd + }; + let bytes = TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[mosaic(true)]).unwrap(); + assert_eq!(probe(&bytes), Some(RawFormat::OtherTiff)); + assert!(matches!(decode(&bytes), Err(RawError::Unsupported(_)))); + assert!(matches!(probe_info(&bytes), Err(RawError::Unsupported(_)))); + assert_eq!(probe(&write(&[mosaic(false)])), None, "a 16-bit grey TIFF"); + let mut rgb = rgb_ifd(16, 12); + rgb.set(LEAF_DATA, Value::Undefined(b"PKTS\0\0\0\x01".to_vec())); + assert_eq!(probe(&write(&[rgb])), None, "a rendered RGB image that kept the block"); + } + + /// Issue #1275: a Hasselblad Lunar is a Sony NEX-7 ARW under Make HASSELBLAD. Sony's raw coding (compression 32767 + /// or the tone-curve tag `0x7010`) sends it to the ARW reader; a Hasselblad file without it does not go there. + #[test] + fn hasselblad_make_with_sony_raw_coding_is_arw() { + let file = |make: &str, compression: u16, tone_curve: bool| { + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![64])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![compression])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + if tone_curve { + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + } + raw.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![0; 128]] }); + let mut ifd0 = rgb_ifd(4, 3); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("Lunar".into())); + ifd0.add_sub_ifd(raw); + write(&[ifd0]) + }; + assert_eq!(probe(&file("HASSELBLAD", 32767, false)), Some(RawFormat::Arw)); + assert_eq!(probe(&file("Hasselblad", 1, true)), Some(RawFormat::Arw)); + let lunar = decode(&file("HASSELBLAD", 32767, true)).unwrap(); + assert_eq!((lunar.format, lunar.width, lunar.height, lunar.bits), (RawFormat::Arw, 64, 2, 14)); + // a Hasselblad CFA image in another coding is not an ARW + assert_ne!(probe(&file("HASSELBLAD", 1, false)), Some(RawFormat::Arw)); + assert_ne!(probe(&file("HASSELBLAD", 7, false)), Some(RawFormat::Arw)); + } + // --- TIFF raws that describe their raw IFD the way DNG does but carry no DNG version tag --- /// 8 x 6 samples, 16 bits, value `1000 + 100 y + x`. diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index e8c766004..59a9cf59e 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -62,6 +62,9 @@ const CROP_SIZE: u16 = 0x74c8; const SR2_OLD_BLACK: u16 = 0x7300; const SR2_OLD_WB: u16 = 0x7303; const SR2_OLD_CROP: u16 = 0x74c3; +/// `SR2SubIFD` white-balance presets at a stated temperature (`WB_RGBLevels2500K` … `8500K`: R, G, B), which give +/// the Kelvin scale of a camera without colour matrices (`crate::kelvin_presets`). +const SR2_KELVIN_PRESETS: [(f32, u16); 5] = [(2500.0, 0x782d), (3200.0, 0x782c), (4500.0, 0x7824), (6000.0, 0x782b), (8500.0, 0x782a)]; const YCBCR_COEFFICIENTS: u16 = 529; const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). @@ -199,9 +202,28 @@ const TAG2010_WB: &[(&[&str], usize)] = &[ ), ]; +/// Maker-note `SonyModelID` (`0xb001`, int16u, ExifTool Sony tag documentation). +const MN_SONY_MODEL_ID: u16 = 0xb001; + +/// `SonyModelID` values (ExifTool Sony tag documentation) of the Sony bodies Hasselblad sold under its own Make and +/// model (the Lunar, a NEX-7; the Stellar and Stellar II, an RX100 and RX100 II; the HV, an SLT-A99), so that +/// [`TAG2010_WB`] finds their layout. Measured on the CC0 Lunar (raw.pixls.us 4051): Make HASSELBLAD, Model Lunar, +/// SonyModelID 289 (issue #1275). +const SONY_MODEL_IDS: &[(u64, &str)] = &[(289, "NEX-7"), (294, "SLT-A99"), (297, "DSC-RX100"), (308, "DSC-RX100M2")]; + +/// The Sony body for the model-keyed tables: the Exif `model` of a Sony-branded file, else the one the maker note's +/// `SonyModelID` names (a Lunar says "Lunar"), else `model` as it is. +fn sony_body<'a>(make: &str, model: &'a str, mn: Option<&makernote::MakerNote>) -> &'a str { + if make.trim().to_ascii_uppercase().starts_with("SONY") { + return model; + } + let id = mn.and_then(|m| m.ifd.u64(MN_SONY_MODEL_ID)); + SONY_MODEL_IDS.iter().find(|(i, _)| Some(*i) == id).map_or(model, |(_, name)| name) +} + /// White balance from the maker note's enciphered `Tag2010` block, the fallback described at [`TAG2010_WB`]. -/// `model` is the Exif model; Sony appends a regional "V" to some names (SLT-A77V), which the documented lists -/// omit. +/// `model` is the Sony body ([`sony_body`]); Sony appends a regional "V" to some names (SLT-A77V), which the +/// documented lists omit. fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<[f32; 3]> { let model = model.trim(); let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?; @@ -478,6 +500,16 @@ fn sr2_old_wb(sr2: &super::sr2::SubIfd) -> Option<[f32; 3]> { } /// The DSC-R1's crop from `0x74c3` (x0, y0, x1, y1): an even origin (CFA phase) and a window inside the `w × h` frame. +/// The five presets of [`SR2_KELVIN_PRESETS`], when the block has them all and they make a curve. +fn sr2_kelvin_presets(sr2: &super::sr2::SubIfd) -> Option { + let mut points = [(0.0f32, [0.0f32; 3]); 5]; + for (p, (k, tag)) in points.iter_mut().zip(SR2_KELVIN_PRESETS) { + let v = sr2.shorts(tag).filter(|v| v.len() == 3)?; + *p = (k, [v[0] as f32, v[1] as f32, v[2] as f32]); + } + crate::kelvin_presets::KelvinPresets::new(points) +} + fn sr2_old_crop(sr2: &super::sr2::SubIfd, w: usize, h: usize) -> Option { let v = sr2.shorts(SR2_OLD_CROP).filter(|v| v.len() == 4)?; let [x0, y0, x1, y1] = [v[0] as usize, v[1] as usize, v[2] as usize, v[3] as usize]; @@ -697,11 +729,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .exif() .and_then(|e| e.get(t::MAKER_NOTE)) .and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default())); + let body = sony_body(&ifd0.string(t::MAKE).unwrap_or_default(), &model, mn.as_ref()); let wb = raw .f64s(WB_RGGB) .and_then(|v| rggb_gains(&v)) .or_else(|| rggb_gains(&sr2()?.shorts(WB_RGGB)?)) - .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))) + .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(body, m.ifd.bytes(MN_TAG2010)?, m.order))) .or_else(|| sr2_old_wb(sr2()?)); let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), @@ -728,7 +761,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: Rect::new(0, 0, w, h), crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + // (linear YCbCr comes white-balanced: its unit multipliers have no place on the presets' curve) + color: ColorData { kelvin_presets: if linear_rgb { None } else { sr2().and_then(sr2_kelvin_presets) }, ..Default::default() }, // Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, @@ -1177,6 +1211,73 @@ mod tests { file } + /// Issue #1275: a Hasselblad Lunar (Make HASSELBLAD, Model Lunar) is a NEX-7. Its maker note (header-less, as the + /// Lunar's is) names the body by `SonyModelID`, which picks the NEX-7's `Tag2010` layout for the fallback white + /// balance; without the ID, or with one that isn't listed, there is no layout to read. + #[test] + fn rebadged_body_takes_its_tag2010_layout_from_the_sony_model_id() { + use lightcraft_tiff::{ByteOrder::Little, IfdBuilder, ImageData, TiffWriter, Value}; + let file = |make: &str, model: &str, id: Option| { + let mut block = vec![0u8; 4600]; + for (i, v) in [639u16, 256, 368].iter().enumerate() { + block[4480 + 2 * i..4482 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let block = encipher(&block); + let note = |at: usize| -> Vec { + let entries = 1 + u16::from(id.is_some()); + let mut n = entries.to_le_bytes().to_vec(); + n.extend(MN_TAG2010.to_le_bytes()); + n.extend(7u16.to_le_bytes()); + n.extend((block.len() as u32).to_le_bytes()); + n.extend(((at + 2 + 12 * entries as usize + 4) as u32).to_le_bytes()); + if let Some(id) = id { + n.extend(MN_SONY_MODEL_ID.to_le_bytes()); + n.extend(3u16.to_le_bytes()); + n.extend(1u32.to_le_bytes()); + n.extend(id.to_le_bytes()); + n.extend([0, 0]); + } + n.extend([0; 4]); + n.extend(&block); + n + }; + let mut raw = IfdBuilder::new(); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; 32 * 4]] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set_child(t::EXIF_IFD, IfdBuilder::new().with(t::MAKER_NOTE, Value::Undefined(note(0)))); + ifd0.add_sub_ifd(raw); + let mut file = TiffWriter::new(Little, false).write(&[ifd0]).unwrap(); + let parsed = Tiff::parse(&file).unwrap(); + let e = parsed.exif().unwrap().get(t::MAKER_NOTE).unwrap(); + let at = e.offset as usize; + file[at..at + e.count()].copy_from_slice(¬e(at)); + file + }; + let wb = |make: &str, model: &str, id: Option| { + let bytes = file(make, model, id); + assert_eq!(crate::probe(&bytes), Some(RawFormat::Arw), "{make} {model}"); + let full = crate::decode(&bytes).unwrap(); + assert_eq!(crate::probe_info(&bytes).unwrap().wb_multipliers, full.wb_multipliers); + full.wb_multipliers + }; + let nex7 = Some([639.0 / 256.0, 1.0, 368.0 / 256.0]); + assert_eq!(wb("HASSELBLAD", "Lunar", Some(289)), nex7); + assert_eq!(wb("SONY", "NEX-7", None), nex7, "the Sony body itself"); + assert_eq!(wb("HASSELBLAD", "Lunar", None), None, "no SonyModelID"); + assert_eq!(wb("HASSELBLAD", "Lunar", Some(288)), None, "an ID that is not listed (NEX-5N)"); + // a Sony file keeps its Exif model whatever the ID says + assert_eq!(wb("SONY", "ILCE-1", Some(289)), None); + assert_eq!(sony_body("HASSELBLAD", "HV", None), "HV"); + } + #[test] fn dsc_r1_sr2_subifd_gives_black_wb_and_crop() { use lightcraft_tiff::ByteOrder::Little; diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 7f52888f4..74d93534d 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -23,7 +23,7 @@ mod sraw; -use super::{black_from_columns, white_from_data}; +use super::{active_rows, black_from_columns, white_from_data}; use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; @@ -37,6 +37,10 @@ const SRAW_YCC: u32 = 4; const CR2_CFA_PATTERN: u16 = 0xc5e0; const SENSOR_INFO: u16 = 0x00e0; const COLOR_BALANCE: u16 = 0x4001; +/// ShotInfo, whose value 7 is the white-balance setting. +const SHOT_INFO: u16 = 0x0004; +/// WBInfo, the PowerShot G9's preset levels (it writes no `ColorData`). +const WB_INFO: u16 = 0x0029; fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.ifds.get(3).or_else(|| tiff.ifds.iter().rev().find(|i| i.contains(CR2_SLICE))) @@ -65,23 +69,51 @@ fn words(v: &Value, order: ByteOrder) -> Vec { } } +/// WB multipliers (R, G, B; G = 1) from one set of levels, when they are plausible. +fn wb_from_levels(r: f32, g1: f32, g2: f32, b: f32) -> Option<[f32; 3]> { + if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 { + return None; + } + let g = (g1 + g2) / 2.0; + let (mr, mb) = (r / g, b / g); + ((0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb)).then_some([mr, 1.0, mb]) +} + /// As-shot WB multipliers (R, G, B; G = 1) from the ColorBalance array. fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { for off in [63usize, 25, 24, 34, 71, 85, 105, 69, 77] { let q = v.get(off..off + 4)?; - let (r, g1, g2, b) = (q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32); - if g1 < 256.0 || g2 < 256.0 || r < 64.0 || b < 64.0 || g1 > 16384.0 || (g1 - g2).abs() > 0.05 * g1 { - continue; - } - let g = (g1 + g2) / 2.0; - let (mr, mb) = (r / g, b / g); - if (0.25..6.0).contains(&mr) && (0.25..6.0).contains(&mb) { - return Some([mr, 1.0, mb]); + if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) { + return Some(wb); } } None } +/// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that +/// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in +/// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy, +/// 26 Tungsten, 34 Fluorescent, 50 Flash, 58 Underwater, 66 Custom 1, 74 Custom 2); other settings (14, Daylight +/// Fluorescent, has no documented row) give none. Measured on the CC0 file IMG_1188.CR2 (Custom 1, row 66: +/// 848 1747 1676 848), whose camera JPEG's neutral areas imply the same gains within 0.1 %. +fn wb_from_wb_info(m: &makernote::MakerNote) -> Option<[f32; 3]> { + let row = match m.ifd.u64s(SHOT_INFO)?.get(7)? { + 0 => 2, + 1 => 10, + 2 => 18, + 3 => 26, + 4 => 34, + 5 => 50, + 15 => 66, + 16 => 74, + 17 => 58, + _ => return None, + }; + let b = m.ifd.bytes(WB_INFO)?; + let level = |i: usize| Some(m.order.u32(b.get(4 * (row + i)..4 * (row + i + 1))?.try_into().ok()?) as i32 as f32); + wb_from_levels(level(1)?, level(0)?, level(3)?, level(2)?) +} + /// The optically black columns left of the image area: from column 2 up to two columns before the first column /// whose mean (over the image rows) rises above the leftmost columns' level by more than 1/256 of the remaining /// range (at least 16), else up to two columns before the image area. Empty when the border is too narrow. @@ -187,9 +219,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active = Rect::new(l, tp, r - l + 1, b - tp + 1); } } - let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v)); + let wb = mn + .as_ref() + .and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))) + .and_then(|v| wb_from_color_balance(&v)) + .or_else(|| wb_from_wb_info(mn.as_ref()?)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); - let white = white_from_data(&data, prec as u32); + let white = white_from_data(&active_rows(&data, width, active), prec as u32); let black = match masked_columns(&data, width, active, white) { cols if cols.len() >= 2 => black_from_columns(&data, width, cols, active.y..active.y + active.height, active), _ => BlackLevel::uniform(0.0), @@ -233,6 +269,11 @@ pub(crate) mod tests { /// Like [`synthetic_cr2`], with `note` as the Exif maker note (bytes as stored). fn synthetic_cr2_note(w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> (Vec, Vec) { let img: Vec = (0..w * h).map(|i| 1024 + ((i * 7919) % 12000) as u16).collect(); + (synthetic_cr2_of(&img, w, h, slices, note), img) + } + + /// A synthetic CR2 whose sensor samples are `img` (`w`×`h`, 14-bit), with `note` as the Exif maker note. + fn synthetic_cr2_of(img: &[u16], w: usize, h: usize, slices: Option<[u16; 3]>, note: Option>) -> Vec { let widths: Vec = match slices { Some([n, sw, last]) => { let mut v = vec![sw as usize; n as usize]; @@ -269,10 +310,9 @@ pub(crate) mod tests { } ifd3.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![enc] }); // (real files also carry a "CR\x02\0" signature at offset 8; probe recognises the structure without it) - let bytes = TiffWriter::new(ByteOrder::Little, false) + TiffWriter::new(ByteOrder::Little, false) .write(&[ifd0, IfdBuilder::new().with(1, Value::Short(vec![0])), IfdBuilder::new().with(1, Value::Short(vec![0])), ifd3]) - .unwrap(); - (bytes, img) + .unwrap() } #[test] @@ -352,13 +392,13 @@ pub(crate) mod tests { assert!(masked_columns(&[], w, active, 15000.0).is_empty()); } - /// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain - /// IFD whose value offset is relative to the TIFF header (patched once the note's position is known). - fn note_with_color_data(ty: u16, data: &[u8]) -> Vec { + /// A maker note holding only `tag` as `ty` (3 = SHORT, 7 = UNDEFINED) with `data`, a plain IFD whose value + /// offset is relative to the TIFF header (patched by [`patch_note`] once the note's position is known). + fn note_with(tag: u16, ty: u16, data: &[u8]) -> Vec { let count = if ty == 3 { data.len() / 2 } else { data.len() } as u32; let mut note = Vec::new(); note.extend_from_slice(&1u16.to_le_bytes()); - note.extend_from_slice(&COLOR_BALANCE.to_le_bytes()); + note.extend_from_slice(&tag.to_le_bytes()); note.extend_from_slice(&ty.to_le_bytes()); note.extend_from_slice(&count.to_le_bytes()); note.extend_from_slice(&0u32.to_le_bytes()); // value offset, patched below @@ -367,15 +407,81 @@ pub(crate) mod tests { note } - fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage { - let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with_color_data(ty, data))); - let tiff = Tiff::parse(&bytes).unwrap(); + fn patch_note(bytes: &mut [u8]) { + let tiff = Tiff::parse(bytes).unwrap(); let e = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap(); let off = e.offset as usize; bytes[off + 10..off + 14].copy_from_slice(&(e.offset as u32 + 18).to_le_bytes()); + } + + fn decode_with_color_data(ty: u16, data: &[u8]) -> RawImage { + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note_with(COLOR_BALANCE, ty, data))); + patch_note(&mut bytes); crate::decode(&bytes).unwrap() } + /// A maker note of `entries` (tag, TIFF type, value bytes), each value stored after the IFD, decoded; the value + /// offsets (relative to the TIFF header) are patched once the note's position is known. + fn decode_with_note(entries: &[(u16, u16, Vec)]) -> RawImage { + let ifd_len = 2 + 12 * entries.len() + 4; + let (mut note, mut data, mut at) = (Vec::new(), Vec::new(), Vec::new()); + note.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, ty, value) in entries { + let count = if *ty == 3 { value.len() / 2 } else { value.len() } as u32; + note.extend_from_slice(&tag.to_le_bytes()); + note.extend_from_slice(&ty.to_le_bytes()); + note.extend_from_slice(&count.to_le_bytes()); + at.push((note.len(), ifd_len + data.len())); + note.extend_from_slice(&0u32.to_le_bytes()); + data.extend_from_slice(value); + } + note.extend_from_slice(&0u32.to_le_bytes()); + note.extend_from_slice(&data); + let (mut bytes, _) = synthetic_cr2_note(64, 10, None, Some(note)); + let tiff = Tiff::parse(&bytes).unwrap(); + let off = tiff.exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for (field, value) in at { + bytes[off + field..off + field + 4].copy_from_slice(&((off + value) as u32).to_le_bytes()); + } + crate::decode(&bytes).unwrap() + } + + /// Issue #1273: the PowerShot G9 has no `ColorData`; its preset levels are WBInfo (`0x0029`: BYTE, int32 values, + /// four per preset in G R B G order), and ShotInfo (`0x0004`) value 7 says which preset was used (15 = Custom 1, + /// row 66: 848 1747 1676 848 on the CC0 file IMG_1188.CR2). + #[test] + fn white_balance_from_wb_info_when_there_is_no_color_data() { + let shot_info = |wb: u16| { + let mut v = [0u16; 34]; + v[7] = wb; + v.iter().flat_map(|x| x.to_le_bytes()).collect::>() + }; + let mut rows = vec![0i32; 512]; + rows[2..6].copy_from_slice(&[850, 1805, 1684, 850]); + rows[66..70].copy_from_slice(&[848, 1747, 1676, 848]); + rows[74..78].copy_from_slice(&[820, 1901, 4000, 820]); + rows[18..22].copy_from_slice(&[850, 1805, 6000, 850]); + let wb_info: Vec = rows.iter().flat_map(|x| x.to_le_bytes()).collect(); + let wb = |si: u16| decode_with_note(&[(0x0004, 3, shot_info(si)), (0x0029, 1, wb_info.clone())]).wb_multipliers; + assert_eq!(wb(15), Some([1747.0 / 848.0, 1.0, 1676.0 / 848.0])); + assert_eq!(wb(0), Some([1805.0 / 850.0, 1.0, 1684.0 / 850.0])); + // each preset its own row (Custom 2: row 74); an empty row (Daylight here), an implausible one (Cloudy: + // B 7.1) or a value without a documented row (14) gives none + assert_eq!(wb(16), Some([1901.0 / 820.0, 1.0, 4000.0 / 820.0])); + assert_eq!(wb(1), None); + assert_eq!(wb(2), None); + assert_eq!(wb(14), None); + // without ShotInfo, or with WBInfo too short for the row + assert_eq!(decode_with_note(&[(0x0029, 1, wb_info.clone())]).wb_multipliers, None); + assert_eq!(decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info[..4 * 68].to_vec())]).wb_multipliers, None); + // ColorData, when it gives levels, wins + let mut color_data = vec![0u8; 2 * 120]; + color_data[0..2].copy_from_slice(&[0xfd, 0xff]); + color_data[142..150].copy_from_slice(&[0xc4, 0x07, 0xe9, 0x02, 0xe9, 0x02, 0x69, 0x06]); + let both = decode_with_note(&[(0x0004, 3, shot_info(15)), (0x0029, 1, wb_info.clone()), (COLOR_BALANCE, 7, color_data)]); + assert_eq!(both.wb_multipliers, Some([1988.0 / 745.0, 1.0, 1641.0 / 745.0])); + } + /// `ColorData` stored as UNDEFINED bytes (versions -3 / -4: PowerShot, EOS M) gives the as-shot WB from word 71, /// exactly as the same words stored as SHORT do. #[test] @@ -388,4 +494,26 @@ pub(crate) mod tests { assert!((wb[0] - 1988.0 / 745.0).abs() < 1e-5 && wb[1] == 1.0 && (wb[2] - 1641.0 / 745.0).abs() < 1e-5, "type {ty}: {wb:?}"); } } + + /// Masked rows above the image full of samples at 16383 (EOS 50D, issue #1272): the white level is the image's + /// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0. + #[test] + fn white_from_active_area_only() { + let (w, h, top, left) = (64, 40, 4, 8); + let img: Vec = (0..w * h) + .map(|i| match (i % w, i / w) { + (_, y) if y < top => 16383, + (x, _) if x < left => 1024, + _ if i % 7 == 0 => 13433, + _ => 1024 + ((i * 7919) % 11000) as u16, + }) + .collect(); + // SensorInfo: width, height, three unused words, then the inclusive left/top/right/bottom borders + let si: Vec = [w, h, 0, 0, 0, left, top, w - 1, h - 1].iter().flat_map(|&v| (v as u16).to_le_bytes()).collect(); + let mut bytes = synthetic_cr2_of(&img, w, h, None, Some(note_with(SENSOR_INFO, 3, &si))); + patch_note(&mut bytes); + let r = crate::decode(&bytes).unwrap(); + assert_eq!(r.active_area, Rect::new(left, top, w - left, h - top)); + assert!((13300.0..=13433.0).contains(&r.white[0]), "{:?}", r.white); + } } diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 5c9f8ee9a..7c93e3bac 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -135,13 +135,15 @@ struct CameraLevels { /// Canon's versioned word arrays. Offsets are word indices from the published tag tables, not /// byte offsets. The four as-shot WB values are R, G, G, B, independent of the CFA layout. +/// A version without a white-level offset takes its white level from the data (see `docs/cr3.md`). fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { let Some(values) = maker.and_then(|m| m.u64s(COLOR_DATA)) else { return CameraLevels::default() }; let offsets = match values.first().copied() { - Some(16..=19) => (71, 329, 797), - Some(32 | 33) => (85, 343, 811), - Some(34 | 48) => (105, 363, 641), - Some(64 | 65) => (105, 383, 661), + Some(16..=19) => (71, 329, Some(797)), + Some(32 | 33) => (85, 343, Some(811)), + Some(34 | 48) => (105, 363, Some(641)), + Some(64 | 65) => (105, 383, Some(661)), + Some(66) => (105, 383, None), _ => return CameraLevels::default(), }; let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32; @@ -160,7 +162,7 @@ fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { }); let black = quadruple(offsets.1).filter(|v| v.iter().any(|&b| b > 0.0) && v.iter().all(|&b| b >= 0.0 && b < full * 0.5)); let max_black = black.map(|v| v.into_iter().fold(0.0f32, f32::max)).unwrap_or(0.0); - let white = values.get(offsets.2).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full); + let white = offsets.2.and_then(|at| values.get(at)).map(|&v| v as f32).filter(|&v| v > max_black && v >= full * 0.5 && v <= full); CameraLevels { wb, black, white } } @@ -526,6 +528,11 @@ mod tests { assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); assert_eq!(levels.white, Some(15000.0)); } + // A version without a white-level offset leaves the white level to the data. + let levels = camera_levels(Some(&maker(66, 105, 383, 661)), 14); + assert_eq!(levels.wb, Some([2.0, 1.0, 1.5])); + assert_eq!(levels.black, Some([100.0, 101.0, 102.0, 103.0])); + assert_eq!(levels.white, None); assert!(camera_levels(Some(&maker(255, 71, 329, 797)), 14).wb.is_none()); assert!(camera_levels(None, 14).black.is_none()); } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index b7bef1471..0d59fd6ed 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -63,6 +63,14 @@ pub(crate) fn black_from_columns(data: &[u16], width: usize, cols: Range, BlackLevel { repeat_rows: 2, repeat_cols: 2, values: (0..4).map(|i| (sum[i] / n[i] as f64) as f32).collect(), delta_h: vec![], delta_v: vec![] } } +/// Samples of up to 512 evenly spaced rows of the active area, for [`white_from_data`]: the borders can hold +/// their own saturated runs (0xffff junk on RW2, masked rows at 16383 on some Canon CR2s). The step is odd so both +/// row parities, and with them every CFA site, are read. +pub(crate) fn active_rows(data: &[u16], w: usize, a: Rect) -> Vec { + let step = (a.height / 512).max(1) | 1; + (a.y..a.y + a.height).step_by(step).filter_map(|y| data.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() +} + /// White level estimate: the saturation plateau if a noticeable number of samples sit at the maximum value, /// otherwise the full `bits` range. A few stray samples above the plateau (hot pixels, e.g. on a Canon EOS 70D /// CR2) don't hide it. @@ -135,6 +143,15 @@ mod tests { /// A Canon EOS 70D CR2 saturates at 13583 but has a handful of samples up to 14426 (out of ~20M): the plateau /// is still found instead of falling back to 16383 (≈0.3 EV too dark, highlights never seen as clipped). + /// A plateau on the blue sites alone (odd rows) is still found: an even row step would read one row parity only. + #[test] + fn active_rows_reads_both_row_parities() { + let n = 2048; + let d: Vec = (0..n * n).map(|i| if (i / n) % 2 == 1 && i % 2 == 1 { 15000 } else { (i % 10000) as u16 }).collect(); + let w = white_from_data(&active_rows(&d, n, Rect::new(0, 0, n, n)), 14); + assert!((14900.0..=15000.0).contains(&w), "{w}"); + } + #[test] fn white_plateau_with_strays_above() { let mut d: Vec = (0..100_000).map(|i| (2048 + i % 10000) as u16).collect(); diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index cacb516af..695d55712 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -58,7 +58,8 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { } /// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the -/// camera's full-size JPEG sits when the two are aligned) and the level where its channels clip. +/// camera's full-size JPEG sits when the two are aligned) and, when the data's own top would be wrong, the level +/// where its channels clip. struct MeasuredBody { model: &'static str, /// Sensor data size and sample depth of the measured files. @@ -66,7 +67,7 @@ struct MeasuredBody { bits: u32, /// Image area in sensor pixels: x, y, width, height. crop: (usize, usize, usize, usize), - white: f32, + white: Option, } /// * **NIKON D4** (148 uncompressed 14-bit NEFs from one body, 2023–2026, ISO 100–10000): the data is 4992 × 3292, @@ -74,8 +75,24 @@ struct MeasuredBody { /// 4928 × 3280 JPEG sits at (8, 6). Without this area the optical black shows as purple stripes on both sides. /// Green saturates at 15546–15672 while red and blue run on to 15687–16383, so the data's own top (~16300) leaves /// clipped highlights magenta; one level of 15520 for every channel turns them white and keeps the white balance. -const MEASURED_BODIES: &[MeasuredBody] = - &[MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }]; +/// * **NIKON D7100** (issue #1248; 14-bit NEFs from the public zjqc/photographing-art repository, with their +/// Lightroom Classic exports): the data holds picture, not masked lines, beyond the camera's frame. In full frame +/// it is 6036 × 4020 and the camera's JPEG is 6000 × 4000; in the 1.3x crop mode it is 4812 × 3212 and the JPEG +/// 4800 × 3200. Fitting renders of the full data to Lightroom's exports (feature matches, homography) puts +/// Lightroom's frame, the camera JPEG's size, at (18, 10) and (6, 6): within 0.5 px mean on DSC_1316, DSC_2763 +/// and DSC_1486. Without it, renders are larger than Lightroom's and its crops land up to 6 px off (18 px in +/// full frame). +/// * **NIKON D90** (550 compressed 12-bit NEFs from one body, 10 measured): the data is 4352 × 2868 and its last 42 +/// columns (from 4310) are filler at a flat ~254 whatever the exposure, above the dark image samples (black 0), so +/// the masked-column test keeps them and they develop as a pink band on the right. The camera's 4288 × 2848 JPEG +/// sits at (10, 10). Green saturates at 3840–3851 while red and blue run on to 4095; one level of 3830 keeps +/// clipped highlights white. +const MEASURED_BODIES: &[MeasuredBody] = &[ + MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) }, + MeasuredBody { model: "NIKON D7100", size: (6036, 4020), bits: 14, crop: (18, 10, 6000, 4000), white: None }, + MeasuredBody { model: "NIKON D7100", size: (4812, 3212), bits: 14, crop: (6, 6, 4800, 3200), white: None }, + MeasuredBody { model: "NIKON D90", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) }, +]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) @@ -164,8 +181,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let active_w = trailing_masked_columns(samples, w, bits); // the white level is measured from the whole image (or known for a measured body); the probe (no samples in its // result) takes the nominal one - let white = match (body, mode) { - (Some(b), _) => b.white, + let white = match (body.and_then(|b| b.white), mode) { + (Some(white), _) => white, (None, Mode::Full) => white_from_data(samples, bits), (None, Mode::Header) => ((1u32 << bits.clamp(1, 16)) - 1) as f32, }; @@ -398,6 +415,48 @@ mod tests { } } + /// Issue #1248: the D7100 records no CropArea, and its data holds picture beyond Lightroom's frame in both + /// image areas (full frame and 1.3x crop), so renders came out larger and Lightroom crops landed off. + #[test] + fn measured_d7100_image_area() { + let decode_as = |model: &str, (w, h): (usize, usize)| { + let mut nef = nef(1, 14, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32); + // the fixture's model is "NIKON TEST" plus its NUL: 11 bytes to overwrite + let at = nef.windows(11).position(|b| b == b"NIKON TEST\0").unwrap(); + let name = format!("{model:\0<11}"); + nef[at..at + 11].copy_from_slice(name.as_bytes()); + crate::decode(&nef).unwrap() + }; + for (size, crop) in [((6036, 4020), Rect::new(18, 10, 6000, 4000)), ((4812, 3212), Rect::new(6, 6, 4800, 3200))] { + let d7100 = decode_as("NIKON D7100", size); + assert_eq!(d7100.crop, crop); + assert_eq!((d7100.metadata.width, d7100.metadata.height), (Some(crop.width as u32), Some(crop.height as u32))); + assert_ne!(d7100.white, vec![15520.0]); + let developed = d7100.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (crop.width, crop.height)); + assert_eq!(decode_as("NIKON D7200", size).crop, Rect::new(0, 0, size.0, size.1)); + } + } + + /// The D90's last 42 columns are filler at a flat ~254, above its dark image samples: without the measured + /// area they stay in the image and develop as a pink band on the right. + #[test] + fn measured_d90_drops_the_filler_columns() { + let (w, h) = (4352usize, 2868usize); + let mut px = vec![40u16; w * h]; + for row in px.chunks_mut(w) { + row[4310..].fill(254); + } + let mut nef = nef(1, 12, vec![px.iter().flat_map(|v| v.to_le_bytes()).collect()], w as u32, h as u32, h as u32); + let at = nef.windows(10).position(|b| b == b"NIKON TEST").unwrap(); + nef[at..at + 10].copy_from_slice(b"NIKON D90 "); + let d90 = crate::decode(&nef).unwrap(); + assert_eq!(d90.crop, Rect::new(10, 10, 4288, 2848)); + assert!(d90.crop.x + d90.crop.width <= 4310, "the filler columns are outside the image"); + assert_eq!(d90.white, vec![3830.0]); + assert_eq!((d90.metadata.width, d90.metadata.height), (Some(4288), Some(2848))); + } + /// Issue #708: the active width is judged from the first [`MASK_ROWS`] rows in both modes, so the header probe /// (which reads only those rows' strips) and the full decode agree whether the masked columns are there or not. #[test] diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 6d77925a0..ea7f30eb7 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -54,6 +54,8 @@ const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; const MN_WB_RED: u16 = 0x1017; const MN_WB_BLUE: u16 = 0x1018; +const MN_BLACK: u16 = 0x1012; +const MN_VALID_BITS: u16 = 0x102c; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; /// The Olympus maker note. @@ -75,6 +77,17 @@ pub(crate) fn sub_ifd(bytes: &[u8], mn: &MakerNote, tag: u16) -> Option { lightcraft_tiff::parse_ifd_at(bytes, at, mn.order, mn.base, false, &opts).ok().map(|(i, _)| i) } +/// Black level of a body without ImageProcessing: the main note's `0x1012` BlackLevel (the mean of its four values) +/// in `0x102c` ValidBits units, scaled to the decoded `bits`. The E-10 states 32 at 10 bits, and its 12-bit samples +/// bottom out at 124 to 132; the two-field compacts state 0 at 12 bits. +fn maker_note_black(m: &MakerNote, bits: u32) -> Option { + let bits = bits.min(16); + let v = m.ifd.f64s(MN_BLACK).filter(|v| v.len() == 4)?; + let valid = m.ifd.u64(MN_VALID_BITS).and_then(|b| u32::try_from(b).ok()).filter(|b| (8..=bits).contains(b))?; + let level = v.iter().sum::() / 4.0 * f64::from(1u32 << (bits - valid)); + (level >= 0.0 && level < f64::from(1u32 << bits) / 4.0).then_some(level as f32) +} + /// Unpack one row of 12-bit samples stored as little-endian 32-bit words read MSB-first. pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { let swapped: Vec = src @@ -282,7 +295,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let values = a.pattern.iter().map(|&c| [v[0], (v[1] + v[2]) / 2.0, v[3]][c as usize] as f32).collect(); BlackLevel { repeat_rows: 2, repeat_cols: 2, values, ..Default::default() } } - _ => BlackLevel::uniform(0.0), + _ => BlackLevel::uniform(mn.as_ref().filter(|_| ip.is_none()).and_then(|m| maker_note_black(m, bits)).unwrap_or(0.0)), }; let wb = ip .as_ref() @@ -290,9 +303,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .filter(|v| v.len() >= 2 && v[0] > 0.0 && v[1] > 0.0) .map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32]) .or_else(|| { - // two-field bodies: the red and blue gains lead the entries of the main maker note - let m = mn.as_ref().filter(|_| two_field)?; - let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 0.0); + // the red and blue gains lead the main note's entries 0x1017/0x1018 on the two-field compacts and on + // the E-10 (16-bit words, no ImageProcessing; 384 and 406 on its CC0 file 5424) + let m = mn.as_ref().filter(|_| two_field || ip.is_none())?; + let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 25.6 && *g < 2560.0); Some([(g(MN_WB_RED)? / 256.0) as f32, 1.0, (g(MN_WB_BLUE)? / 256.0) as f32]) }); let white = white_from_data(samples, bits); @@ -344,6 +358,11 @@ mod tests { /// A minimal ORF with one strip in IFD0 and, optionally, an Exif `CFAPattern`. fn orf_with(w: u32, h: u32, bits: u16, strip: Vec, cfa_pattern: Option<&[u8]>) -> Vec { + orf_with_note(w, h, bits, strip, cfa_pattern, None) + } + + /// [`orf_with`] plus an optional maker note blob in the Exif IFD. + fn orf_with_note(w: u32, h: u32, bits: u16, strip: Vec, cfa_pattern: Option<&[u8]>, note: Option>) -> Vec { let mut ifd = IfdBuilder::new(); ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); @@ -352,9 +371,14 @@ mod tests { ifd.set(t::PHOTOMETRIC, Value::Short(vec![1])); ifd.set(t::MAKE, Value::Ascii("OLYMPUS IMAGING CORP.".into())); ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] }); - if let Some(p) = cfa_pattern { + if cfa_pattern.is_some() || note.is_some() { let mut exif = IfdBuilder::new(); - exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec())); + if let Some(p) = cfa_pattern { + exif.set(EXIF_CFA_PATTERN, Value::Undefined(p.to_vec())); + } + if let Some(n) = note { + exif.set(t::MAKER_NOTE, Value::Undefined(n)); + } ifd.set_child(t::EXIF_IFD, exif); } let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); @@ -363,6 +387,104 @@ mod tests { b } + /// An old-style little-endian Olympus maker note (`OLYMP\0`, version 1, then the IFD) of SHORT (type 3) or + /// UNDEFINED (type 7) entries. Values longer than four bytes follow the IFD; their offsets count from the TIFF + /// header, so `at` is where the note sits in the file. + fn olymp_note(at: usize, entries: &[(u16, u16, Vec)]) -> Vec { + let ifd_end = 8 + 2 + 12 * entries.len() + 4; + let (mut b, mut extra) = (b"OLYMP\0\x01\0".to_vec(), Vec::new()); + b.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, typ, value) in entries { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&typ.to_le_bytes()); + b.extend_from_slice(&(if *typ == 3 { value.len() / 2 } else { value.len() } as u32).to_le_bytes()); + if value.len() <= 4 { + let mut v = value.clone(); + v.resize(4, 0); + b.extend_from_slice(&v); + } else { + b.extend_from_slice(&((at + ifd_end + extra.len()) as u32).to_le_bytes()); + extra.extend_from_slice(value); + } + } + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&extra); + b + } + + /// SHORT values, little-endian. + fn shorts(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() + } + + /// A 16 × 4 ORF of 12-bit samples in 16-bit words with an [`olymp_note`] of `entries`, decoded. + fn decode_noted(entries: &[(u16, u16, Vec)]) -> RawImage { + let (w, h) = (16u32, 4u32); + let words: Vec = (0..w * h).flat_map(|i| (((200 + i * 37) % 4096) as u16).to_le_bytes()).collect(); + let file = |at| orf_with_note(w, h, 16, words.clone(), Some(RGGB), Some(olymp_note(at, entries))); + let at = file(0).windows(6).position(|b| b == b"OLYMP\0").unwrap(); + let bytes = file(at); + assert_eq!(bytes.windows(6).position(|b| b == b"OLYMP\0"), Some(at)); + crate::decode(&bytes).unwrap() + } + + /// An ImageProcessing sub-directory stored inline (old-style note) with one unrelated entry and no levels. + fn bare_image_processing() -> (u16, u16, Vec) { + let mut ifd = 1u16.to_le_bytes().to_vec(); + ifd.extend_from_slice(&[0, 0, 3, 0, 1, 0, 0, 0, 1, 0, 0, 0]); + ifd.extend_from_slice(&0u32.to_le_bytes()); + (IMAGE_PROCESSING, 7, ifd) + } + + /// Issue #1273: the E-10 (16-bit words, no ImageProcessing sub-directory) keeps its red and blue gains in + /// maker-note entries `0x1017`/`0x1018`, as the two-field compacts do. + #[test] + fn as_shot_white_balance_from_the_main_maker_note() { + let wb = |red: u16, blue: Option| { + let mut e = vec![(MN_WB_RED, 3, shorts(&[red, 64]))]; + e.extend(blue.map(|b| (MN_WB_BLUE, 3, shorts(&[b, 64])))); + decode_noted(&e).wb_multipliers + }; + assert_eq!(wb(384, Some(406)), Some([1.5, 1.0, 406.0 / 256.0])); + // both gains are needed, within 0.1 to 10 (25.6 to 2560 in 256 units) + assert_eq!(wb(384, None), None); + assert_eq!(wb(0, Some(406)), None); + assert_eq!(wb(25, Some(406)), None); + assert_eq!(wb(384, Some(2560)), None); + assert_eq!(wb(27, Some(2559)), Some([27.0 / 256.0, 1.0, 2559.0 / 256.0])); + assert_eq!(decode_noted(&[]).wb_multipliers, None); + // a body with ImageProcessing but without its levels doesn't fall back to these entries + let e = [bare_image_processing(), (MN_WB_RED, 3, shorts(&[384, 64])), (MN_WB_BLUE, 3, shorts(&[406, 64]))]; + assert_eq!(decode_noted(&e).wb_multipliers, None); + } + + /// Issue #1273: a body without ImageProcessing states its black level in the main note, `0x1012` (four + /// values) in `0x102c` ValidBits units: the E-10's 32 at 10 bits is 128 on its 12-bit samples. The two-field + /// compacts state 0 at 12 bits. + #[test] + fn black_level_from_the_main_maker_note() { + let black = |level: u16, valid: Option| { + let mut e = vec![(MN_BLACK, 3, shorts(&[level; 4]))]; + e.extend(valid.map(|v| (MN_VALID_BITS, 3, shorts(&[v, 0])))); + let r = decode_noted(&e); + assert_eq!(r.bits, 12); + r.black.values + }; + assert_eq!(black(32, Some(10)), vec![128.0]); + assert_eq!(black(0, Some(12)), vec![0.0]); + assert_eq!(black(32, Some(12)), vec![32.0]); + // the four values are averaged + let e = [(MN_BLACK, 3, shorts(&[30, 32, 32, 34])), (MN_VALID_BITS, 3, shorts(&[10, 0]))]; + assert_eq!(decode_noted(&e).black.values, vec![128.0]); + // no ValidBits, ValidBits above the samples' depth, a level of a quarter of the range or more: no black + assert_eq!(black(32, None), vec![0.0]); + assert_eq!(black(32, Some(14)), vec![0.0]); + assert_eq!(black(256, Some(10)), vec![0.0]); + // ImageProcessing present (without BlackLevel2): the main note isn't read + let e = [bare_image_processing(), (MN_BLACK, 3, shorts(&[32; 4])), (MN_VALID_BITS, 3, shorts(&[10, 0]))]; + assert_eq!(decode_noted(&e).black.values, vec![0.0]); + } + /// A 12-bit mosaic stored as 16-bit words: `site(cx, cy)` gives the four values of the 2×2 cell at (cx, cy). fn mosaic(w: usize, h: usize, site: impl Fn(usize, usize) -> [u16; 4]) -> Vec { (0..w * h).flat_map(|i| site(i % w / 2, i / w / 2)[((i / w) & 1) * 2 + ((i % w) & 1)].to_le_bytes()).collect() diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index d065fc4f5..abb928e68 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -167,25 +167,47 @@ pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits Ok(out) } -/// The image area without all-dark leading/trailing columns (masked or empty), kept at even offsets. -fn dark_trimmed(d: &[u16], w: usize, h: usize) -> Rect { - if w < 256 || h == 0 { +/// The image area without leading/trailing lines that hold no picture (masked or empty), kept at even offsets. With +/// the maker-note `black` level, a line is dark when its mean is at most 1/256 of the remaining range (at least 16) +/// above it, the tolerance `cr2::masked_columns` uses: masked lines sit at the black level plus noise, while dark +/// real content (a night sky) is well above. Without it, below 5% of the interior's mean. +fn dark_trimmed(d: &[u16], w: usize, h: usize, black: Option, white: f32) -> Rect { + if w < 256 || h < 256 { return Rect::new(0, 0, w, h); } - let step = (h / 256).max(1); - let col_mean = |x: usize| (0..h).step_by(step).map(|y| d[y * w + x] as f64).sum::() / h.div_ceil(step) as f64; + let (xstep, ystep) = (w / 256, h / 256); + let at = |x: usize, y: usize| d.get(y * w + x).map_or(0.0, |&v| f64::from(v)); + let col_mean = |x: usize| (0..h).step_by(ystep).map(|y| at(x, y)).sum::() / h.div_ceil(ystep) as f64; + let row_mean = |y: usize| (0..w).step_by(xstep).map(|x| at(x, y)).sum::() / w.div_ceil(xstep) as f64; let interior = (w / 4..w * 3 / 4).step_by(w / 64).map(col_mean).sum::() / 32.0; - let dark = interior * 0.05; - let mut left = 0; - while left < 64 && col_mean(left) < dark { - left += 1; + let (base, tol) = match black { + Some(b) => (b, ((f64::from(white) - b) / 256.0).max(16.0)), + None => (0.0, interior * 0.05), + }; + // a frame no brighter than masked lines (a lens-cap shot) has nothing to tell them from picture + if interior - base <= tol { + return Rect::new(0, 0, w, h); } - let mut right = w; - while right > w - 64 && col_mean(right - 1) < dark { - right -= 1; - } - let left = left.next_multiple_of(2); - Rect::new(left, 0, (right - left) & !1, h) + let range = f64::from(white) - base; + // lines from one edge that hold no picture: within `tol` of the outermost line's level when that sits a little + // above black (K-70, K-1: 65 over BlackPoint), and saturated lines when dark ones follow (645Z) + let run = |mean: &dyn Fn(usize) -> f64| { + let saturated = (0..64).take_while(|&k| mean(k) - base >= range * 0.98).count(); + let outer = mean(saturated) - base; + let level = if outer > 0.0 && outer < range * 0.01 { outer } else { 0.0 }; + match (saturated..64).take_while(|&k| mean(k) - base < level + tol).count() { + 0 => 0, + dark => saturated + dark, + } + }; + let edges = |n: usize, mean: &dyn Fn(usize) -> f64| { + let lo = run(mean).next_multiple_of(2); + let hi = n - run(&|k| mean(n - 1 - k)); + (lo, (hi - lo) & !1) + }; + let (left, width) = edges(w, &col_mean); + let (top, height) = edges(h, &row_mean); + Rect::new(left, top, width, height) } /// Whether every strip or tile of a one-sample-per-pixel image holds exactly the bytes that its rows need when @@ -249,9 +271,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float PEF".into())) }; + let white = white_from_data(samples, bits); + let black_point = mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).filter(|v| v.len() == 4); let active = match tagged { Some(r) => r, - None => dark_trimmed(samples, w, hgt), + None => dark_trimmed(samples, w, hgt, black_point.as_deref().map(|v| v.iter().sum::() / 4.0), white), }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, @@ -261,7 +285,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // tag keep BGGR. _ => cfa_from_exif(&tiff).unwrap_or_else(|| Cfa::bayer_static("BGGR")), }; - let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_POINT)).as_deref() { + let black = match black_point.as_deref() { Some(v @ [_, _, _, _]) => { // RGGB-ordered levels → per CFA position at the active area origin let a = cfa.shifted(active.x, active.y); @@ -275,7 +299,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let g = (v[1] + v[2]) / 2.0; [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] }); - let white = white_from_data(samples, bits); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(active.width as u32); metadata.height = Some(active.height as u32); @@ -488,4 +511,126 @@ mod tests { let img = decode(&tiny_pef(4, 2, 1, 16, &[0; 16]), Mode::Full).unwrap(); assert_eq!(img.cfa, Some(Cfa::bayer_static("BGGR"))); } + + /// A `w x h` big-endian 14-bit PEF of plain 16-bit words without area tags, whose maker note has only a + /// BlackPoint of `black`, with `px(x, y)` as the samples. + fn pef_with_black_point(w: usize, h: usize, black: u16, px: impl Fn(usize, usize) -> u16) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let strip: Vec = (0..w * h).flat_map(|i| px(i % w, i / w).to_be_bytes()).collect(); + let build = |levels_at: u32| { + let mut ifd = IfdBuilder::new(); + ifd.set(0x0100, Value::Long(vec![w as u32])); + ifd.set(0x0101, Value::Long(vec![h as u32])); + ifd.set(0x0102, Value::Short(vec![14])); + ifd.set(0x0103, Value::Short(vec![1])); + ifd.set(0x0106, Value::Short(vec![32803])); + ifd.set(0x010f, Value::Ascii("PENTAX Corporation".into())); + ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![strip.clone()] }); + // AOC\0 + "MM", one entry (BlackPoint, four shorts stored after the IFD), next-IFD 0, the four levels + let mut note = b"AOC\0MM\0\x01".to_vec(); + note.extend_from_slice(&[0x02, 0x00, 0, 3, 0, 0, 0, 4]); + note.extend_from_slice(&levels_at.to_be_bytes()); + note.extend_from_slice(&0u32.to_be_bytes()); + (0..4).for_each(|_| note.extend_from_slice(&black.to_be_bytes())); + let mut exif = IfdBuilder::new(); + exif.set(0x927c, Value::Undefined(note)); + ifd.set_child(0x8769, exif); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd]).unwrap() + }; + let at = Tiff::parse(&build(0)).unwrap().exif().unwrap().get(0x927c).unwrap().offset; + build((at + 6 + 2 + 12 + 4) as u32) + } + + /// The K-3 Mark III / KP / KF layout (issue #1271): masked columns and rows at the black level, an empty band + /// at zero, around an interior less than 20 times as bright as the black level. + #[test] + fn masked_rows_and_columns_at_the_black_level_are_trimmed_without_crop_tags() { + let (w, h) = (512, 384); + let file = pef_with_black_point(w, h, 256, |x, y| { + if x >= w - 8 { + 0 + } else if y >= h - 14 { + 15 + } else if x < 24 || x >= w - 16 || y < 28 { + 256 + } else { + 1256 + ((x * 7 + y * 13) % 64) as u16 + } + }); + let img = decode(&file, Mode::Full).unwrap(); + assert_eq!(img.black.values, vec![256.0; 4]); + assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); + } + + /// A night frame (bright centre, dark sky): 20 lines of real content 100 DN above the black level inside the + /// masked border stay in the picture, whatever the interior's brightness. + #[test] + fn dark_real_content_next_to_the_masked_border_is_kept() { + let (w, h) = (512, 384); + let file = pef_with_black_point(w, h, 256, |x, y| { + if x >= w - 8 { + 0 + } else if y >= h - 14 { + 15 + } else if x < 24 || x >= w - 16 || y < 28 { + 256 + ((x + y) % 3) as u16 + } else if x < 44 || x >= w - 36 || y < 48 || y >= h - 34 { + 356 + ((x * 7 + y * 13) % 16) as u16 + } else { + 3256 + ((x * 7 + y * 13) % 64) as u16 + } + }); + let img = decode(&file, Mode::Full).unwrap(); + assert_eq!(img.active_area, Rect::new(24, 28, w - 40, h - 42)); + } + + /// The K-70 (and K-1) layout: masked lines 65 DN over BlackPoint 64, just above the tolerance over black, and + /// empty rows at the bottom. + #[test] + fn masked_lines_a_little_above_the_black_level_are_trimmed() { + let (w, h) = (320, 288); + let file = pef_with_black_point(w, h, 64, |x, y| { + if y >= h - 12 { + 0 + } else if x < 56 || y < 28 { + 128 + ((x + y) % 3) as u16 + } else { + 2064 + ((x * 7 + y * 13) % 64) as u16 + } + }); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(56, 28, 264, 248)); + } + + /// The 645Z layout: 12 saturated columns outside 46 masked ones on the right; they hold no picture either. + #[test] + fn saturated_lines_outside_masked_ones_are_trimmed() { + let (w, h) = (400, 288); + let file = pef_with_black_point(w, h, 64, |x, y| { + if x >= w - 12 { + 16316 + } else if x >= w - 58 { + 64 + ((x + y) % 3) as u16 + } else { + 2064 + ((x * 7 + y * 13) % 64) as u16 + } + }); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w - 58, h)); + } + + /// A blown-out edge with the picture right behind it is picture: nothing is trimmed. + #[test] + fn a_saturated_edge_followed_by_picture_stays() { + let (w, h) = (400, 288); + let file = pef_with_black_point(w, h, 64, |x, y| if x >= w - 12 { 16316 } else { 2064 + ((x * 7 + y * 13) % 64) as u16 }); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h)); + } + + /// A frame at the black level everywhere (a lens-cap shot) has no picture to tell masked lines from: it keeps + /// its whole area. + #[test] + fn a_frame_at_the_black_level_keeps_its_whole_area() { + let (w, h) = (320, 288); + let file = pef_with_black_point(w, h, 64, |x, y| 64 + ((x * 7 + y * 13) % 5) as u16); + assert_eq!(decode(&file, Mode::Full).unwrap().active_area, Rect::new(0, 0, w, h)); + } } diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 0a64c9e44..b856fbd52 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -99,7 +99,7 @@ //! 1 and 3, and bit depths other than the ones above (no samples of any of them). use super::pef::{Bits, Huffman, diff}; -use super::white_from_data; +use super::{active_rows, white_from_data}; use crate::{BlackLevel, Cfa, ColorData, MAX_SAMPLES, Mode, Opcode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; use lightcraft_geom::Orientation; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, tags as t}; @@ -497,12 +497,6 @@ fn fill_defects(data: &mut [u16], w: usize, area: Rect) { } } -/// Samples of up to 512 evenly spaced rows of the active area (the borders hold junk such as 0xffff runs). -fn active_rows(data: &[u16], w: usize, a: Rect) -> Vec { - let step = (a.height / 512).max(1); - (a.y..a.y + a.height).step_by(step).filter_map(|y| data.get(y * w + a.x..y * w + a.x + a.width)).flatten().copied().collect() -} - fn cfa(code: u16) -> Cfa { Cfa::bayer_static(match code { 2 => "GRBG", diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 14daaaf99..8d3f81839 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -417,6 +417,43 @@ fn floating_point_dngs() { assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::F32(vals)); } +#[test] +fn non_finite_float_samples_decode_and_develop() { + // regression: NaN samples in a float CFA DNG panicked AHD and PPG (`f32::clamp` with NaN bounds) + let (w, h) = (24, 20); + let mut raw = synthetic(w, h, Some(Cfa::bayer("RGGB").unwrap()), 1); + let mut vals: Vec = (0..w * h).map(|i| ((i * 37) % 200) as f32 / 256.0).collect(); + for (i, v) in vals.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + if (8..14).contains(&x) && (6..12).contains(&y) { + *v = f32::NAN; + } else if i % 11 == 0 { + *v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY }; + } + } + raw.data = RawData::F32(vals.clone()); + raw.black = BlackLevel::uniform(0.0); + raw.white = vec![0.75]; + for compression in [DngCompression::Uncompressed, DngCompression::Deflate { tile: 16, half: false }] { + let bytes = write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).unwrap(); + let img = decode(&bytes).unwrap(); + // NaN and −∞ read as black, +∞ as the white level; finite samples are kept bit-exactly + let RawData::F32(got) = &img.data else { panic!("float data expected") }; + for (a, b) in vals.iter().zip(got) { + let want = match *a { + f32::INFINITY => 0.75, + v if v.is_finite() => v, + _ => 0.0, + }; + assert_eq!(want.to_bits(), b.to_bits(), "{a} read as {b}"); + } + for m in [Method::Bilinear, Method::Ppg, Method::Ahd] { + let rgb = img.develop(m).unwrap(); + assert!(rgb.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{m:?}"); + } + } +} + #[test] fn lj92_strips_and_levels_and_linearization() { let (w, h) = (20, 12); @@ -1028,3 +1065,24 @@ fn baseline_sharpness_is_read_when_plausible() { assert_eq!(decode(&bytes).unwrap().color.baseline_sharpness, None); assert_eq!(decode(&profile_dng(ByteOrder::Big, |_| {})).unwrap().color.baseline_sharpness, None); } + +/// The DNG CFAPattern is anchored at the top-left of the ActiveArea: a DNG whose area starts on an odd row or +/// column still demosaics with the pattern the tag states (#1086), and the writer states it back the same way. +#[test] +fn cfa_pattern_is_relative_to_the_active_area() { + let (w, h) = (20, 12); + for (top, left, sensor_name) in [(0u32, 0u32, "GRBG"), (1, 0, "BGGR"), (0, 1, "RGGB"), (1, 1, "GBRG"), (3, 5, "GBRG"), (2, 4, "GRBG")] { + let mut raw = base_ifd(w, h, 16, 1, true); + raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&pattern(w, h, 1, 16), ByteOrder::Little)] }); + raw.set(t::WHITE_LEVEL, Value::Short(vec![65535])); + raw.set(t::ACTIVE_AREA, Value::Long(vec![top, left, h as u32 - 1, w as u32 - 1])); + let img = decode(&dng(raw, ByteOrder::Little)).unwrap(); + assert_eq!(img.cfa.as_ref().unwrap().name(), sensor_name, "sensor anchor for area at ({left}, {top})"); + assert_eq!(img.normalized().unwrap().cfa.unwrap().name(), "GRBG", "tag pattern for area at ({left}, {top})"); + let back = + decode(&write_dng(&img, &DngWriteOptions { compression: DngCompression::Uncompressed, order: ByteOrder::Little, xmp: None }).unwrap()) + .unwrap(); + assert_eq!(back.cfa.as_ref().unwrap().name(), sensor_name); + assert_eq!(back.normalized().unwrap().cfa.unwrap().name(), "GRBG"); + } +} diff --git a/crates/raw/tests/rendered_dng.rs b/crates/raw/tests/rendered_dng.rs new file mode 100644 index 000000000..fad006fe4 --- /dev/null +++ b/crates/raw/tests/rendered_dng.rs @@ -0,0 +1,50 @@ +//! Render to DNG: a linear DNG of a rendered image reads back as the same pixels and colours. + +use lightcraft_color::{Mat3, PROPHOTO, REC2020, SRGB}; +use lightcraft_raw::{Method, color, write_rendered_dng}; + +fn image(w: usize, h: usize) -> Vec { + (0..w * h).flat_map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.25 + (i % 7) as f32 * 0.25]).collect() +} + +fn close(a: &Mat3, b: &Mat3, tol: f64) -> bool { + a.0.iter().flatten().zip(b.0.iter().flatten()).all(|(x, y)| (x - y).abs() < tol) +} + +#[test] +fn rendered_dng_round_trips_pixels_and_colour() { + let (w, h) = (300usize, 70usize); + let rgb = image(w, h); + for space in [SRGB, PROPHOTO] { + let meta = lightcraft_meta::Metadata { make: Some("Maker".into()), model: Some("Model".into()), ..Default::default() }; + let bytes = write_rendered_dng(w, h, &rgb, &space, &meta, None).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp, raw.cfa.is_none()), (w, h, 3, true)); + assert_eq!(raw.metadata.model.as_deref(), Some("Model")); + let dev = raw.develop(Method::Ahd).unwrap(); + for (p, q) in rgb.as_chunks::<3>().0.iter().zip(&dev.data) { + for k in 0..3 { + // above-white samples (1.25, 1.5, 1.75) are clipped by white level 1 on develop + let want = p[k].min(1.0); + assert!((q[k].min(1.0) - want).abs() <= want * 1e-3 + 1e-4, "{space:?} {p:?} {q:?}"); + } + } + // the camera transform is the space → Rec. 2020 matrix under a neutral white + let t = color::camera_transform(&raw, color::as_shot_white_xy(&raw)); + assert!(!t.matrix_is_fallback); + assert!(t.wb.iter().all(|v| (v - 1.0).abs() < 1e-3), "{:?}", t.wb); + assert!(close(&t.matrix, &space.to_space(&REC2020), 2e-3), "{}: {:?} vs {:?}", space.name, t.matrix, space.to_space(&REC2020)); + } +} + +#[test] +fn rendered_dng_rejects_bad_input_without_panicking() { + let m = lightcraft_meta::Metadata::default(); + assert!(write_rendered_dng(0, 4, &[], &SRGB, &m, None).is_err()); + assert!(write_rendered_dng(4, 4, &[0.5; 10], &SRGB, &m, None).is_err()); + assert!(write_rendered_dng(usize::MAX, 3, &[0.5; 10], &SRGB, &m, None).is_err()); + let odd = [f32::NAN, f32::INFINITY, -2.0, 70000.0, 0.5, 1.0]; + let raw = lightcraft_raw::decode(&write_rendered_dng(2, 1, &odd, &SRGB, &m, None).unwrap()).unwrap(); + let dev = raw.develop(Method::Bilinear).unwrap(); + assert!(dev.data.iter().flatten().all(|v| v.is_finite() && *v >= 0.0)); +} diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs index 3ab735299..15e6398b2 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/sparse_corpus.rs @@ -64,45 +64,71 @@ fn partial_reads_describe_raws_as_whole_files_do() { paths.sort(); // format → (files ready, bytes read, bytes total, files read whole) let mut stats: BTreeMap = BTreeMap::new(); + let mut preview_stats: BTreeMap = BTreeMap::new(); for p in paths { let name = p.file_name().unwrap().to_string_lossy().to_string(); let file = std::fs::read(&p).unwrap(); let format = probe(&file).map_or("?".to_string(), |f| format!("{f:?}")); - let (plan, zeros, loaded) = run(&file, 0, false); + let whole = describe(&file); + let preview = embedded_preview(&file); let entry = stats.entry(format.clone()).or_default(); - match plan { - HeaderPlan::Ready => { + let header_ready = match run(&file, 0, false) { + (HeaderPlan::Ready, zeros, loaded) => { let (plan_ff, ones, loaded_ff) = run(&file, 0xff, false); assert_eq!((plan_ff, &loaded_ff), (HeaderPlan::Ready, &loaded), "{name}: the plan read skipped bytes"); - let whole = describe(&file); assert_eq!(describe(&zeros), whole, "{name}: differs from the whole file (skipped bytes 0)"); assert_eq!(describe(&ones), whole, "{name}: differs from the whole file (skipped bytes 0xFF)"); entry.0 += 1; entry.1 += loaded.total(); entry.2 += file.len() as u64; - // with the preview (for a raw shown from it), the preview is what the whole file gives too - let preview = embedded_preview(&file); - for fill in [0, 0xff] { - let (plan, buf, _) = run(&file, fill, true); - assert_eq!(plan, HeaderPlan::Ready, "{name}: preview plan"); - assert_eq!(describe(&buf), whole, "{name}: preview plan, skipped bytes {fill}"); - assert!(embedded_preview(&buf) == preview, "{name}: a different embedded preview (skipped bytes {fill})"); - } if loaded.total() * 4 > file.len() as u64 { eprintln!("{name}: read {:.0} %", loaded.total() as f64 * 100.0 / file.len() as f64); } if matches!(format.as_str(), "Cr3" | "Dng") && file.len() > 8 << 20 { assert!(loaded.total() * 4 < file.len() as u64, "{name}: read {} of {} bytes", loaded.total(), file.len()); } + true } - HeaderPlan::Whole(why) => { + (HeaderPlan::Whole(why), _, _) => { entry.3 += 1; eprintln!("{name}: whole ({why})"); + false } - HeaderPlan::Need(_) => unreachable!(), + (HeaderPlan::Need(_), _, _) => unreachable!(), + }; + // The preview plan (what the display path reads) is asked for **every** file, because a + // header probe that reads samples (a NEF, an ORF, a PEF) makes the plan above `Whole` and + // would otherwise hide its preview from this check. Ready means the preview handed on is the + // whole file's, byte for byte, with the skipped bytes zeroed and again 0xFF; `Whole` is a + // legitimate answer here (that format keeps reading whole for a preview too) and is counted. + let pentry = preview_stats.entry(format.clone()).or_default(); + match run(&file, 0, true) { + (HeaderPlan::Ready, buf, loaded_p) => { + if header_ready { + assert_eq!(describe(&buf), whole, "{name}: preview plan differs from the whole file"); + } + assert!(embedded_preview(&buf) == preview, "{name}: a different embedded preview"); + let (plan_ff, buf_ff, loaded_ff) = run(&file, 0xff, true); + assert_eq!((plan_ff, loaded_ff.total()), (HeaderPlan::Ready, loaded_p.total()), "{name}: the preview plan read skipped bytes"); + assert!(embedded_preview(&buf_ff) == preview, "{name}: a different embedded preview (skipped bytes 0xFF)"); + pentry.0 += 1; + pentry.1 += loaded_p.total(); + pentry.2 += file.len() as u64; + } + (HeaderPlan::Whole(why), _, _) => { + pentry.3 += 1; + eprintln!("{name}: preview whole ({why})"); + } + (HeaderPlan::Need(_), _, _) => unreachable!(), } } for (format, (ready, read, total, whole)) in stats { eprintln!("{format:>10}: {ready} described from {:.1} % of their bytes, {whole} read whole", read as f64 * 100.0 / total.max(1) as f64); } + for (format, (ready, read, total, whole)) in preview_stats { + eprintln!( + "{format:>10} preview: {ready} previews read from {:.1} % of their bytes, {whole} read whole", + read as f64 * 100.0 / total.max(1) as f64 + ); + } } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..dff2ddb75 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "{name} opened in the other editor": "{name} im anderen Editor geöffnet", + "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", + "· {in_source} identical copies": "· {in_source} identische Kopien" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..25c411ce0 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -703,6 +703,7 @@ "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", "In use": "In Verwendung", "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", + "Include selected photos": "Ausgewählte Fotos einschließen", "Include subfolders": "Unterordner einbeziehen", "Increase Brush Feather": "Weiche Pinselkante vergrößern", "Increase Brush Size": "Pinselgröße vergrößern", @@ -803,6 +804,7 @@ "Local folders": "Lokale Ordner", "Local library — no cloud account needed": "Lokale Bibliothek — kein Cloud-Konto erforderlich", "Locate Missing File…": "Fehlende Datei suchen…", + "Locate Missing Files…": "Fehlende Dateien suchen…", "Locate Photo": "Foto suchen", "Location": "Ort", "Lock": "Sperren", @@ -1380,6 +1382,8 @@ "Show Luminance Map": "Luminanzkarte anzeigen", "Show Mask": "Maske anzeigen", "Show Mask Overlay": "Maskenüberlagerung anzeigen", + "Show All Mask Overlays": "Alle Maskenüberlagerungen anzeigen", + "Show all overlays": "Alle Überlagerungen anzeigen", "Show Mask Pins": "Maskenpins anzeigen", "Show Original": "Original anzeigen", "Show Photo Counts": "Fotoanzahl anzeigen", @@ -1813,6 +1817,8 @@ "Install the SAM 3 Model": "SAM-3-Modell installieren", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.", "Open Log Folder": "Protokollordner öffnen", + "Reading photos…": "Fotos werden gelesen…", + "Adding photos…": "Fotos werden hinzugefügt…", "Back to this person": "Back to this person", "AI Denoise": "AI Denoise", "AI Denoise Model": "AI Denoise Model", @@ -2109,5 +2115,8 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", + "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..452fc6621 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -63,9 +63,9 @@ "Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.", - "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Upright está desactivado.", - "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Upright guiado no es compatible.", - "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Upright no es compatible.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Vertical está desactivado.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Vertical guiado no es compatible.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada de Vertical no es compatible.", "XMP adjustments were not imported for {path}: {fields}.": "Los ajustes XMP no se importaron para {path}: {fields}.", "Imported {n} point curve preset{}": "{n} ajuste{0} preestablecido{0} de curva de puntos importado{0}", "Imported {n} preset{}": "{n} ajuste{0} preestablecido{0} importado{0}", @@ -197,23 +197,23 @@ "{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)", ", {} star{}": ", {} estrella{}", ", {} label": ", etiqueta {}", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Se descarga desde {host}. Cuando llega y se comprueba, LightCraft lo instala y {after}.", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "Se ejecuta en el procesador: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Se guardan en la carpeta «denoise» de la biblioteca; al llegar al límite se eliminan primero las más antiguas que no usa ninguna foto.", + "Denoising {name}: {done} of {total} tiles{}": "Reduciendo el ruido de {name}: {done} de {total} mosaicos{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "{} de {} desde {host}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "Generando la imagen sin ruido… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "Entrenado con: {provenance}", + " · {queued} waiting": " · {queued} en espera", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Muestra las condiciones del modelo, descarga {} desde {host}, lo instala y empieza a usarlo. No se envía nada más.", + "In line for AI Denoise ({ahead} ahead)…": "En cola para la reducción de ruido con IA ({ahead} por delante)…", + "Running on {}.": "Se ejecuta en {}.", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "Poner dentro de “{parent}”", @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "{name} opened in the other editor": "{name} abierto en el otro editor", + "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", + "· {in_source} identical copies": "· {in_source} copias idénticas" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..ba3e1e331 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -177,7 +177,9 @@ "Cancel": "Cancelar", "Cancel AI Mask Model Download": "Cancelar la descarga del modelo de máscaras con IA", "Cancel Download": "Cancelar descarga", + "Cancel Face Model Download": "Cancelar la descarga del modelo de caras", "Cancel Interaction": "Cancelar interacción", + "Cancel Pending Photo Action": "Cancelar la acción pendiente sobre la foto", "Cancel Preview Build": "Cancelar generación de vistas previas", "Candidate": "Candidata", "Caption": "Pie de foto", @@ -197,7 +199,9 @@ "Choose Another Library…": "Elegir otra biblioteca…", "Choose Edit Settings to Copy": "Elegir ajustes de edición para copiar", "Choose Edit Settings to Copy…": "Elegir ajustes de edición para copiar…", + "Choose Face Recognition Model": "Elegir el modelo de reconocimiento facial", "Choose Folder…": "Elegir carpeta…", + "Choose a model below (Use) to start.": "Elegir un modelo abajo (Usar) para empezar.", "Choose a photo in the grid or filmstrip": "Elegir una foto en la cuadrícula o en la tira de película", "Choose…": "Elegir…", "Cinematic": "Cinematográfico", @@ -388,6 +392,8 @@ "Detail": "Detalle", "Detail (D)": "Detalle (D)", "Details": "Detalles", + "Detect Faces": "Detectar caras", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Detectar caras (menú Foto) lo necesita: una pequeña descarga única.", "Diagonal": "Diagonal", "Discard Preview Cache": "Descartar caché de vistas previas", "Display P3": "Display P3", @@ -401,6 +407,7 @@ "Download AI Mask Model": "Descargar modelo de máscaras con IA", "Download the SAM 3 Model?": "¿Descargar el modelo SAM 3?", "Downloads": "Descargas", + "Drag Crop Handle": "Arrastrar el tirador de recorte", "Drag along the horizon; double-click for Auto": "Arrastrar a lo largo del horizonte; doble clic para Automático", "Drag over an eye on the photo; the pupil inside is found automatically.": "Arrastrar sobre un ojo en la foto; la pupila se detecta automáticamente.", "Draw Guides": "Dibujar guías", @@ -442,6 +449,7 @@ "Enter Full Screen": "Pantalla completa", "Enter a date as YYYY-MM-DD HH:MM:SS": "Introducir una fecha como AAAA-MM-DD HH:MM:SS", "Erase": "Borrar", + "Evaluate Face Recognition": "Evaluar el reconocimiento facial", "Every EXIF, GPS and XMP field in the file": "Todos los campos EXIF, GPS y XMP del archivo", "Existing previews": "Vistas previas existentes", "Exit": "Salir", @@ -470,8 +478,15 @@ "Extended Description": "Descripción ampliada", "External Editor": "Editor externo", "Face Boxes": "Recuadros de caras", + "Face Model Downloads": "Descargas de modelos de caras", + "Face Models": "Modelos de caras", + "Face Recognition On/Off": "Activar/desactivar el reconocimiento facial", "Face boxes": "Recuadros de caras", + "Face detector": "Detector de caras", "Face names written to XMP by Lightroom and other apps show up here.": "Aquí aparecen los nombres de caras escritos en XMP por Lightroom y otras aplicaciones.", + "Face recognition": "Reconocimiento facial", + "Faces": "Caras", + "Faces of a Person": "Caras de una persona", "Faded Matte": "Mate desvaído", "Faded Slide": "Diapositiva desvaída", "Faded Urban": "Urbano desvaído", @@ -562,11 +577,12 @@ "Global Luminance": "Luminancia global", "Global Saturation": "Saturación global", "Glossy": "Brillante", + "Go back to listing them by name": "Volver a la lista por nombre", "Go to History Step": "Ir al paso del historial", "Golden": "Dorado", "Golden Glow": "Resplandor dorado", "Golden Hour": "Hora dorada", - "Grab bag": "Lista de nombres", + "Grab bag": "Miscelánea", "Grain": "Grano", "Graphic": "Gráfico", "Green": "Verde", @@ -724,6 +740,7 @@ "Lens Corrections": "Correcciones de lente", "Level": "Nivel", "Library": "Biblioteca", + "Library Folders": "Carpetas de la biblioteca", "Library Info": "Información de la biblioteca", "Library Originals": "Originales de la biblioteca", "Library Preferences": "Preferencias de la biblioteca", @@ -764,6 +781,7 @@ "Local folders": "Carpetas locales", "Local library — no cloud account needed": "Biblioteca local: no requiere cuenta en la nube", "Locate Missing File…": "Localizar archivo que falta…", + "Locate Missing Files…": "Localizar archivos que faltan…", "Locate Photo": "Localizar foto", "Location": "Ubicación", "Lock": "Bloquear", @@ -860,6 +878,8 @@ "My Photos Panel": "Panel Mis fotos", "Name": "Nombre", "Name (A–Z)": "Nombre (A–Z)", + "Name Face": "Poner nombre a la cara", + "Name Faces": "Poner nombre a las caras", "Name of the folder the original is in": "Nombre de la carpeta en la que está el original", "Named": "Con nombre", "Named People": "Personas con nombre", @@ -1102,6 +1122,8 @@ "Recent Keywords": "Palabras clave recientes", "Recently Added": "Añadidas recientemente", "Recently Deleted": "Eliminadas recientemente", + "Recognise faces (experimental)": "Reconocer caras (experimental)", + "Recognition models": "Modelos de reconocimiento", "Red": "Rojo", "Red Eye": "Ojos rojos", "Red Hue": "Tono rojo", @@ -1136,10 +1158,11 @@ "Remove CA": "Eliminar AC", "Remove Chromatic Aberration": "Eliminar aberración cromática", "Camera local tone mapping": "Mapeo tonal local de la cámara", - "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar el mapeo tonal local guardado en el raw (Apple ProRAW), como lo renderiza la cámara", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar el mapeo tonal local guardado en el archivo RAW (Apple ProRAW), como lo renderiza la cámara", "Remove Disk from Library": "Quitar disco de la biblioteca", "Remove Disk from Library…": "Quitar disco de la biblioteca…", "Remove Face Box": "Quitar recuadro de cara", + "Remove Face Model": "Quitar modelo de caras", "Remove Folder from Library": "Quitar carpeta de la biblioteca", "Remove a filter above, or choose Clear all": "Quitar un filtro arriba o elegir Borrar todo", "Remove from Album": "Quitar del álbum", @@ -1174,6 +1197,7 @@ "Rename…": "Cambiar nombre…", "Rendering": "Renderizado", "Rendering…": "Renderizando…", + "Reorder Album": "Reordenar álbum", "Reorder Masks": "Reordenar máscaras", "Reset": "Restablecer", "Reset All": "Restablecer todo", @@ -1327,8 +1351,11 @@ "Show Luminance Map": "Mostrar mapa de luminancia", "Show Mask": "Mostrar máscara", "Show Mask Overlay": "Mostrar superposición de máscara", + "Show All Mask Overlays": "Mostrar todas las superposiciones de máscara", + "Show all overlays": "Mostrar todas las superposiciones", "Show Mask Pins": "Mostrar chinchetas de máscara", "Show Original": "Mostrar original", + "Show Person": "Mostrar persona", "Show Photo Counts": "Mostrar recuento de fotos", "Show Photos with Keyword": "Mostrar fotos con la palabra clave", "Show Pins": "Mostrar chinchetas", @@ -1373,6 +1400,7 @@ "Soft Proofing": "Prueba en pantalla", "Soft Skin": "Piel suave", "Sort": "Ordenar", + "Sort Albums by Name": "Ordenar álbumes por nombre", "Sort by": "Ordenar por", "Source": "Origen", "Source code on GitHub": "Código fuente en GitHub", @@ -1406,7 +1434,9 @@ "Subject": "Sujeto", "Subtract": "Restar", "Subtract from this mask": "Restar de esta máscara", + "Suggest Names for Faces": "Sugerir nombres para las caras", "Suggestions": "Sugerencias", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Sugiere quién aparece en una foto a partir de las caras con nombre. Todo se queda en el equipo.", "Summer Haze": "Bruma estival", "Sunday": "Domingo", "Survey": "Encuesta", @@ -1488,6 +1518,7 @@ "Ungroup Stack": "Desagrupar pila", "Unknown Date": "Fecha desconocida", "Unknown tags stay as typed — check the preview for a {typo}.": "Las etiquetas desconocidas se quedan tal como se escribieron: revisar la vista previa por si hay un {typo}.", + "Unnamed Faces": "Caras sin nombre", "Unrated": "Sin valorar", "Unreadable": "Ilegible", "Update": "Actualizar", @@ -1658,98 +1689,108 @@ "Captured until": "Capturadas hasta", "Only": "Solo", "Open Log Folder": "Abrir carpeta de registros", - "Back to this person": "Back to this person", - "AI Denoise": "AI Denoise", - "AI Denoise Model": "AI Denoise Model", - "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", - "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", - "Accept & Download": "Accept & Download", - "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", - "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", - "Cache": "Cache", - "Checking and installing the model…": "Checking and installing the model…", - "Checking…": "Checking…", - "Clear cache": "Clear cache", - "Denoise Models": "Denoise Models", - "Denoise Settings": "Denoise Settings", - "Denoise all photos with an Amount": "Denoise all photos with an Amount", - "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", - "Failed its last test": "Failed its last test", - "Graphics card": "Graphics card", - "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", - "Inspect Denoise Model": "Inspect Denoise Model", - "Install Denoise Model": "Install Denoise Model", - "Install from file…": "Install from file…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", - "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", - "Limit": "Limit", - "Make it now": "Make it now", - "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", - "Next up for AI Denoise…": "Next up for AI Denoise…", - "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", - "Open page": "Open page", - "Open the model's page": "Open the model's page", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", - "Processor": "Processor", - "Set up AI Denoise…": "Set up AI Denoise…", - "Speed": "Speed", - "Starting…": "Starting…", - "Tested and works on this computer": "Tested and works on this computer", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", - "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", - "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", - "The processor does the work.": "The processor does the work.", - "There is no model file to install": "There is no model file to install", - "This build cannot run denoise models.": "This build cannot run denoise models.", - "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", - "This file cannot be used yet": "This file cannot be used yet", - "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", - "Try again": "Try again", - "Try the graphics card again": "Try the graphics card again", - "Turn off": "Turn off", - "Unknown licence": "Unknown licence", - "Use": "Use", - "Waiting to start…": "Waiting to start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", - "cached picture": "cached picture", - "cached pictures": "cached pictures", - "commercial use allowed": "commercial use allowed", + "Back to this person": "Volver a esta persona", + "AI Denoise": "Reducción de ruido con IA", + "AI Denoise Model": "Modelo de reducción de ruido con IA", + "AI Denoise is off: press Use on a model.": "La reducción de ruido con IA está desactivada: pulsar Usar en un modelo.", + "AI Denoise needs a model. Install one in Settings.": "La reducción de ruido con IA necesita un modelo. Instalar uno en Ajustes.", + "Accept & Download": "Aceptar y descargar", + "Already installed. Remove it before installing a replacement.": "Ya está instalado. Quitarlo antes de instalar uno nuevo.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automático usa la tarjeta gráfica cuando es más rápida que el procesador: ambos se cronometran en este equipo la primera vez que se genera una foto.", + "Cache": "Caché", + "Checking and installing the model…": "Comprobando e instalando el modelo…", + "Checking…": "Comprobando…", + "Clear cache": "Vaciar caché", + "Denoise Models": "Modelos de reducción de ruido", + "Denoise Settings": "Ajustes de reducción de ruido", + "Denoise all photos with an Amount": "Reducir el ruido de todas las fotos con una cantidad", + "Downloaded and checked. Installing…": "Descargado y comprobado. Instalando…", + "Failed its last test": "Falló en su última prueba", + "Graphics card": "Tarjeta gráfica", + "I have read these terms and accept them for my own use": "He leído estas condiciones y las acepto para mi propio uso", + "Inspect Denoise Model": "Inspeccionar modelo de reducción de ruido", + "Inspect Face Model File": "Inspeccionar el archivo del modelo de caras", + "Install Denoise Model": "Instalar modelo de reducción de ruido", + "Install Face Model": "Instalar modelo de caras", + "Install from file…": "Instalar desde archivo…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Instalar un modelo ONNX propio con denoise-model.json junto a él. Esta versión no ofrece descargas de modelos.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Su imagen sin ruido aún no se ha generado, y las imágenes no se generan por sí solas (Ajustes, Reducción de ruido con IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft no conoce este modelo, así que supuso:", + "Limit": "Límite", + "Make it now": "Generarla ahora", + "Make the denoised picture of the photos I am looking at": "Generar la imagen sin ruido de las fotos que estoy viendo", + "Next up for AI Denoise…": "Siguiente en la reducción de ruido con IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "No se descarga nada hasta aceptar. El modelo se guarda solo en este equipo; LightCraft nunca lo sube ni lo comparte.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Solo se generan las imágenes sin ruido de las fotos cuya cantidad de reducción de ruido con IA es mayor que 0. Las exportaciones siempre generan la suya si falta.", + "Open page": "Abrir página", + "Open the model's page": "Abrir la página del modelo", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Abre la página del modelo en el navegador, para leer sobre él u obtener el archivo a mano.", + "Processor": "Procesador", + "Set up AI Denoise…": "Configurar la reducción de ruido con IA…", + "Speed": "Velocidad", + "Starting…": "Iniciando…", + "Tested and works on this computer": "Probado y funciona en este equipo", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "El modelo de reducción de ruido está instalado y en uso: el regulador Cantidad de reducción de ruido con IA, en Detalle, ya funciona", + "The graphics card is set up when the next photo is made.": "La tarjeta gráfica se configura al generar la siguiente foto.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "El modelo se guarda solo en este equipo. LightCraft nunca lo sube ni lo comparte.", + "The processor does the work.": "El procesador hace el trabajo.", + "There is no model file to install": "No hay ningún archivo de modelo que instalar", + "This build cannot run denoise models.": "Esta versión no puede ejecutar modelos de reducción de ruido.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Esta versión no puede ejecutar modelos de reconocimiento: se pueden añadir y elegir, pero no usar.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Esta versión no tiene dónde guardar modelos de reducción de ruido, así que la reducción de ruido con IA no está disponible. (La aplicación de escritorio sí.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Esta versión no tiene dónde guardar modelos de caras, así que la lista de modelos no está disponible. (La aplicación de escritorio sí.)", + "This file cannot be used yet": "Este archivo aún no se puede usar", + "Tick the box to accept the model's terms first": "Marcar antes la casilla para aceptar las condiciones del modelo", + "Try again": "Reintentar", + "Try the graphics card again": "Volver a probar la tarjeta gráfica", + "Turn off": "Desactivar", + "Unknown licence": "Licencia desconocida", + "Use": "Usar", + "Waiting to start…": "Esperando para empezar…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funciona con archivos RAW de cámaras con sensor estándar (Bayer). Los demás archivos conservan su reducción de ruido normal.", + "cached picture": "imagen en caché", + "cached pictures": "imágenes en caché", + "commercial use allowed": "uso comercial permitido", + "Reading photos…": "Leyendo fotos…", + "Adding photos…": "Añadiendo fotos…", "denoiseModel.accept": "denoiseModel.accept", - "in use": "in use", - "installed": "installed", - "makes AI Denoise available": "makes AI Denoise available", - "non-commercial use only": "non-commercial use only", - "photo has its denoised picture.": "photo has its denoised picture.", - "photo waiting": "photo waiting", - "photos have their denoised picture.": "photos have their denoised picture.", - "photos waiting": "photos waiting", - "starts using it for AI Denoise": "starts using it for AI Denoise", - "terms unclear": "terms unclear", - "the graphics card": "the graphics card", - "the graphics card is not used": "the graphics card is not used", - "Cancel Denoise": "Cancel Denoise", - "Cancel Denoise Model Download": "Cancel Denoise Model Download", - "Choose Denoise Model": "Choose Denoise Model", - "Clear Denoise Cache": "Clear Denoise Cache", - "Denoise Model Downloads": "Denoise Model Downloads", - "Denoise Photos": "Denoise Photos", - "Denoise Pump": "Denoise Pump", - "Denoise Status": "Denoise Status", - "Download Denoise Model": "Download Denoise Model", - "Remove Denoise Model": "Remove Denoise Model", - "Test Denoise Model": "Test Denoise Model", - "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", - "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", - "Cancel pending action": "Cancel pending action", - "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", - "Pending photo action cancelled": "Pending photo action cancelled", - "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", - "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "in use": "en uso", + "installed": "instalado", + "makes AI Denoise available": "activa la reducción de ruido con IA", + "non-commercial use only": "solo uso no comercial", + "photo has its denoised picture.": "foto tiene su imagen sin ruido.", + "photo waiting": "foto en espera", + "photos have their denoised picture.": "fotos tienen su imagen sin ruido.", + "photos waiting": "fotos en espera", + "starts using it for AI Denoise": "empieza a usarlo para la reducción de ruido con IA", + "terms unclear": "condiciones poco claras", + "the graphics card": "la tarjeta gráfica", + "the graphics card is not used": "no se usa la tarjeta gráfica", + "Cancel Denoise": "Cancelar reducción de ruido", + "Cancel Denoise Model Download": "Cancelar la descarga del modelo de reducción de ruido", + "Choose Denoise Model": "Elegir modelo de reducción de ruido", + "Clear Denoise Cache": "Vaciar caché de reducción de ruido", + "Denoise Model Downloads": "Descargas de modelos de reducción de ruido", + "Denoise Photos": "Reducir el ruido de las fotos", + "Denoise Pump": "Cola de reducción de ruido", + "Denoise Status": "Estado de la reducción de ruido", + "Download Denoise Model": "Descargar modelo de reducción de ruido", + "Download Face Model": "Descargar modelo de caras", + "Remove Denoise Model": "Quitar modelo de reducción de ruido", + "Test Denoise Model": "Probar modelo de reducción de ruido", + "Test Face Model": "Probar el modelo de caras", + "AI Denoise is enabled on the requesting photo": "La reducción de ruido con IA está activada en la foto que la pidió", + "After installation, AI Denoise will turn on for the requesting photo.": "Tras la instalación, la reducción de ruido con IA se activará en la foto que la pidió.", + "Cancel pending action": "Cancelar acción pendiente", + "Install a denoise model to enable AI Denoise on this photo": "Instalar un modelo de reducción de ruido para activar la reducción de ruido con IA en esta foto", + "Pending photo action cancelled": "Acción pendiente sobre la foto cancelada", + "Pending photo action cancelled because its library or photo changed": "Acción pendiente sobre la foto cancelada porque cambió su biblioteca o su foto", + "ONNX model": "Modelo ONNX", + "commercial use subject to GPL conditions": "uso comercial sujeto a las condiciones de la GPL", "Decrease Rating": "Reducir valoración", "Increase Rating": "Aumentar valoración", + "Index Faces": "Indexar caras", + "Index Faces in the Background": "Indexar caras en segundo plano", "Click to type a value": "Hacer clic para escribir un valor", "Angle": "Ángulo", "Video": "Vídeo", @@ -1761,9 +1802,9 @@ "Square": "Cuadrada", "In Color": "En color", "No Label": "Sin etiqueta", - "Choose an album…": "Elige un álbum…", + "Choose an album…": "Elegir un álbum…", "No albums yet": "Aún no hay álbumes", - "Fix the marked rules to save this album.": "Corrige las reglas marcadas para guardar este álbum.", + "Fix the marked rules to save this album.": "Corregir las reglas marcadas para guardar este álbum.", "Some rules need fixing (Edit Smart Album…):": "Hay reglas que corregir (Editar álbum inteligente…):", "isn't a field rules know": "no es un campo conocido", "can't be tested that way": "no se puede comprobar así", @@ -1776,16 +1817,16 @@ "needs a number more than 0": "necesita un número mayor que 0", "needs hours, days, weeks, months or years": "necesita horas, días, semanas, meses o años", "isn't one of the choices": "no es ninguna de las opciones", - "needs something to look for (or use “is empty”)": "necesita algo que buscar (o usa «está vacío»)", - "needs something to look for (or use “isn't empty”)": "necesita algo que buscar (o usa «no está vacío»)", + "needs something to look for (or use “is empty”)": "necesita algo que buscar (o usar «está vacío»)", + "needs something to look for (or use “isn't empty”)": "necesita algo que buscar (o usar «no está vacío»)", "needs text": "necesita texto", "can't match any rating from 0 to 5": "no coincide con ninguna valoración de 0 a 5", "needs an album": "necesita un álbum", "names an album that no longer exists": "indica un álbum que ya no existe", - "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", - "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", - "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", - "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)", + "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elegir un álbum dentro", + "This group is empty: add a rule or remove it.": "Este grupo está vacío: añadir una regla o quitarlo.", + "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usar «están vacíos»)", + "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usar «no están vacíos»)", "Mo": "Lu", "Tu": "Ma", "We": "Mi", @@ -1797,7 +1838,7 @@ "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes", "Search albums": "Buscar álbumes", "No albums match": "Ningún álbum coincide", - "This is the album you're editing": "Es el álbum que estás editando", + "This is the album you're editing": "Es el álbum que se está editando", "It tests this album, so testing it back would loop": "Comprueba este álbum, así que comprobarlo a su vez crearía un bucle", "Copy Name": "Nombre de la copia", "In a Stack": "En una pila", @@ -1810,7 +1851,7 @@ "Keywords & People": "Palabras clave y personas", "Description": "Descripción", "Camera Info": "Información de la cámara", - "Develop": "Edición", + "Develop": "Revelado", "Keyword List": "Lista de palabras clave", "Filter Keywords": "Filtrar palabras clave", "No keywords yet": "Aún no hay palabras clave", @@ -1832,7 +1873,7 @@ "Delete Keyword…": "Eliminar palabra clave…", "Drop here for the top level": "Soltar aquí para el nivel superior", "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave.", - "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave.", + "Select photos to give them keywords.": "Seleccionar fotos para darles palabras clave.", "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas", "Will Export": "Se exportará", "No keywords are exported": "No se exporta ninguna palabra clave", @@ -1842,7 +1883,7 @@ "Keywords (⌥1–⌥9)": "Palabras clave (⌥1–⌥9)", "A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre", "Save as a new set": "Guardar como conjunto nuevo", - "Recent Keywords is built in: choose another name": "“Palabras clave recientes” es fijo: elige otro nombre", + "Recent Keywords is built in: choose another name": "«Palabras clave recientes» es fijo: elegir otro nombre", "Import Keywords…": "Importar palabras clave…", "Export Keywords…": "Exportar palabras clave…", "Import Keywords": "Importar palabras clave", @@ -1852,7 +1893,7 @@ "Synchronize Folder": "Sincronizar carpeta", "Synchronize": "Sincronizar", "Scanning…": "Explorando…", - "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Buscar fotos añadidas a esta carpeta del disco o que faltan en ella, y archivos XMP complementarios cambiados por otras apps", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Buscar fotos añadidas a esta carpeta del disco o que faltan en ella, y archivos XMP complementarios cambiados por otras aplicaciones", "Bring the library up to date with this folder and the folders inside it:": "Poner la biblioteca al día con esta carpeta y las carpetas que contiene:", "Import new photos": "Importar fotos nuevas", "Remove missing photos from the library": "Quitar de la biblioteca las fotos que faltan", @@ -1874,7 +1915,7 @@ "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", - "Wait for the import to finish": "Espera a que termine la importación", + "Wait for the import to finish": "Esperar a que termine la importación", "Appearance": "Apariencia", "Appearance Mode": "Modo de apariencia", "Sync with System": "Sincronizar con el sistema", @@ -1905,7 +1946,7 @@ "Looking for changes": "Buscando cambios", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Esto es un catálogo de Lightroom, no una biblioteca de LightCraft. ¿Importarlo a una biblioteca de LightCraft?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "La importación lee el catálogo sin modificarlo y añade sus fotos, ediciones, palabras clave y colecciones a la biblioteca abierta ahora.", - "Albums hold library photos: Add to My Photos first": "Los álbumes contienen fotos de la biblioteca: añádelas primero a Mis fotos", + "Albums hold library photos: Add to My Photos first": "Los álbumes contienen fotos de la biblioteca: añadirlas primero a Mis fotos", "Add to My Photos First": "Añadir primero a Mis fotos", "Fill Location from GPS": "Rellenar ubicación desde GPS", "Download Place Names (GeoNames)": "Descargar nombres de lugares (GeoNames)", @@ -1935,7 +1976,7 @@ "Recent Libraries": "Bibliotecas recientes", "Clear Recent Libraries": "Borrar bibliotecas recientes", "No Recent Libraries": "No hay bibliotecas recientes", - "New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla", + "New Library: Choose Where to Put It": "Nueva biblioteca: elegir dónde guardarla", "New Library": "Nueva biblioteca", "Library name": "Nombre de la biblioteca", "New Folder on Disk": "Nueva carpeta en el disco", @@ -1943,7 +1984,7 @@ "Move to Folder": "Mover a carpeta", "Move to Folder…": "Mover a carpeta…", "Move Selected Photos Here": "Mover aquí las fotos seleccionadas", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP complementarios a esta carpeta; nunca sobrescribe", "Appearance Settings…": "Ajustes de apariencia…", "Add to favorites": "Añadir a favoritos", "Remove from favorites": "Quitar de favoritos", @@ -1951,5 +1992,8 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", + "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..d85e837a3 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", + "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", + "· {in_source} identical copies": "· {in_source} copies identiques" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..b0becb154 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -761,6 +761,7 @@ "Local folders": "Dossiers locaux", "Local library — no cloud account needed": "Bibliothèque locale : aucun compte cloud nécessaire", "Locate Missing File…": "Localiser le fichier manquant…", + "Locate Missing Files…": "Localiser les fichiers manquants…", "Locate Photo": "Localiser la photo", "Location": "Lieu", "Lock": "Verrouiller", @@ -1317,6 +1318,8 @@ "Show Luminance Map": "Afficher la carte de luminance", "Show Mask": "Afficher le masque", "Show Mask Overlay": "Afficher l'incrustation du masque", + "Show All Mask Overlays": "Afficher toutes les superpositions de masque", + "Show all overlays": "Afficher toutes les superpositions", "Show Mask Pins": "Afficher les épingles de masque", "Show Original": "Afficher l'original", "Show Photo Counts": "Afficher le nombre de photos", @@ -1951,5 +1954,8 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", + "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..211e47e29 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "{name} opened in the other editor": "{name}を別のエディタで開きました", + "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", + "· {in_source} identical copies": "・同一のコピー {in_source} 件" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..7a282e676 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -866,6 +866,7 @@ "Local folders": "ローカルフォルダ", "Local library — no cloud account needed": "ローカルのライブラリ(クラウドアカウント不要)", "Locate Missing File…": "見つからないファイルを指定…", + "Locate Missing Files…": "見つからないファイルを指定…", "Location": "保存場所", "Long Edge": "長辺", "Looking for photos…": "写真を検索中…", @@ -1263,6 +1264,8 @@ "Show Luminance Map": "輝度マップを表示", "Show Mask": "マスクを表示", "Show Mask Overlay": "マスクの重ね表示", + "Show All Mask Overlays": "すべてのマスクオーバーレイを表示", + "Show all overlays": "すべてのオーバーレイを表示", "Show Mask Pins": "マスクのピンを表示", "Show Original": "元の写真を表示", "Show Photo Counts": "写真の枚数を表示", @@ -1658,6 +1661,8 @@ "Install the SAM 3 Model": "SAM 3モデルのインストール", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。", "Open Log Folder": "ログフォルダーを開く", + "Reading photos…": "写真を確認中…", + "Adding photos…": "写真を追加中…", "Back to this person": "Back to this person", "AI Denoise": "AI Denoise", "AI Denoise Model": "AI Denoise Model", @@ -1953,5 +1958,8 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", + "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..e5f32f551 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "{name} opened in the other editor": "{name} aberto no outro editor", + "{n} originals opened in the other editor": "{n} originais abertos no outro editor", + "· {in_source} identical copies": "· {in_source} cópias idênticas" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..2e21c727c 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -772,6 +772,7 @@ "Local folders": "Pastas locais", "Local library — no cloud account needed": "Biblioteca local — não precisa de conta na nuvem", "Locate Missing File…": "Localizar arquivo ausente…", + "Locate Missing Files…": "Localizar arquivos ausentes…", "Locate Photo": "Localizar foto", "Location": "Local", "Lock": "Travar", @@ -949,6 +950,8 @@ "Open Library": "Abrir biblioteca", "Open Library…": "Abrir biblioteca…", "Open Log Folder": "Abrir pasta de logs", + "Reading photos…": "Lendo fotos…", + "Adding photos…": "Adicionando fotos…", "Open in": "Abrir em", "Open in Detail": "Abrir em Detalhe", "Open the place in OpenStreetMap": "Abrir o local no OpenStreetMap", @@ -1338,6 +1341,8 @@ "Show Luminance Map": "Mostrar mapa de luminância", "Show Mask": "Mostrar máscara", "Show Mask Overlay": "Mostrar sobreposição da máscara", + "Show All Mask Overlays": "Mostrar todas as sobreposições de máscara", + "Show all overlays": "Mostrar todas as sobreposições", "Show Mask Pins": "Mostrar pinos da máscara", "Show Original": "Mostrar original", "Show Photo Counts": "Mostrar contagem de fotos", @@ -1951,5 +1956,47 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Sort Albums A–Z": "Ordenar álbuns de A a Z", + "Go back to listing them by name": "Voltar a listá-los por nome", + "Sort Contents A–Z": "Ordenar conteúdo de A a Z", + "Cancel Pending Photo Action": "Cancelar ação pendente da foto", + "Show Person": "Mostrar pessoa", + "Add Face Model…": "Adicionar modelo de rostos…", + "Cancel Face Model Download": "Cancelar download do modelo de rostos", + "Choose Face Recognition Model": "Escolher modelo de reconhecimento facial", + "Detect Faces": "Detectar rostos", + "Download Face Model": "Baixar modelo de rostos", + "Drag Crop Handle": "Arrastar alça de recorte", + "Evaluate Face Recognition": "Avaliar reconhecimento facial", + "Face Model Downloads": "Downloads de modelos de rostos", + "Face Models": "Modelos de rostos", + "Face Recognition On/Off": "Ativar/desativar reconhecimento facial", + "Faces of a Person": "Rostos de uma pessoa", + "Index Faces": "Indexar rostos", + "Index Faces in the Background": "Indexar rostos em segundo plano", + "Inspect Face Model File": "Inspecionar arquivo do modelo de rostos", + "Install Face Model": "Instalar modelo de rostos", + "Library Folders": "Pastas da biblioteca", + "Name Face": "Nomear rosto", + "Name Faces": "Nomear rostos", + "Remove Face Model": "Remover modelo de rostos", + "Reorder Album": "Reordenar álbum", + "Sort Albums by Name": "Ordenar álbuns por nome", + "Suggest Names for Faces": "Sugerir nomes para rostos", + "Test Face Model": "Testar modelo de rostos", + "Unnamed Faces": "Rostos sem nome", + "Faces": "Rostos", + "Face recognition": "Reconhecimento facial", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Esta versão não tem onde guardar modelos de rostos, então a lista de modelos não está disponível. (O aplicativo para desktop tem.)", + "Recognise faces (experimental)": "Reconhecer rostos (experimental)", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Sugere quem aparece em uma foto com base nos rostos que você nomeou. Tudo permanece no seu computador.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Esta versão não consegue executar modelos de reconhecimento: eles podem ser adicionados e escolhidos, mas não usados.", + "Choose a model below (Use) to start.": "Escolha um modelo abaixo (Usar) para começar.", + "Face detector": "Detector de rostos", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Detectar rostos (menu Foto) precisa dele: um pequeno download único.", + "Recognition models": "Modelos de reconhecimento", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", + "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..38e28e356 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "{name} opened in the other editor": "{name} открыт в другом редакторе", + "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", + "· {in_source} identical copies": "· идентичных копий: {in_source}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..c840218a0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -637,6 +637,7 @@ "Local folders": "Локальные папки", "Local library — no cloud account needed": "Локальная библиотека — учётная запись в облаке не нужна", "Locate Missing File…": "Указать отсутствующий файл…", + "Locate Missing Files…": "Указать отсутствующие файлы…", "Locate Photo": "Найти фото", "Location": "Местоположение", "Long Edge": "Длинная сторона", @@ -784,6 +785,8 @@ "Open Library": "Открыть библиотеку", "Open Library…": "Открыть библиотеку…", "Open Log Folder": "Открыть папку журналов", + "Reading photos…": "Чтение фото…", + "Adding photos…": "Добавление фото…", "Open in": "Открыть в", "Open in Detail": "Открыть в режиме «Детализация»", "Open the place in OpenStreetMap": "Открыть место в OpenStreetMap", @@ -1106,6 +1109,8 @@ "Show Luminance Map": "Показать карту яркости", "Show Mask": "Показать маску", "Show Mask Overlay": "Показать наложение маски", + "Show All Mask Overlays": "Показать все наложения масок", + "Show all overlays": "Показать все наложения", "Show Mask Pins": "Показать булавки масок", "Show Original": "Показать оригинал", "Show Photo Counts": "Показывать количество фото", @@ -2143,5 +2148,8 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", + "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..55636422f 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "{name} opened in the other editor": "{name} відкрито в іншому редакторі", + "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", + "· {in_source} identical copies": "· ідентичних копій: {in_source}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..85a838098 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1024,6 +1024,7 @@ "Local folders": "Локальні теки", "Local library — no cloud account needed": "Локальна бібліотека — хмарний обліковий запис не потрібен", "Locate Missing File…": "Знайти відсутній файл…", + "Locate Missing Files…": "Знайти відсутні файли…", "Long Edge": "Довгий край", "Looking for photos…": "Пошук фото…", "Luminance Range": "Діапазон яскравості", @@ -1331,6 +1332,8 @@ "Show Hidden Local Locations": "Показати приховані локальні розташування", "Show Luminance Map": "Показати карту яскравості", "Show Mask Overlay": "Показати накладання маски", + "Show All Mask Overlays": "Показати всі накладення масок", + "Show all overlays": "Показати всі накладення", "Show Mask Pins": "Показати маркери масок", "Show Original": "Показати оригінал", "Show Photo Counts": "Показати кількість фото", @@ -2145,5 +2148,8 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", + "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..782f8d731 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "{name} opened in the other editor": "已在其他编辑器中打开 {name}", + "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", + "· {in_source} identical copies": "· {in_source} 个相同副本" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..ad379f1ef 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -858,6 +858,7 @@ "Local folders": "本地文件夹", "Local library — no cloud account needed": "本地图库 — 无需云账户", "Locate Missing File…": "定位缺失的文件…", + "Locate Missing Files…": "定位缺失的文件…", "Locate Photo": "定位照片", "Location": "位置", "Long Edge": "长边", @@ -1325,6 +1326,8 @@ "Show Luminance Map": "显示明亮度图", "Show Mask": "显示蒙版", "Show Mask Overlay": "显示蒙版叠加", + "Show All Mask Overlays": "显示所有蒙版叠加", + "Show all overlays": "显示所有叠加", "Show Mask Pins": "显示蒙版图钉", "Show Original": "显示原图", "Show Photo Counts": "显示照片数量", @@ -1658,6 +1661,8 @@ "Install the SAM 3 Model": "安装 SAM 3 模型", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。", "Open Log Folder": "打开日志文件夹", + "Reading photos…": "正在读取照片…", + "Adding photos…": "正在添加照片…", "Back to this person": "返回此人", "AI Denoise": "AI 降噪", "AI Denoise Model": "AI 降噪模型", @@ -1989,5 +1994,8 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", + "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..56db559c6 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,8 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", + "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", + "· {in_source} identical copies": "· {in_source} 個相同副本" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..647d153d0 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -858,6 +858,7 @@ "Local folders": "本機資料夾", "Local library — no cloud account needed": "本機照片庫,無需雲端帳號", "Locate Missing File…": "尋找遺失的檔案…", + "Locate Missing Files…": "尋找遺失的檔案…", "Locate Photo": "尋找照片", "Location": "位置", "Long Edge": "長邊", @@ -1325,6 +1326,8 @@ "Show Luminance Map": "顯示明度圖", "Show Mask": "顯示遮色片", "Show Mask Overlay": "顯示遮色片疊加", + "Show All Mask Overlays": "顯示所有蒙版疊加", + "Show all overlays": "顯示所有疊加", "Show Mask Pins": "顯示遮色片控制點", "Show Original": "顯示原始照片", "Show Photo Counts": "顯示照片數量", @@ -1658,6 +1661,8 @@ "Install the SAM 3 Model": "安裝 SAM 3 模型", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。", "Open Log Folder": "開啟記錄資料夾", + "Reading photos…": "正在讀取照片…", + "Adding photos…": "正在新增照片…", "Back to this person": "Back to this person", "AI Denoise": "AI Denoise", "AI Denoise Model": "AI Denoise Model", @@ -1951,5 +1956,8 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", + "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..891914d1d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -26,12 +26,34 @@ pub struct ControlRequest { pub method: String, pub params: Value, pub reply: Sender, + /// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the + /// sender gave up on it, so the app must not run it late: issue #718). + pub state: std::sync::Arc, } +/// Queued, not yet run. +pub const PENDING: u8 = 0; +/// The app has started it: it will answer. +pub const TAKEN: u8 = 1; +/// Its sender gave up waiting (the window drew no frames): it is dropped unrun. +pub const ABANDONED: u8 = 2; + impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx }, rx) + (Self { method: method.into(), params, reply: tx, state: Default::default() }, rx) + } + + /// Claim the request for running; false when its sender already gave up on it. + pub fn take(&self) -> bool { + use std::sync::atomic::Ordering; + self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN + } + + /// Give up on the request; false when the app has already started it (it will answer). + pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool { + use std::sync::atomic::Ordering; + state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED } } @@ -96,6 +118,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main), + // why its render failed (the loupe then shows a stand-in, or nothing, for good) + "failed": app.renderer.failure_of(crate::render::Slot::Main, p), // zoomed past the whole-frame render: the window rendered at no more than 100 % (pixels of its frame) "region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})), // …and the same for the Before side of a Before/After view diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..3ce1331a2 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -450,6 +450,50 @@ impl Headless { } } +/// For tests, a render offload: renders wait (stay queued) until `open` is set, then run; those +/// whose slot `fails` picks come back failed, as an undecodable raw's would. Install it with +/// `h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone())))`. +#[cfg(test)] +pub(crate) struct RenderGate { + open: std::rc::Rc>, + fails: fn(crate::render::Slot) -> bool, + jobs: Vec<(crate::render::Slot, lightcraft_engine::media::RenderJob)>, +} + +#[cfg(test)] +impl RenderGate { + pub(crate) fn new(open: std::rc::Rc>) -> Self { + Self::failing(open, |_| false) + } + pub(crate) fn failing(open: std::rc::Rc>, fails: fn(crate::render::Slot) -> bool) -> Self { + Self { open, fails, jobs: Vec::new() } + } +} + +#[cfg(test)] +impl crate::render::RenderOffload for RenderGate { + fn try_start(&mut self, slot: crate::render::Slot, job: lightcraft_engine::media::RenderJob) -> Option { + if !self.open.get() { + return Some(job); + } + self.jobs.push((slot, job)); + None + } + fn finished(&mut self) -> Vec<(crate::render::Slot, lightcraft_engine::media::RenderResult, f64)> { + let fails = self.fails; + self.jobs + .drain(..) + .map(|(slot, job)| { + let mut r = job.run(); + if fails(slot) { + r.rendered = Err("no decoder for this raw".into()); + } + (slot, r, 0.0) + }) + .collect() + } +} + #[cfg(test)] mod tests { #[test] @@ -508,6 +552,45 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Install from file… with a model the engine rejects says why in the AI Denoise section (#1381), instead of + /// leaving the button looking dead. + #[test] + fn a_rejected_model_file_says_why_in_settings() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-ui-denoise-rejected-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.set_denoise_models_dir(Some(dir.join("denoise-models"))); + // without the runtime the button is disabled (the workspace build turns it on through the desktop app) + if h.app.session.execute("denoise.models.list", &json!({})).unwrap()["runtime"] != true { + let _ = std::fs::remove_dir_all(&dir); + return; + } + // another app's model folder: the .onnx with that app's model.json, and no denoise-model.json + let onnx = dir.join("rawdenoise.onnx"); + std::fs::write(&onnx, b"not a model").unwrap(); + std::fs::write(dir.join("model.json"), br#"{"name": "rawdenoise", "scale": 2}"#).unwrap(); + let picked = onnx.to_string_lossy().into_owned(); + h.app.services.pick_denoise_model = Some(Box::new(move || vec![picked.clone()])); + h.app.ui.dialog = Some(Dialog::Settings { tab: "denoise".into() }); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "denoise:installFile"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); + let painted = h.painted_text(); + assert!( + painted + .iter() + .any(|s| s.starts_with("No denoise-model.json beside this .onnx file. The model.json there") && s.ends_with("(see docs/denoise.md)")), + "{painted:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + use super::*; /// The pretend host has a clipboard, as a desktop does: text the UI copies lands in it, and a @@ -750,6 +833,51 @@ mod tests { assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); } + /// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo. + #[test] + fn up_and_down_move_through_the_grid_rows() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "grid"}), t); + h.settle(SETTLE); + let first = h.app.session.visible_cloned()[0]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first.0]}}), t); + h.settle(SETTLE); + let rect = |h: &Headless| { + let a = h.app.session.selection.active.unwrap(); + h.app.widgets.iter().find(|(w, _)| *w == format!("thumb:{}", a.0)).map(|(_, r)| *r).expect("grid cell") + }; + let top = rect(&h); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + let below = rect(&h); + let pos = { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + } + .unwrap(); + assert!(below.top() > top.bottom(), "the next row, at {pos}: {top:?} -> {below:?}"); + assert!(below.left() < top.right() && below.right() > top.left(), "overlapping horizontally"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "back up"); + h.request("ui.key", json!({"key": "up"}), t); + h.settle(SETTLE); + assert_eq!(h.app.session.selection.active, Some(first), "the top row stays put"); + // the filmstrip (one row): Down is the next photo + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + h.request("ui.key", json!({"key": "down"}), t); + h.settle(SETTLE); + assert_eq!( + { + let a = h.app.session.selection.active; + h.app.session.visible_cloned().iter().position(|x| Some(*x) == a) + }, + Some(1) + ); + } + /// The search field is a shared text field: right-click ▸ Paste puts the clipboard's text in /// and filters by it. #[test] @@ -1510,6 +1638,51 @@ mod tests { h.settle(SETTLE); } + /// A collection of thousands of presets (a Lightroom library imported whole) lists only the + /// rows in view: a few dozen widgets and thumbnails a frame, not one per preset (every frame + /// built all of them and froze the app for seconds); scrolling brings later rows in. + #[test] + fn presets_panel_with_thousands_of_presets_builds_only_visible_rows() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let many = (0..5000) + .map(|i| lightcraft_develop::Preset { + id: format!("user.big.{i}"), + name: format!("Look {i}"), + group: format!("Big {:02}", i / 500), + settings: json!({"light": {"exposure": (i % 20) as f64 / 10.0}}), + favorite: false, + builtin: false, + }) + .collect(); + assert_eq!(h.app.session.add_presets(many).len(), 5000); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.presets"}), t); + h.app.ui.preset_thumbs = true; + h.settle(SETTLE); + h.step(); + let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("preset:")).map(|(w, _)| w.clone()).collect::>(); + let first = rows(&h); + assert!((5..60).contains(&first.len()), "{} rows built", first.len()); + assert!(h.app.renderer.variant_textures() < 60, "{} thumbnails", h.app.renderer.variant_textures()); + assert!(h.app.widgets.iter().any(|(w, _)| w == "presetGroup:Big 09"), "every group header is listed"); + let started = std::time::Instant::now(); + h.step(); + eprintln!("presets panel frame with 5000 presets: {:?}", started.elapsed()); + // scroll far down over the list: later presets come into view + let r = h.request("ui.hoverWidget", json!({"id": first[first.len() / 2]}), t); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..20 { + h.request("ui.scroll", json!({"dy": -2000}), t); + h.step(); + } + h.step(); + let later = rows(&h); + assert!((5..60).contains(&later.len()), "{} rows built", later.len()); + let deep = later.iter().filter_map(|w| w.strip_prefix("preset:user.big.")).filter_map(|n| n.parse::().ok()).max(); + assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { @@ -1678,7 +1851,7 @@ mod tests { } assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); }; - confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None }, "Weddings"); + confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings"); assert!(album(&h, "Weddings").is_some(), "New Album"); confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); @@ -3076,6 +3249,70 @@ mod tests { } } + /// With "Leave them", a file in Recently Deleted is left unchecked but a click on its checkbox + /// (or on its "In Recently Deleted" badge) still checks it, and importing it restores the + /// photo (issue #1285). + #[test] + fn import_review_checks_a_recently_deleted_file_by_hand() { + let dir = std::env::temp_dir().join(format!("lc-ui-import-trash-click-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let data: Vec<[u8; 4]> = (0..40 * 30).map(|k| [(k % 40 * 6) as u8, i * 90, 7, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: 40, height: 30, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("img{i}.png")), png).unwrap(); + } + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + let r = h.request("engine.execute", json!({"command": "library.import", "params": {"paths": [dir.join("img0.png").to_string_lossy()]}}), t); + let id = r["result"]["imported"][0].as_u64().unwrap(); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 3}}), t); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id]}}), t); + h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()]}}), t); + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no import review") }; + let trashed = (0..opts.candidates.len()).find(|i| opts.is_trashed(*i)).expect("a file in Recently Deleted"); + opts.set_on_deleted(""); + h.settle(SETTLE); + let checked = |h: &Headless| matches!(&h.app.ui.dialog, Some(crate::state::Dialog::Import { opts }) if opts.checked[trashed]); + assert!(!checked(&h), "Leave them: unchecked"); + let cell = |h: &mut Headless| { + let r = h.request("ui.widgets", json!({"filter": format!("import:{trashed}")}), t); + let w = r["result"].as_array().and_then(|a| a.iter().find(|w| w["id"] == format!("import:{trashed}"))).cloned().expect("cell on screen"); + [0usize, 1, 2, 3].map(|i| w["rect"][i].as_f64().unwrap()) + }; + // the checkbox: 16 pt, 6 pt in from the cell's top-left corner + let [x, y, ..] = cell(&mut h); + h.request("ui.click", json!({"x": x + 14.0, "y": y + 14.0}), t); + h.settle(SETTLE); + assert!(checked(&h), "a click on the checkbox checks the file"); + // the badges ("Will be restored" at the top right, "In Recently Deleted" under the + // checkbox) don't swallow the click either + let [x, y, w, _] = cell(&mut h); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 12.0}), t); + h.settle(SETTLE); + assert!(!checked(&h), "a click on the badge unchecks it again"); + h.request("ui.click", json!({"x": x + w - 14.0, "y": y + 34.0}), t); + h.settle(SETTLE); + assert!(checked(&h)); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "finished"); + let photos = &h.app.session.catalog; + assert!(photos.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| !p.deleted && p.rating == 3), "restored with its edits"); + assert_eq!(photos.photos().filter(|p| !p.deleted).count(), 2); + assert_eq!(photos.len(), 2, "restored, not imported a second time"); + let _ = std::fs::remove_dir_all(&dir); + } + /// The import review opens bigger than the old fixed 6 × 2.5 grid and can be resized by its /// corner: the photo grid takes the new height, and the window then keeps its size (issue #337). #[test] @@ -3282,10 +3519,16 @@ mod tests { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join("IMG_01.png"), png).unwrap(); + // undated files are filed by the file's date (2026-01-14T05:58:48) + std::fs::File::options() + .write(true) + .open(src.join("IMG_01.png")) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); let dest_s = dest.to_string_lossy().to_string(); let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; let mut session = lightcraft_engine::Session::with_demo().with_fs(); - // undated files are filed by the import time session.clock = Box::new(|| "2026-01-14T05:58:48".to_string()); let mut app = LightcraftApp::new(session, services); app.ui.view = crate::state::ViewMode::PhotoGrid; @@ -3335,6 +3578,12 @@ mod tests { let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[40 + i, 3, 9, 255]; 64] }; let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); std::fs::write(src.join(format!("IMG_0{i}.png")), png).unwrap(); + std::fs::File::options() + .write(true) + .open(src.join(format!("IMG_0{i}.png"))) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + Duration::from_secs(1_768_370_328)) + .unwrap(); } let dest_s = dest.to_string_lossy().to_string(); let services = crate::Services { png: None, pick_folder: Some(Box::new(move || Some(dest_s.clone()))), ..Default::default() }; @@ -3604,7 +3853,10 @@ mod tests { assert_eq!(r["ok"], true, "{tab}: {r}"); assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); } - // General: confirm before delete, startup view + // General: confirm before delete (on by default), startup view + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(!h.app.ui.settings.confirm_delete); h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); assert!(h.app.ui.settings.confirm_delete); h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); @@ -3965,6 +4217,82 @@ mod tests { assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); } + /// While an edit renders, the loupe shows the last render: it says "Rendering…" once that has + /// lasted a moment (not at once after a spell away from the loupe, when no frames were drawn), + /// and stops when the render lands. + #[test] + fn the_loupe_says_rendering_while_an_edit_renders() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering"); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!pill(&h), "a finished render"); + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + // half a second in the grid: the loupe draws no frames + h.request("ui.set", json!({"view": "photoGrid"}), t); + for _ in 0..30 { + h.step(); + } + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + for _ in 0..12 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held"); + assert!(!pill(&h), "not at once"); + for _ in 0..30 { + h.step(); + } + assert!(pill(&h), "the last render is on screen and the edit's is on its way"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); + assert!(!h.app.renderer.is_pending(Slot::Main)); + } + + /// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left + /// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the + /// error on hover, and keeps saying so while a library change retries it. + #[test] + fn the_loupe_says_so_when_the_render_behind_a_stand_in_fails() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!has(&h, "loupe:renderFailed")); + let first = h.app.session.active().expect("a photo is open").0; + // every loupe render fails, as an undecodable raw's would; the rest render + let open = Rc::new(Cell::new(true)); + h.app.renderer.set_offload(Box::new(super::RenderGate::failing(open.clone(), |s| s == Slot::Main))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo").0; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other], "active": other}}), t); + assert!(h.step_until(SETTLE, |h| has(h, "loupe:renderFailed")), "the failure is said"); + let shown = h.app.loupe_shown.map(|l| l.1); + assert!(!matches!(shown, Some("render" | "none")), "over a stand-in: {shown:?}"); + assert!(!has(&h, "loupe:rendering")); + let r = h.request("ui.inspect", json!({}), t); + assert_eq!(r["result"]["loupe"]["failed"], "no decoder for this raw", "{}", r["result"]["loupe"]); + // a library change (here a rating) retries it: the failure stays said, no "Rendering…" + open.set(false); + h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 2}}), t); + for _ in 0..45 { + h.step(); + } + assert!(h.app.renderer.is_pending(Slot::Main), "the retry is held"); + assert!(has(&h, "loupe:renderFailed") && !has(&h, "loupe:rendering"), "no blink while it retries"); + // back on a photo that rendered: nothing to say + open.set(true); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first], "active": first}}), t); + h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone()))); + assert!(h.step_until(SETTLE, |h| h.app.loupe_shown.is_some_and(|l| l.1 == "render") && !has(h, "loupe:renderFailed"))); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..4e797a572 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -8,7 +8,7 @@ //! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is //! one undo step. -use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; use lightcraft_engine::import::{ ImportCandidate, ImportUprightMode, ImportWarning, ImportWarningCode, ScanInput, ScanOutput, ScanProgress, scan_with, }; @@ -123,14 +123,21 @@ impl ImportDialog { if !self.is_trashed(i) { continue; } - let on = self.importable(i); + let on = self.checked_by_default(i); if let Some(c) = self.checked.get_mut(i) { *c = on; } } } + /// Candidate `i` can be checked: a readable file the library doesn't have, or one whose photo + /// is in Recently Deleted — even with "Leave them", a click picks it (issue #1285). pub fn importable(&self, i: usize) -> bool { - self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i) && !self.on_deleted.is_empty())) + self.candidates.get(i).is_some_and(|c| c.error.is_none() && (c.duplicate.is_none() || self.is_trashed(i))) + } + /// Candidate `i` is checked by Check All and the file type toggles: with "Leave them", a file in + /// Recently Deleted is only checked by hand. + pub fn checked_by_default(&self, i: usize) -> bool { + self.importable(i) && !(self.is_trashed(i) && self.on_deleted.is_empty()) } /// The file types among the candidates (`ARW`, `JPG`…, upper case, sorted) and how many of each. pub fn file_types(&self) -> Vec<(String, usize)> { @@ -149,7 +156,7 @@ impl ImportDialog { pub fn check_type(&mut self, ty: &str, on: bool) { for i in 0..self.candidates.len() { let same = self.candidates.get(i).is_some_and(|c| file_type(c) == ty); - let importable = self.importable(i); + let importable = self.checked_by_default(i); if same && let Some(c) = self.checked.get_mut(i) { *c = on && importable; } @@ -410,6 +417,8 @@ pub struct ScanTask { rx: std::sync::mpsc::Receiver, /// Open the review with "copy into the library" checked (a camera / card). pub copy: bool, + /// Open the review with this album chosen (an album's context menu ▸ Import…). + pub album: Option, /// Browsing a folder (Local): the photos are read in place instead of opening the review. browse: bool, /// What is being scanned (the review's source). @@ -448,7 +457,7 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> #[cfg(target_arch = "wasm32")] job(); let guard = scan_guard(app, &progress, true); - app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new(), guard }); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); app.renderer.forget_imports(); Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) } @@ -572,6 +581,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { .collect(); d.set_trashed(trashed); d.copy = task.copy; + // only an album the review offers (it may have been deleted while the scan ran) + d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); d.sources = task.sources; app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); } @@ -911,19 +922,31 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) true } +/// Skipped candidates: (already in the library, identical copies within the source, in Recently +/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with +/// the same bytes as one earlier in the list (no library photo) is a copy in the source. +fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { + let trashed = d.trashed.iter().filter(|t| **t).count(); + let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); + (dups, in_source, trashed) +} + /// The dialog body: options, then the candidate grid. pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let t = Tokens::get(ui.ctx()); let n = d.candidates.len(); - let trashed = d.trashed.iter().filter(|t| **t).count(); - // files of photos in Recently Deleted are not "already in the library": they are counted apart - let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed); + let (dups, in_source, trashed) = dup_counts(d); let sel = d.selected_paths().len(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); if dups > 0 { ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); } + if in_source > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); + } if trashed > 0 { ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); } @@ -933,7 +956,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { for i in 0..n { - d.checked[i] = d.importable(i); + d.checked[i] = d.checked_by_default(i); } } }); @@ -1212,16 +1235,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { d.preset.clear(); } - for p in &app.session.presets { - if ui - .selectable_label( - d.preset == p.id, - format!("{} — {}", crate::i18n::builtin_label(&p.group, p.builtin), crate::i18n::builtin_label(&p.name, p.builtin)), - ) - .clicked() - { - d.preset = p.id.clone(); - } + let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); + if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { + d.preset = pid; } }); }); @@ -1282,7 +1298,8 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial } let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); let badge = match (&c.duplicate, &c.error) { - (Some(_), _) if in_trash && ok => Some(if d.on_deleted == "restore" { "Will be restored" } else { "Will be replaced" }), + // checked with "Leave them", the file goes without `onDeleted`: the engine restores it + (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), (Some(_), _) if in_trash => Some("In Recently Deleted"), (Some(r), _) if r == "path" => Some("In library"), (Some(_), _) => Some("Duplicate"), @@ -1291,7 +1308,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial }; if let Some(b) = badge { let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); - let br = Rect::from_min_size(pos2(img.right() - g.size().x - 12.0, img.top() + 6.0), g.size() + vec2(8.0, 4.0)); + let br = badge_rect(img, cb, g.size()); p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); } @@ -1313,6 +1330,15 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial } } +/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit +/// beside it — drawn over the checkbox it hid it (issue #1285). +fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { + let size = text + vec2(8.0, 4.0); + let left = img.right() - size.x - 4.0; + let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; + Rect::from_min_size(pos2(left, top), size) +} + /// Reserve the actual translated tag button width beside a template field. pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { let t = Tokens::get(ui.ctx()); @@ -1524,6 +1550,19 @@ mod tests { ImportDialog::new(candidates) } + /// Identical copies inside the source folder are not "already in the library" (a fresh library + /// said "8 already in the library"). + #[test] + fn copies_within_the_source_are_not_counted_as_in_the_library() { + let mut d = dialog(); + assert_eq!(dup_counts(&d), (0, 1, 0), "same bytes as an earlier file, no library photo"); + d.candidates[4].duplicate = Some("content".into()); + d.candidates[4].existing = Some(7); + d.candidates[5].duplicate = Some("path".into()); + d.candidates[5].existing = Some(8); + assert_eq!(dup_counts(&d), (2, 1, 0)); + } + /// A toggle per file type: the raws only, without the JPEGs beside them (issue #344). #[test] fn file_types_check_and_uncheck_together() { @@ -1580,6 +1619,44 @@ mod tests { assert_eq!(d.checked, [true, true, false, false, true, true]); } + /// With "Leave them", a file in Recently Deleted stays out of Check All and the type toggles, + /// but a click still checks it: it is imported (restored) on its own (issue #1285). + #[test] + fn leave_them_keeps_a_trashed_file_out_until_it_is_clicked() { + let mut d = dialog(); + d.set_trashed(vec![false, false, false, true, false, false]); + assert!(d.checked[3], "restored by default"); + d.set_on_deleted(""); + assert!(!d.checked[3]); + d.check_type("JPG", true); + assert!(!d.checked[3], "the type toggle leaves it out"); + d.click(3, false); + assert!(d.checked[3], "a click checks it"); + assert!(d.selected_paths().contains(&"img3.jpg".to_string())); + d.click(3, false); + assert!(!d.checked[3]); + // a plain duplicate (not in Recently Deleted) still can't be checked + d.set_trashed(vec![false; 6]); + d.click(3, false); + assert!(!d.checked[3]); + } + + /// A badge too wide to fit beside the checkbox goes under it instead of covering it (issue #1285). + #[test] + fn a_badge_never_covers_the_checkbox() { + let img = Rect::from_min_size(pos2(100.0, 50.0), vec2(116.0, 116.0)); + let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); + // "Will be restored" fits beside it, top right + let short = badge_rect(img, cb, vec2(76.0, 12.0)); + assert_eq!(short.right(), img.right() - 4.0); + assert_eq!(short.top(), img.top() + 6.0); + // "In Recently Deleted" doesn't + let long = badge_rect(img, cb, vec2(96.0, 12.0)); + assert!(!long.intersects(cb), "{long:?} covers {cb:?}"); + assert_eq!(long.right(), img.right() - 4.0); + assert!(img.contains_rect(long)); + } + #[test] fn shift_click_with_a_stale_anchor_toggles_one() { let mut d = dialog(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..8248ee5a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,8 @@ mod tests_keyword_list; #[cfg(test)] mod tests_keyword_set; #[cfg(test)] +mod tests_keyword_sidebar; +#[cfg(test)] mod tests_keywording; #[cfg(test)] mod tests_labels; @@ -112,6 +114,8 @@ mod tests_switch_library; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_temp_slider; +#[cfg(test)] mod tests_titlebar; #[cfg(test)] mod tests_unsaved; @@ -149,6 +153,8 @@ pub type RevealFn = Box Result<(), String>>; pub type OpenUrlFn = Box Result<(), String>>; /// Open a file in an application (`app` = "" for the system's default one). pub type OpenWithFn = Box Result<(), String>>; +/// Open several files with one start of an application (`app` as for [`OpenWithFn`]). +pub type OpenAllWithFn = Box Result<(), String>>; /// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: /// not known). pub type ScreenProfileFn = Box Option>>; @@ -214,6 +220,9 @@ pub struct Services { pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). pub open_with: Option, + /// Open several files with one start of the editor (Open Original; desktop only): an editor + /// that keeps one window takes them all. None = [`Services::open_with`] for each. + pub open_all_with: Option, /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = /// assume it can). pub check_editor: Option, @@ -377,6 +386,9 @@ pub struct LightcraftApp { pub window_is_fullscreen: bool, /// The GPU preference last applied (`app.gpu`), to apply Settings changes once. gpu_applied: Option, + /// Say once, when the GPU device has been tried, that no graphics processor was found + /// ([`panels::notices::gpu_notice`]). The desktop app sets it; headless sessions and tests don't. + pub gpu_notice: bool, /// The memory budget setting last applied (MB, 0 = automatic). memory_applied: Option, /// The display profile setting last applied (`app.displayProfile`). @@ -459,6 +471,7 @@ impl LightcraftApp { hover_preview: None, window_is_fullscreen: false, gpu_applied: None, + gpu_notice: false, memory_applied: None, display_applied: None, display_error: None, @@ -789,6 +802,11 @@ impl LightcraftApp { fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { + // a request its sender gave up on (the window drew nothing for a while) is not run + // late: it would happen twice once the sender retries (issue #718) + if !req.take() { + continue; + } let reply = req.reply.clone(); match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 815e98576..ef6b49432 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -5,10 +5,11 @@ //! data only on the owner thread, then finishes the archive index on a worker. use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, mpsc}; use lightcraft_engine::activity::{Cancel, TaskGuard}; +use lightcraft_engine::lightroom_job::{LightroomProgress, LightroomStep}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -37,8 +38,7 @@ pub struct LightroomTask { kind: Kind, path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, rx: mpsc::Receiver, phase: Phase, cancelled: bool, @@ -52,8 +52,7 @@ impl LightroomTask { kind: Kind, path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, rx: mpsc::Receiver, ) -> Self { let label = match kind { @@ -61,7 +60,7 @@ impl LightroomTask { Kind::Import => "Importing Lightroom catalog", }; let guard = s.activity.start("lightroom", label, Cancel::Flag(cancel.clone())); - let task = Self { kind, path, cancel, total, done, rx, phase: Phase::Reading, cancelled: false, guard }; + let task = Self { kind, path, cancel, progress, rx, phase: Phase::Reading, cancelled: false, guard }; task.refresh(); task } @@ -73,16 +72,21 @@ impl LightroomTask { self.refresh(); } - /// Bring the activity row up to date: the count while reading, then what is being done. + /// Bring the activity row up to date: while reading, the step under way and its own count + /// (reading the photos takes nearly all of an import, so it is counted, not waited out); + /// then what is being done. fn refresh(&self) { - let detail = match (self.phase, self.kind) { - (Phase::Reading, Kind::Inspect) => "", - (Phase::Reading, Kind::Import) => crate::i18n::tr("Reading Lightroom catalog…"), - (Phase::Committing, _) => crate::i18n::tr("Adding Lightroom photos…"), - (Phase::Finalizing, _) => crate::i18n::tr("Saving Lightroom import index…"), + let (step, done, total) = self.progress.step(); + let detail = match (self.phase, self.kind, step) { + (Phase::Reading, Kind::Inspect, _) => "", + (Phase::Reading, Kind::Import, LightroomStep::Catalog) => crate::i18n::tr("Reading Lightroom catalog…"), + (Phase::Reading, Kind::Import, LightroomStep::ReadPhotos) => crate::i18n::tr("Reading photos…"), + (Phase::Reading, Kind::Import, LightroomStep::AddPhotos) => crate::i18n::tr("Adding photos…"), + (Phase::Committing, ..) => crate::i18n::tr("Adding Lightroom photos…"), + (Phase::Finalizing, ..) => crate::i18n::tr("Saving Lightroom import index…"), }; if self.phase == Phase::Reading { - self.guard.progress(self.done.load(Ordering::Relaxed) as u64, self.total.load(Ordering::Relaxed) as u64); + self.guard.progress(done as u64, total as u64); } else { self.guard.progress(0, 0); } @@ -100,13 +104,21 @@ impl LightroomTask { Phase::Committing => "committing", Phase::Finalizing => "finalizing", }; + let (step, done, total) = self.progress.step(); + let step = match step { + LightroomStep::Catalog => "catalog", + LightroomStep::ReadPhotos => "readPhotos", + LightroomStep::AddPhotos => "addPhotos", + }; json!({ "running": true, "kind": kind, "path": self.path, "phase": phase, - "done": self.done.load(Ordering::Relaxed), - "total": self.total.load(Ordering::Relaxed), + "step": step, + "done": done, + "total": total, + "fraction": self.progress.fraction(), "cancelled": self.cancelled || self.cancel.load(Ordering::Relaxed), }) } @@ -148,8 +160,7 @@ fn busy(app: &LightcraftApp) -> Result<(), String> { fn spawn_inspect( path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, tx: mpsc::Sender, ctx: egui::Context, ) -> Result<(), String> { @@ -157,7 +168,7 @@ fn spawn_inspect( .name("lc-lightroom-inspect".into()) .spawn(move || { let result = lightcraft_engine::guard::catch("Lightroom inspect", || { - lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &total, &done) + lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &progress.catalog_total, &progress.catalog_done) }) .and_then(|r| r.map(inspect_report)); let _ = tx.send(Message::Inspected(result)); @@ -278,12 +289,11 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result { busy(app)?; let cancel = Arc::new(AtomicBool::new(false)); - let total = Arc::new(AtomicUsize::new(0)); - let done = Arc::new(AtomicUsize::new(0)); + let progress = LightroomProgress::default(); let (tx, rx) = mpsc::channel(); - spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?; + spawn_inspect(path.clone(), cancel.clone(), progress.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask::new(&app.session, Kind::Inspect, path.clone(), cancel, total, done, rx)); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Inspect, path.clone(), cancel, progress, rx)); Ok(json!({"running": true, "kind": "inspect", "path": path})) } @@ -292,12 +302,11 @@ fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, c busy(app)?; let job = lightcraft_engine::lightroom_job::LightroomJob::new(&mut app.session, path.clone(), update_existing).map_err(|e| e.to_string())?; let cancel = Arc::new(AtomicBool::new(false)); - let total = job.total_atomic(); - let done = job.done_atomic(); + let progress = job.progress(); let (tx, rx) = mpsc::channel(); spawn_prepare(job, cancel.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask::new(&app.session, Kind::Import, path.clone(), cancel, total, done, rx)); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Import, path.clone(), cancel, progress, rx)); Ok(json!({"running": true, "kind": "import", "path": path})) } @@ -463,11 +472,11 @@ mod tests { use lightcraft_engine::lightroom_catalog::CatalogImport; /// A task as `start_import` makes it, fed by a channel the test holds instead of a worker. - fn fake_import(h: &crate::headless::Headless) -> (LightroomTask, mpsc::Sender, Arc, Arc, Arc) { + fn fake_import(h: &crate::headless::Headless) -> (LightroomTask, mpsc::Sender, Arc, LightroomProgress) { let (tx, rx) = mpsc::channel(); - let (cancel, total, done) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); - let task = LightroomTask::new(&h.app.session, Kind::Import, "fixture.lrcat".into(), cancel.clone(), total.clone(), done.clone(), rx); - (task, tx, cancel, total, done) + let (cancel, progress) = (Arc::new(AtomicBool::new(false)), LightroomProgress::default()); + let task = LightroomTask::new(&h.app.session, Kind::Import, "fixture.lrcat".into(), cancel.clone(), progress.clone(), rx); + (task, tx, cancel, progress) } fn demo() -> crate::headless::Headless { @@ -481,10 +490,10 @@ mod tests { #[test] fn lightroom_row_is_not_cancellable_while_committing() { let mut h = demo(); - let (task, tx, cancel, total, done) = fake_import(&h); + let (task, tx, cancel, progress) = fake_import(&h); h.app.lightroom = Some(task); - total.store(10, Ordering::Relaxed); - done.store(3, Ordering::Relaxed); + progress.catalog_total.store(10, Ordering::Relaxed); + progress.catalog_done.store(3, Ordering::Relaxed); h.step(); let rows = h.app.session.activity.list(); assert_eq!(rows.len(), 1); @@ -505,7 +514,7 @@ mod tests { #[test] fn lightroom_cancel_from_the_stack_stops_the_read() { let mut h = demo(); - let (task, tx, cancel, _, _) = fake_import(&h); + let (task, tx, cancel, _) = fake_import(&h); h.app.lightroom = Some(task); h.step(); let id = h.app.session.activity.list()[0].id; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 58bcb390f..04eea2439 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -157,6 +157,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.visualizeHdr", "view.softProof", "view.maskOverlay", + "view.maskOverlayAll", "view.maskOverlayMode", "view.maskOverlayColor", "view.maskPins", @@ -320,6 +321,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.visualizeHdr" => Some(u.hdr_visualize), "view.softProof" => Some(u.soft_proof), "view.maskOverlay" => Some(u.mask_overlay), + "view.maskOverlayAll" => Some(u.mask_overlay_all), "view.maskPins" => Some(u.mask_pins), "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..141d6d782 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -92,6 +92,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + // Ctrl+Shift+O / Cmd+Shift+O: every mask's region at once, not just the selected one + ("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+Shift+O"), "View"), // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), @@ -166,6 +168,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.contactSheet", "Export Contact Sheet PDF", None, ""), ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), + // {ids?, app?}: the original files themselves (a raw stays a raw: no render, no copy), opened + // in the other editor, e.g. a raw developer + ("photo.openOriginalInExternal", "Open Original in Other Editor (RAW, no copy)", None, "Photo>Edit In"), ("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"), @@ -265,6 +270,18 @@ fn parent_param(p: &Value) -> Option { p.get("parent").and_then(Value::as_u64) } +/// `file.addPhotos` / `file.addFolder` / `file.addFromDevice` `{album?}`: the review of the scan +/// just started opens with that album chosen (an album's context menu ▸ Import ▸ …). An error +/// means another scan is running. +fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result) -> Result { + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.album = p.get("album").and_then(Value::as_u64); + } + r +} + /// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { if let Some(s) = v.as_str() { @@ -379,6 +396,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { @@ -819,6 +840,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.mask_overlay_all = !app.ui.mask_overlay_all; + Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all})) + } "view.maskOverlayMode" => { use lightcraft_engine::pipeline::MaskView; let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); @@ -1069,13 +1094,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, parent: parent_param(p) }); + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: true, + parent: parent_param(p), + include_selected: false, + }); 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, parent: parent_param(p) }); + // {name?, parent?, includeSelected? (true)} + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: false, + parent: parent_param(p), + include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true), + }); Ok(Value::Null) } "dialog.autoStack" => { @@ -1219,6 +1253,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option edit_in(app, &ctx, p, true), "photo.editInExternal" => edit_in(app, &ctx, p, false), + "photo.openOriginalInExternal" => open_original_in(app, &ctx, p), "dialog.cull" => { app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); Ok(Value::Null) @@ -1307,7 +1342,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { @@ -1336,9 +1372,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let within = match p.get("within") { + None | Some(Value::Null) => None, + Some(Value::String(path)) if !path.is_empty() => Some(path.clone()), + Some(Value::String(_)) => return Some(Err("file.findMissing: `within` must not be empty".into())), + Some(value) => return Some(Err(format!("file.findMissing: `within` must be a folder path, not {value}"))), + }; + let title = if within.is_some() { "Locate Missing Files…" } else { "Find Missing Photos…" }; let folder = match p.get("folder").and_then(Value::as_str) { Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| { + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr(title)), |s| { s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) }) { Picked::Now(v) => v.into_iter().next(), @@ -1352,7 +1395,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option| { // relinked under the session as it is now: photos relinked meanwhile and files @@ -1476,7 +1522,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { // a folder (searched recursively) into the import review @@ -1490,7 +1536,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::import::open(app, vec![path]), + Some(path) => { + let r = crate::import::open(app, vec![path]); + import_into_album(app, p, r) + } None => Ok(Value::Null), } } @@ -1807,6 +1856,14 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "photo.editInPhotoCraft" | "photo.editInExternal" => app.session.active().is_some() && app.services.open_with.is_some(), "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), + "photo.openOriginalInExternal" => { + app.services.open_with.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 { .. })) + } "app.showInFinder" => { app.services.reveal.is_some() && app @@ -1963,3 +2020,87 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: } Ok(r) } + +/// Most originals one Open Original hands the editor (each starts it once). +const MAX_ORIGINALS: usize = 50; + +/// Photo ▸ Edit In ▸ Open Original in Other Editor: hand each selected photo's original file +/// itself (a raw stays a raw: nothing is rendered, copied or added to the library) to the editor +/// set in Settings ▸ External Editing, e.g. a raw developer. `p` may give `ids` and the editor +/// (`app`). Demo photos and missing files are skipped and listed; when no photo has a file to +/// open, it fails saying why. +fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> Result { + let r = open_originals(app, p); + if let Err(e) = &r { + app.toast(ctx, e.clone()); + } + let r = r?; + let opened = r["opened"].as_array().map_or(0, Vec::len); + match (r["failed"].as_str(), r["opened"][0]["path"].as_str()) { + (Some(e), _) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), + (None, Some(path)) if opened == 1 => { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(ctx, crate::i18n::tr_format!("{name} opened in the other editor", name = name)); + } + _ => app.toast(ctx, crate::i18n::tr_format!("{n} originals opened in the other editor", n = opened)), + } + Ok(r) +} + +fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { + let mut ids = app.session.targets(p); + ids.dedup(); + if ids.is_empty() { + return Err("no photo selected".into()); + } + if ids.len() > MAX_ORIGINALS { + return Err(format!("{} photos selected: open at most {MAX_ORIGINALS} originals at a time", ids.len())); + } + let mut files = Vec::new(); + let mut skipped = Vec::new(); + for id in ids { + match app.session.catalog.photo(id).map(|ph| &ph.source) { + Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())), + Some(lightcraft_engine::catalog::Source::File { path }) => { + skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")})) + } + Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), + None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), + } + } + if files.is_empty() { + let why = skipped.first().and_then(|s| s["reason"].as_str()).unwrap_or("no original file"); + return Err(if skipped.len() == 1 { + format!("nothing to open: {why}") + } else { + format!("nothing to open: none of these photos has its original file ({why})") + }); + } + let editor = p.get("app").and_then(Value::as_str).map_or_else(|| app.ui.settings.external_editor.clone(), str::to_string); + if let Some(check) = app.services.check_editor.as_ref() { + check(&editor)?; + } + // all at once where the host can: an editor that keeps one window (DxO PhotoLab) loses the + // files that arrive while it is still starting + if let Some(open_all) = app.services.open_all_with.as_mut() { + let paths: Vec = files.iter().map(|(_, path)| path.clone()).collect(); + open_all(&paths, &editor).map_err(|e| format!("Couldn't open the editor: {e}"))?; + let opened: Vec = files.iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect(); + return Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": null})); + } + let open = app.services.open_with.as_mut().ok_or("opening another application isn't available here")?; + let mut opened = Vec::new(); + let mut failed = None; + for (id, path) in files { + match open(&path, &editor) { + Ok(()) => opened.push(json!({"id": id.0, "path": path})), + Err(e) => failed = Some(e), + } + } + if opened.is_empty() + && let Some(e) = failed + { + return Err(format!("Couldn't open the editor: {e}")); + } + Ok(json!({"opened": opened, "skipped": skipped, "app": editor, "failed": failed})) +} diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index b1d066a7b..a603692fa 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -260,13 +260,13 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { match t.rx.try_recv() { Ok(r) => { let Some(t) = app.merge.final_task.take() else { return }; - let what = if t.options.command == "merge.panorama" { + let what = crate::i18n::tr(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); @@ -327,7 +327,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) let info = app.merge.preview.as_ref().map(|p| p.3.clone()); if let Some(info) = info { let txt = if opts.is_pano() { - format!( + crate::i18n::tr_format!( "{} photos · {} · {:.0}° × {:.0}°", info["used"].as_array().map_or(0, Vec::len), info["projection"].as_str().unwrap_or(""), @@ -349,7 +349,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) if opts.is_pano() { ui.label(egui::RichText::new(crate::i18n::tr("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() { + if ui.radio(opts.projection == v, crate::i18n::tr(l)).clicked() { opts.projection = v.to_string(); } } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 65c25c6b5..874df26cc 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -215,7 +215,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo ("200%", Zoom::Percent(200.0)), ("400%", Zoom::Percent(400.0)), ] { - if ui.selectable_label(app.ui.zoom == z, label).clicked() { + if ui.selectable_label(app.ui.zoom == z, crate::i18n::tr(label)).clicked() { app.ui.zoom = z; app.ui.pan = (0.5, 0.5); } diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..4c2855718 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -258,6 +258,36 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if active { p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); } + // Compare and Reference are where sharpness is judged: as in the loupe, say when what is on + // screen isn't the render on its way (a thumbnail, the last render after an edit, a magnified + // frame under its window), or when that render failed + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) { + let i = slot_index(slot); + let r = &app.renderer; + let mine = |s: Slot| r.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key); + let waits = |s: Slot| r.is_pending(s) && !r.retrying(s); + // a slider drag on this photo: Compare has no drafts, so its render can't keep up until the drag ends + let dragging = app.session.interaction.is_some() && app.session.selection.active == Some(id); + let coming = (waits(slot) && !dragging) || (window.is_some() && waits(Slot::CompareRegion(i))); + let keys = [mine(slot), mine(Slot::CompareRegion(i)), None, None]; + let now = ui.input(|i| i.time); + // (the wait belongs to the photo in the pane: one moved in starts its own) + let rendering = ui.ctx().data_mut(|d| { + let (photo, wait) = d.get_temp_mut_or(egui::Id::new(("compare-rendering", i)), (id, None)); + if *photo != id { + (*photo, *wait) = (id, None); + } + super::detail::rendering_overdue(wait, keys, coming, now) + }); + let failed = (!rendering && mine(slot).is_none() && (!r.is_pending(slot) || r.retrying(slot))).then(|| r.failure_of(slot, id)).flatten(); + let over = display.intersect(img_area); + let over = if over.is_positive() { over } else { img_area }; + if let Some(e) = failed { + super::detail::failed_pill(ui, app, &photo, img_area, over, None, &format!("compare:renderFailed:{i}"), e); + } else if rendering { + super::detail::loupe_pill(ui, img_area, over, None, &format!("compare:rendering:{i}"), crate::i18n::tr("Rendering…"), false); + } + } // caption: label · file name · stars · flag // caption right under the image (at the bottom when zoomed in) let cap_top = (display.bottom() + 2.0).min(img_area.bottom()); @@ -265,7 +295,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo 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 }); + let g = pt.layout_no_wrap(crate::i18n::tr(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; } @@ -350,6 +380,8 @@ fn request_window(app: &mut LightcraftApp, v: &TileView) -> Option, list: Option<(u64, f64, Value)>, + /// Why the last file picked with Install from file… was refused, shown under the button. + rejected: Option, } /// Called every frame: keeps the engine's denoise work going and asks for a redraw when a photo's picture appears (the @@ -247,9 +249,17 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { register(ui.ctx(), "denoise:installFile", r.rect); if r.clicked() && let Some(path) = app.services.pick_denoise_model.as_mut().and_then(|f| f().into_iter().next()) - && let Ok(info) = app.run("denoise.models.inspect", json!({"path": path})) { - app.ui.dialog = Some(crate::state::Dialog::DenoiseModel { info, accepted: false }); + match app.session.execute("denoise.models.inspect", &json!({"path": path})) { + Ok(info) => { + app.caches.denoise.rejected = None; + app.ui.dialog = Some(crate::state::Dialog::DenoiseModel { info, accepted: false }); + } + Err(e) => app.caches.denoise.rejected = Some(plain(&e)), + } + } + if let Some(why) = &app.caches.denoise.rejected { + hint(ui, t, why); } if all.is_empty() { hint(ui, t, "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads."); @@ -550,6 +560,14 @@ pub fn install(app: &mut LightcraftApp, info: &Value, accepted: bool) -> Result< r } +/// What an engine error says, without the command it came from (the install job's errors read the same way). +fn plain(e: &lightcraft_engine::EngineError) -> String { + match e { + lightcraft_engine::EngineError::BadParams { msg, .. } => msg.clone(), + other => other.to_string(), + } +} + /// "1 photo" / "3 photos": the count with the right noun. fn count(n: u64, one: &str, many: &str) -> String { tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..66f9c7997 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -309,10 +309,11 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt to_px * (v.visible.bottom() - v.target.top()), ); let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; - // the Before side waits for the original the After is decoding (see `defer_before_window`) + // a window waits for the original another window job is decoding (see `defer_window`) let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh))); - if crate::region::defer_before_window(v.before, app.renderer.is_pending(Slot::Region), original_held) { - return app.region_before_view.filter(|w| w.photo == c.id); + let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore); + if crate::region::defer_window(window_pending, original_held) { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); } let job = if v.before { app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool) @@ -331,6 +332,8 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) }; let job = job.with_overlay(overlay); + // keyed as the renderer keys the texture (a display profile changes the key) + let job = app.renderer.job_for(v.slot, job); // The overlay is part of the tile's identity too, so changing mask view/colour never leaves a // stale region tile on screen while its replacement renders. let look = job.settings.hash64() ^ overlay.key().rotate_left(17); @@ -473,6 +476,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); + // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed + // in, what changes on screen is the window's draft) + #[cfg(not(target_arch = "wasm32"))] + if interacting && plan.window_edge.is_none() { + let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); + // (a frame time that is not a number waits for nothing) + let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); + app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); + } } // hovering a preset or profile: the photo with that look, shown instead of the loupe render // once it is ready (nothing is committed) @@ -623,7 +635,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { shown = draw(Slot::Main, img_rect, canvas); if shown == "none" { - match app.renderer.failure(Slot::Main) { + match app.renderer.failure_of(Slot::Main, id) { Some(e) => { p.text( canvas.center() - vec2(0.0, 10.0), @@ -642,19 +654,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } app.loupe_shown = Some((id, shown)); // a wipe shows the Before whole-frame render on its side of the line… + let line = crate::region::wipe_line(img_rect, canvas); if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { let before_rect = fit_texture_rect(img_rect, tex.size); - let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom())); - p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE); + let u = ((line.x - before_rect.left()) / before_rect.width().max(1.0)).clamp(0.0, 1.0); + let left = Rect::from_min_max(before_rect.min, pos2(before_rect.left() + u * before_rect.width(), before_rect.bottom())); + p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(u, 1.0)), Color32::WHITE); } if app.ui.before_after == BeforeAfter::SplitTopBottom && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { let before_rect = fit_texture_rect(img_rect, tex.size); - let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y)); - p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); + let v = ((line.y - before_rect.top()) / before_rect.height().max(1.0)).clamp(0.0, 1.0); + let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.top() + v * before_rect.height())); + p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, v)), Color32::WHITE); } // …and the window renders go over the whole-frame ones, all of them, where they belong in the // frame (clipped to their pane, or their side of the line) @@ -666,12 +681,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // the line of a wipe over both if app.ui.before_after == BeforeAfter::Split { - let mid = img_rect.center().x; - p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); + p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); } if app.ui.before_after == BeforeAfter::SplitTopBottom { - let mid = img_rect.center().y; - p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); + p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], Stroke::new(1.5, Color32::WHITE)); } if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); @@ -749,14 +762,47 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } // drawn and hit-tested above the loupe and its tools: clicks on it pan - navigator(app, ui, canvas, img_rect, id); + let nav = navigator(app, ui, canvas, img_rect, id); + // what is on screen isn't the render on its way (a stand-in, the last render after an edit, + // a magnified frame under its window): say so once that has lasted a moment + let whole: &[Slot] = if show_before && !split { + &[Slot::Before] + } else if split || split_view { + &[Slot::Main, Slot::Before] + } else { + &[Slot::Main] + }; + let coming = !matches!(shown, "none" | "hover") + && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)); + let keys = + [Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key)); + let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now)); + // the render failed while a stand-in is up: what is on screen is all there will be (also while + // a library change retries it) + let failed = (!rendering + && !(show_before && !split) + && !matches!(shown, "render" | "none" | "hover") + && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) + .then(|| app.renderer.failure_of(Slot::Main, id)) + .flatten(); + if rendering || failed.is_some() { + // over the photo (the After in two views); in the crop tool's wide margin, under it + let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) }; + let over = if over.is_positive() { over } else { canvas }; + match failed { + Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e), + None => { + loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); + } + } + } if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { preview_only_pill(ui, canvas, why); } resp.context_menu(|ui| { ui.menu_button(crate::i18n::tr("Zoom"), |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run(cmd, json!({})); } } @@ -1039,11 +1085,11 @@ fn pretty_date(iso: &str) -> String { const NAV_W: f32 = 180.0; /// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the -/// visible region outlined; click or drag on it to pan. -fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) { +/// visible region outlined; click or drag on it to pan. Returns the area it covers. +fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option { let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; if !app.ui.navigator || !zoomed || app.ui.fullscreen { - return; + return None; } let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); let aspect = img.width() / img.height().max(1.0); @@ -1073,11 +1119,82 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); app.ui.pan = (u, v); } + Some(frame.expand(5.0)) } /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. const PREFETCH_PRIORITY: u32 = 4; +/// How long a render may be on its way, with nothing new on screen, before the loupe says +/// "Rendering…" (s): as long as the activity stack waits before it shows a task. +const RENDERING_AFTER_SECS: f64 = 0.5; + +/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on +/// screen, since when, and whether the pill is up. +type RenderingWait = Option<([Option; 4], f64, bool)>; + +/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with +/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws +/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming: +/// drafts slower than the wait don't make it blink. +pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: bool, now: f64) -> bool { + if !coming { + *wait = None; + return false; + } + let (seen, since, up) = wait.get_or_insert((keys, now, false)); + if !*up && *seen != keys { + (*seen, *since) = (keys, now); + } + *up |= now - *since > RENDERING_AFTER_SECS; + *up +} + +/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that +/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The +/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see +/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all +/// there will be. +pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option, id: &str, text: &str, caution: bool) -> Rect { + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225)); + let inset = if caution { 29.0 } else { 10.0 }; + let size = vec2(g.size().x + inset + 10.0, 20.0); + let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size); + if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) { + r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size); + } + p.rect_filled(r, 10.0, Color32::from_black_alpha(185)); + if caution { + crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution); + } + p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + register(ui.ctx(), id, r); + r +} + +/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]): +/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with +/// the error on hover and for screen readers. +#[allow(clippy::too_many_arguments)] +pub(crate) fn failed_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + canvas: Rect, + over: Rect, + navigator: Option, + id: &str, + error: &str, +) { + let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path)); + let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" }); + let r = loupe_pill(ui, canvas, over, navigator, id, text, true); + let resp = ui.interact(r, egui::Id::new(("render-failed", id)), Sense::hover()).on_hover_text(error); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, format!("{text}: {error}"))); +} + fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { use lightcraft_engine::media::QuickSource; match q { @@ -1125,6 +1242,15 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra if app.ui.fullscreen { return Overlay::None; } + // Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay + // so the shortcut is not shadowed by the always-on single-mask overlay. + if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) { + return Overlay::AllMasks { + view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), + color: app.ui.mask_overlay_color, + opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8, + }; + } if app.ui.right == RightPanel::Masking && app.ui.mask_overlay && let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)) @@ -2145,7 +2271,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) child.style_mut().always_scroll_the_only_direction = true; // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` - let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active); + let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source); egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); app.film_scroll = Some(vp.left()); @@ -2448,6 +2574,27 @@ mod tests { assert!(outside_crop_mesh(Rect::NAN, &box_at(pos2(50.0, 50.0), 20.0, 20.0, 10.0), Color32::BLACK).indices.is_empty()); } + /// A raw's embedded JPEG shown while the raw decodes, or the last render while an edit renders, + /// looked like the finished photo: the loupe says "Rendering…" once a render has been coming for + /// a moment with nothing new on screen, and doesn't blink while slow drafts arrive. + #[test] + fn the_loupe_says_rendering_when_the_screen_falls_behind() { + use super::{RENDERING_AFTER_SECS as WAIT, rendering_overdue}; + let key = |k| [Some(k), None, None, None]; + let mut wait = None; + assert!(!rendering_overdue(&mut wait, key(1), false, 0.0), "nothing coming"); + // an edit after an idle spell: the wait starts now, not at the last frame drawn + assert!(!rendering_overdue(&mut wait, key(1), true, 30.0), "a render just asked for"); + assert!(!rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 0.8), "a quick render never shows it"); + assert!(rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 1.2), "nothing new for the wait"); + assert!(rendering_overdue(&mut wait, key(2), true, 31.0 + WAIT), "once up, a slow draft doesn't blink it"); + assert!(!rendering_overdue(&mut wait, key(3), false, 32.0), "done"); + // a drag whose drafts keep up: always something coming, a new draft every 0.1 s + for i in 1..30u32 { + assert!(!rendering_overdue(&mut wait, key(u64::from(i)), true, 40.0 + f64::from(i) * 0.1), "draft {i}"); + } + } + /// Issue #652: a native host reports the GPU's texture limit in the first frame's input only. /// The loupe must still know it in every later frame (it read the raw input, found nothing and /// cut every window render to 2048 px). diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..08e3bd670 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -98,8 +98,8 @@ fn watermark_preview( let (out, repaint) = (base.clone(), ctx.clone()); let work = move || { for job in jobs { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.run().rendered.map(|r| r.image))) - .unwrap_or_else(|_| Err("the preview failed unexpectedly".into())); + // (a panic in the render is its failure: `RenderJob::run`) + let r = job.run().rendered.map(|r| r.image); let mut got = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // a failed sharp render keeps the thumbnail if r.is_ok() || !matches!(got.1, Some(Ok(_))) { @@ -298,13 +298,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); - ui.add( + let r = 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()), ); + crate::widgets::named(r, crate::i18n::tr("Stack photos taken within this time of each other:")); 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( @@ -359,7 +360,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::AllMetadata { title, rows, search } => { ui.label(egui::RichText::new(title.as_str()).color(t.text_label)); - let r = ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)); + let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(search).hint_text(crate::i18n::tr("Filter fields")).desired_width(f32::INFINITY)), crate::i18n::tr("Filter fields")); crate::widgets::register(ui.ctx(), "field:metadataSearch", r.rect); let q = search.trim().to_lowercase(); let keep = |n: &str, v: &str| q.is_empty() || n.to_lowercase().contains(&q) || v.to_lowercase().contains(&q); @@ -402,7 +403,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } Dialog::SmartRules { id, name, rules, .. } => { - let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); + let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Name")); crate::widgets::register(ui.ctx(), "field:smartName", r.rect); ui.add_space(6.0); // the folder an album made from a folder view carries is not in the editor, but it counts @@ -508,12 +509,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, &colour, |ui| { let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); ui.painter().circle_filled(r.center(), 6.0, crate::panels::grid::label_color(*l)); - let te = ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)); + let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut names[i]).hint_text(colour.as_str()).desired_width(f32::INFINITY)), crate::i18n::tr(&colour)); crate::widgets::register(ui.ctx(), format!("field:labelName-{}", colour.to_lowercase()), te.rect); }); } field(ui, "Save as set", |ui| { - let te = ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)); + let te = crate::widgets::named(ui.add(egui::TextEdit::singleline(save_as).hint_text(crate::i18n::tr("Optional name")).desired_width(f32::INFINITY)), crate::i18n::tr("Save as set")); crate::widgets::register(ui.ctx(), "field:labelSetName", te.rect); }); ui.label( @@ -528,7 +529,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let tags_open = field(ui, "Template", |ui| { ui.spacing_mut().item_spacing.x = 4.0; let w = crate::import::tag_field_width(ui); - let r = ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)); + let r = crate::widgets::named(ui.add(egui::TextEdit::singleline(template).id(template_id).hint_text("{name}").desired_width(w)), crate::i18n::tr("Template")); crate::widgets::register(ui.ctx(), "field:renameTemplate", r.rect); crate::import::tag_toggle(ui, "renameTemplate") }); @@ -608,14 +609,25 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } } - Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { + Dialog::NewAlbum { name, folder, include_selected, .. } => { + if dialog_field(ui, "field:albumName", name, "Name") { + confirm = true; + } + // as in Lightroom, the option is only there when something is selected + if !*folder && !app.session.targets(&json!({})).is_empty() { + ui.add_space(6.0); + let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); + crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect); + } + } + Dialog::RenameAlbum { name, .. } => { if dialog_field(ui, "field:albumName", name, "Name") { confirm = true; } } Dialog::CreatePreset { name, group, groups } => { - ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)); - ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)); + crate::widgets::named(ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Preset name")).desired_width(f32::INFINITY)), crate::i18n::tr("Preset name")); + crate::widgets::named(ui.add(egui::TextEdit::singleline(group).hint_text(crate::i18n::tr("Group")).desired_width(f32::INFINITY)), crate::i18n::tr("Group")); ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); group_checklist(ui, "presetInclude", groups); } @@ -678,13 +690,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); ui.add_space(4.0); let before = opts.format; + // "Rendered DNG" is a DNG option (below), not a format of its own in this list + let mut shown = if opts.format == F::RenderedDng { F::Dng } else { opts.format }; choices( ui, "Format", "exportFormat", &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Jxl, "JPEG XL"), (F::Dng, "DNG"), (F::Original, "Original files")], - &mut opts.format, + &mut shown, ); + if shown != opts.format && !(shown == F::Dng && opts.format == F::RenderedDng) { + opts.format = shown; + } + if matches!(opts.format, F::Dng | F::RenderedDng) { + choices(ui, "DNG of", "exportDngKind", &[(F::Dng, "Raw data"), (F::RenderedDng, "Rendered photo")], &mut opts.format); + } if !opts.format.is_rendered() { let note = if opts.format == F::Dng { "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply." @@ -710,10 +730,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if opts.hdr && !opts.hdr_output() { ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); } + if opts.hdr && opts.format == F::Avif { + use lightcraft_engine::export::HdrCurve as H; + choices(ui, "HDR curve", "exportHdrCurve", &[(H::Pq, "PQ"), (H::Hlg, "HLG")], &mut opts.hdr_transfer); + } } if !rendered { } else if opts.format == F::Avif && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim)); + let curve = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { "HLG" } else { "PQ" }; + ui.label(egui::RichText::new(format!("Color space: Rec. 2020 {curve} (HDR AVIF)")).color(t.text_dim)); } else if opts.format == F::Png && opts.hdr { ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); @@ -823,7 +848,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { { wm.image = f; } - ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())); + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width())), crate::i18n::tr("Image")); }); }); let mut width = wm.image_width as f64 * 100.0; @@ -833,7 +858,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } else { choices(ui, app.ui.language.tr("Text direction"), "exportWmOrientation", &[(false, app.ui.language.tr("Horizontal text")), (true, app.ui.language.tr("Vertical text"))], &mut wm.vertical); field(ui, "Text", |ui| { - ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)) + crate::widgets::named(ui.add(egui::TextEdit::multiline(&mut wm.text).desired_rows(2).hint_text(crate::i18n::tr("© Your Name")).desired_width(f32::INFINITY)), crate::i18n::tr("Text")) }); } choices( @@ -885,7 +910,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let tags_open = field(ui, "File name", |ui| { ui.spacing_mut().item_spacing.x = 4.0; let w = crate::import::tag_field_width(ui); - ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)); + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.naming).id(naming_id).hint_text("{name}-{seq} · {date} · {title} · {folder}").desired_width(w)), crate::i18n::tr("File name")); crate::import::tag_toggle(ui, "exportNaming") }); if tags_open { @@ -907,11 +932,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { { *dir = d; } - ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())); + crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); }); }); field(ui, "Subfolder", |ui| { - ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)) + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder")) }); use lightcraft_engine::export::Conflict as K; choices( @@ -928,7 +953,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::widgets::register(ui.ctx(), "exportAddToPhotos", r.rect); if opts.add_to_photos { field(ui, "Album", |ui| { - ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)) + crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.photos_album).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Album")) }); ui.label( egui::RichText::new(crate::i18n::tr("The first time, macOS asks whether LightCraft may control Photos.")) @@ -956,7 +981,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Err(e) => app.toast(ui.ctx(), e), } } - ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())); + crate::widgets::named(ui.add(egui::TextEdit::singleline(preset_name).hint_text(crate::i18n::tr("Preset name")).desired_width(ui.available_width())), crate::i18n::tr("Preset name")); }); }); } @@ -1520,8 +1545,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder, "parent": parent})) + Dialog::NewAlbum { name, folder, parent, include_selected } => { + created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent})) } Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), Dialog::TextPrompt { value, command, params, key, .. } => { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..76a54171f 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -6,11 +6,13 @@ use egui::{Align2, Color32, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId; use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls}; use lightcraft_geom::Point; +use lightcraft_raster::Histogram; use serde_json::{Value, json}; use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::render::Slot; +use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register, section_header, slider, text_button}; @@ -160,7 +162,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() { + if crate::widgets::icon_button( + ui, + "profileBrowser", + Icon::ProfileGrid, + vec2(28.0, 28.0), + false, + true, + crate::i18n::tr("Browse Profiles"), + ) + .clicked() + { let _ = app.run("panel.profiles", json!({})); } }); @@ -230,8 +242,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let active = app.ui.tool == "wbPicker"; - if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)") - .clicked() + if crate::widgets::icon_button( + ui, + "wbPicker", + Icon::Picker, + vec2(28.0, 28.0), + active, + true, + crate::i18n::tr("White Balance Selector (W)"), + ) + .clicked() { if active { app.ui.tool.clear(); @@ -521,17 +541,52 @@ fn section( // ------------------------------------------------------------------------------ histogram -fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { - let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); - // the loupe render of this photo, else its thumbnail (grid view before the loupe has rendered it) - let hist = app - .renderer +/// The current photo's existing preview histogram, shared by Histogram and Tone Curve. +fn preview_histogram(app: &LightcraftApp, id: PhotoId) -> Option { + 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())); + .or_else(|| app.renderer.textures.get(&Slot::Thumb(id)).and_then(|t| t.histogram.clone())) +} + +/// An HDR edit's histogram stays an HDR histogram: some renders carry only their 8-bit image's +/// (a drag's drafts, a cached thumbnail or stand-in), and showing those would swap the panel to +/// the SDR layout and back while a slider moves. Every render with HDR bins replaces the one kept for the photo, so +/// the histogram still follows the edit live; an SDR edit shows what it gets. +fn steady_hdr_histogram(ctx: &egui::Context, id: PhotoId, hdr_edit: bool, hist: Option) -> Option { + let key = egui::Id::new(("hdrHistogram", id)); + match hist { + Some(h) if h.hdr.is_some() => { + ctx.data_mut(|d| d.insert_temp(key, h.clone())); + Some(h) + } + h if hdr_edit => ctx.data(|d| d.get_temp::(key)).or(h), + h => h, + } +} + +fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let hdr_edit = app.session.develop_of(id).is_some_and(|d| d.hdr.enabled); + // a drag of an HDR edit: its drafts have no HDR histogram, so a small render beside them + // gives one (at its own pace: the drafts don't wait for it) + let interacting = app.session.interaction.is_some(); + if interacting && hdr_edit { + let edge = lightcraft_pipeline::HDR_HISTOGRAM_DRAG_EDGE; + if let Some(job) = app.session.render_job(id, edge, edge, false, app.ui.right != RightPanel::Crop) + && app.renderer.live_hdr_histogram.as_ref().is_none_or(|l| l.1 != job.key) + { + app.renderer.request(Slot::HdrHistogram, job, 95); + } + } + let live = app.renderer.live_hdr_histogram.as_ref().filter(|l| interacting && l.0 == id).map(|l| l.2.clone()); + // else the loupe render of this photo, else its thumbnail (grid view before the loupe has + // rendered it) + let hist = live.or_else(|| preview_histogram(app, id)); + let hist = steady_hdr_histogram(ui.ctx(), id, hdr_edit, hist); // an HDR edit's histogram (`pipeline::hdr_histogram`) gets a colour bar and an SDR / HDR label // row under the plot let hdr = hist.as_ref().and_then(|h| h.hdr); @@ -740,6 +795,52 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // ------------------------------------------------------------------------------ tone curve +/// A 256-point, peak-normalized display profile of the photo histogram. +/// For HDR, only the SDR portion maps to a curve's input (0..1). +fn curve_histogram_profile(h: &lightcraft_raster::Histogram, channel: &str) -> [f32; 256] { + let source = match channel { + "red" => &h.r, + "green" => &h.g, + "blue" => &h.b, + _ => &h.luma, + }; + let bins = h.hdr.map_or(256, |hdr| hdr.from.clamp(2, 256)); + let samples: [f32; 256] = std::array::from_fn(|i| { + let j = i * (bins - 1) / 255; + let start = j.saturating_sub(2); + let end = (j + 3).min(bins); + let sum: f32 = (start..end).map(|k| source.get(k).copied().unwrap_or(0) as f32).sum(); + sum / (end - start) as f32 + }); + let peak = samples.iter().copied().fold(1.0_f32, f32::max); + samples.map(|v| (v / peak).sqrt()) +} + +/// Show the currently rendered photo's tonal distribution behind the editable +/// curve, without requesting another render or blocking pointer interactions. +fn draw_curve_histogram(p: &egui::Painter, r: Rect, h: &lightcraft_raster::Histogram, channel: &str) { + let profile = curve_histogram_profile(h, channel); + let color = match channel { + "red" => hex("#dd3333"), + "green" => hex("#33bb55"), + "blue" => hex("#3377ee"), + _ => Color32::from_gray(130), + } + .gamma_multiply(0.27); + let mut mesh = Mesh::default(); + for (i, height) in profile.into_iter().enumerate() { + let x = r.left() + r.width() * i as f32 / 255.0; + let top = pos2(x, r.bottom() - r.height() * height); + mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color }); + mesh.vertices.push(Vertex { pos: pos2(x, r.bottom()), uv: Pos2::ZERO, color }); + if i > 0 { + let k = i as u32 * 2; + mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]); + } + } + p.add(mesh); +} + fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { let t = Tokens::get(ui.ctx()); // channel selector @@ -796,6 +897,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev resp.context_menu(|ui| curve_reset_menu(app, ui, &ch)); let p = ui.painter(); p.rect_filled(r, 2.0, t.canvas); + if let Some(hist) = preview_histogram(app, id) { + draw_curve_histogram(p, r, &hist, &ch); + } for i in 1..4 { let f = i as f32 / 4.0; p.line_segment( @@ -858,7 +962,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev }; let line: Vec = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect(); p.add(egui::Shape::line(line, Stroke::new(2.0, color))); - // interaction: drag a point (both axes, between its neighbours), drag empty space to add and + // interaction: drag a point (both axes, between its neighbours; ⌥ = fine), drag empty space to add and // drag a new point, click empty space to add, double-click a point to delete it let drag_id = egui::Id::new(("curve-drag", &ch)); // the point being dragged (stored as `Option` so a stale value can be cleared) @@ -919,8 +1023,17 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev } else if resp.dragged() && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) { - let (mut x, y) = from_screen(q); let mut v = pts.clone(); + // ⌥-drag = fine adjustment, as in Lightroom: the point moves a tenth of the pointer's + // motion (relative to where it is, so pressing ⌥ mid-drag doesn't make it jump) + let fine = ui.input(|i| i.modifiers.alt); + let (mut x, y) = match v.get(i) { + Some(cur) if fine => { + let dd = ui.input(|i| i.pointer.delta()); + ((cur.x + (dd.x / r.width()) as f64 * 0.1).clamp(0.0, 1.0), (cur.y - (dd.y / r.height()) as f64 * 0.1).clamp(0.0, 1.0)) + } + _ => from_screen(q), + }; if i < v.len() { let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 }; let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 }; @@ -1028,8 +1141,11 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) /// Right-click menu of the curve graph. fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) { - let label = - if ch == "parametric" { "Reset Parametric Curve".to_string() } else { crate::i18n::tr_format!("Reset {} Channel", channel_label(ch)) }; + let label = if ch == "parametric" { + crate::i18n::tr("Reset Parametric Curve").to_string() + } else { + crate::i18n::tr_format!("Reset {} Channel", crate::i18n::tr(channel_label(ch))) + }; let r = ui.button(label); register(ui.ctx(), "curveMenu:resetChannel", r.rect); if r.clicked() { @@ -1058,7 +1174,19 @@ 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() }; + set_tat(app, ui.ctx(), tool, active); + } +} + +/// Turn targeted-adjustment tool `tool` off (`active`) or on. Turning it on says how to use and +/// leave it: while it is on, dragging on the photo adjusts instead of zooming or panning, which is +/// easy to miss when the button looks like a tab. +fn set_tat(app: &mut LightcraftApp, ctx: &egui::Context, tool: &str, active: bool) { + if active { + app.ui.tool.clear(); + } else { + app.ui.tool = tool.to_string(); + app.toast(ctx, crate::i18n::tr("Targeted adjustment on: drag up or down on the photo · Esc to stop")); } } @@ -1096,11 +1224,11 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { 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) + if text_button(ui, &format!("tatMixer-{k}"), crate::i18n::tr(label), active) .on_hover_text(crate::i18n::tr("Targeted adjustment: drag up/down on the photo to adjust the colours there")) .clicked() { - app.ui.tool = if active { String::new() } else { tool }; + set_tat(app, ui.ctx(), &tool, active); } } }); @@ -1149,8 +1277,16 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) 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() + if crate::widgets::icon_button( + ui, + "pointColorPicker", + Icon::Picker, + vec2(26.0, 26.0), + active, + !full, + crate::i18n::tr("Sample a colour on the photo"), + ) + .clicked() { app.ui.tool = if active { String::new() } else { "pointColor".into() }; } @@ -1234,7 +1370,9 @@ fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, phot ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (key, tip, icon) in GRADING_TABS { - if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, tip).clicked() { + if crate::widgets::icon_button(ui, &format!("grading-{key}"), icon, vec2(28.0, 28.0), key == mode, true, crate::i18n::tr(tip)) + .clicked() + { app.ui.grading_mode = key.to_string(); } } @@ -1289,6 +1427,9 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings let zone = r.expand(10.0); let hovering = ui.rect_contains_pointer(zone); let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); + let resp = resp.on_hover_text(crate::i18n::tr( + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.", + )); register(ui.ctx(), format!("wheel:{key}"), r); paint_wheel(ui.painter(), c, rad); if hidden { @@ -1305,25 +1446,40 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings } let a = (wh.hue as f32).to_radians(); let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0); - // Shift-drag: saturation only, the hue locked to the spoke the point is on (as in Lightroom). - // Pressing or releasing Shift mid-drag turns the lock on or off there. - let lock_id = egui::Id::new(("wheelHueLock", key)); - let shift = ui.input(|i| i.modifiers.shift); - let lock = match resp.interact_pointer_pos() { - Some(q) if resp.dragged() && shift => { - // a point at the centre has no hue yet: lock to where the pointer is - let start = if wh.sat >= 1.0 { wh.hue as f32 } else { wheel_pick(q - c, rad, None).0 }; - Some(ui.data_mut(|m| *m.get_temp_mut_or_insert_with(lock_id, || start))) + // Modifiers (as in Lightroom): Shift keeps the hue, Cmd/Ctrl keeps the saturation, Alt moves + // the puck a tenth as far as the pointer. Pressing or releasing one mid-drag re-grabs the + // puck as it is at that moment. + let grab_id = egui::Id::new(("wheelGrab", key)); + let mods = ui.input(|i| WheelMods::from_modifiers(i.modifiers)); + let grab = match resp.interact_pointer_pos() { + Some(q) if resp.dragged() || resp.clicked() => { + let held = ui.data(|m| m.get_temp::(grab_id)).filter(|g| g.mods == mods && !resp.drag_started()); + Some(held.unwrap_or_else(|| { + // a fine drag counts the pointer's travel from the press, not from the frame + // egui first called it a drag + let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(q) } else { q }; + let g = WheelGrab::new(mods, wh.hue as f32, wh.sat as f32, origin - c, rad); + ui.data_mut(|m| m.insert_temp(grab_id, g)); + g + })) } _ => { - ui.data_mut(|m| m.remove::(lock_id)); + ui.data_mut(|m| m.remove::(grab_id)); None } }; - // while locked: the whole spoke, a guide for the saturation-only move - if let Some(h) = lock { - let a = h.to_radians(); - ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], Stroke::new(1.5, Color32::from_white_alpha(170))); + // while a constraint is held: the guide for the one move left (the whole spoke for the hue, + // the circle for the saturation) + let guide = Stroke::new(1.5, Color32::from_white_alpha(170)); + match grab.and_then(|g| g.lock()) { + Some(WheelLock::Hue(h)) => { + let a = h.to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * rad], guide); + } + Some(WheelLock::Sat(s)) => { + ui.painter().circle_stroke(c, rad * s / 100.0, guide); + } + None => {} } // the neutral centre (issue #559), under the point ui.painter().circle_filled(c, 2.0, Color32::WHITE); @@ -1336,7 +1492,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings if (resp.dragged() || resp.clicked()) && let Some(q) = resp.interact_pointer_pos() { - let (hue, sat) = wheel_pick(q - c, rad, lock); + let (hue, sat) = grab.map_or_else(|| wheel_pick(q - c, rad, None), |g| g.pick(q - c, rad)); let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}})); } if resp.drag_stopped() || resp.clicked() { @@ -1369,17 +1525,98 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings } } +/// A wheel's modifier state. Shift keeps the hue, Cmd/Ctrl the saturation (Shift wins when both +/// are down: keeping both would freeze the puck), Alt fine-adjusts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct WheelMods { + pub keep_hue: bool, + pub keep_sat: bool, + pub fine: bool, +} + +impl WheelMods { + pub(crate) fn from_modifiers(m: egui::Modifiers) -> Self { + Self { keep_hue: m.shift, keep_sat: m.command && !m.shift, fine: m.alt } + } +} + +/// What a constraint holds still while the pointer moves. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum WheelLock { + /// Keep this hue (degrees): only the saturation changes, along the spoke at that hue. + Hue(f32), + /// Keep this saturation (0..100): only the hue changes, round the circle at that radius. + Sat(f32), +} + +/// Pointer travel to puck travel with Alt held, as for a slider's fine adjust. +const WHEEL_FINE: f32 = 0.1; + +/// The puck as it was when the pointer grabbed it (or a modifier last changed): the hue and +/// saturation a constraint keeps, and the origin a fine adjust measures the pointer's travel from. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct WheelGrab { + pub mods: WheelMods, + /// The puck's hue (degrees) and saturation (0..100) at the grab. + hue: f32, + sat: f32, + /// The pointer at the grab, as an offset from the wheel's centre. + origin: egui::Vec2, +} + +impl WheelGrab { + /// Grab a puck at `hue` / `sat` with the pointer `origin` from the centre of a wheel of + /// radius `rad`. The kept hue is the puck's own, never the angle the pointer happens to be + /// at. A puck at the centre (saturation under 1) has no hue yet, so there it is the angle of + /// the pointer at the grab; the same rule covers a non-finite hue. + pub(crate) fn new(mods: WheelMods, hue: f32, sat: f32, origin: egui::Vec2, rad: f32) -> Self { + let sat = if sat.is_finite() { sat.clamp(0.0, 100.0) } else { 0.0 }; + let hue = if hue.is_finite() && sat >= 1.0 { hue.rem_euclid(360.0) } else { wheel_pick(origin, rad, None).0 }; + Self { mods, hue, sat, origin } + } + + /// The constraint this grab holds, if any. + pub(crate) fn lock(&self) -> Option { + if self.mods.keep_hue { + Some(WheelLock::Hue(self.hue)) + } else if self.mods.keep_sat { + Some(WheelLock::Sat(self.sat)) + } else { + None + } + } + + /// The new hue and saturation for the pointer at offset `v` from the centre. + pub(crate) fn pick(&self, v: egui::Vec2, rad: f32) -> (f32, f32) { + let v = if self.mods.fine { + // the puck where it was, moved a tenth of the pointer's travel since the grab + let a = self.hue.to_radians(); + let puck = vec2(a.cos(), -a.sin()) * rad.max(1.0) * (self.sat / 100.0); + puck + (v - self.origin) * WHEEL_FINE + } else { + v + }; + wheel_pick(v, rad, self.lock()) + } +} + /// Hue (degrees) and saturation (0..100) for a pointer at offset `v` from a wheel's centre. -/// `lock`: keep this hue and take the saturation from `v` projected onto its spoke (Shift-drag). -pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { +/// `lock`: hold the hue (the saturation is `v` projected onto that hue's spoke) or hold the +/// saturation (the hue is the angle of `v`). A non-finite `v` gives a finite answer (the locked +/// value, else 0). +pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option) -> (f32, f32) { let rad = rad.max(1.0); + let v = if v.x.is_finite() && v.y.is_finite() { v } else { egui::Vec2::ZERO }; + let angle = || ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0; match lock { - Some(h) => { + Some(WheelLock::Hue(h)) => { + let h = if h.is_finite() { h } else { 0.0 }; let a = h.to_radians(); let along = v.x * a.cos() - v.y * a.sin(); (h, (along / rad * 100.0).clamp(0.0, 100.0)) } - None => (((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0, (v.length() / rad * 100.0).min(100.0)), + Some(WheelLock::Sat(s)) => (angle(), if s.is_finite() { s.clamp(0.0, 100.0) } else { 0.0 }), + None => (angle(), (v.length() / rad * 100.0).min(100.0)), } } @@ -1435,3 +1672,54 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { }); }); } + +#[cfg(test)] +mod curve_histogram_tests { + use super::*; + + #[test] + fn curve_backdrop_tracks_luma_and_selected_channel() { + let img = lightcraft_raster::Rgba8::from_fn(64, 32, |x, _| if x < 32 { [32, 100, 32, 255] } else { [224, 100, 224, 255] }); + let h = lightcraft_raster::Histogram::of_srgb8(&img); + let red = curve_histogram_profile(&h, "red"); + let green = curve_histogram_profile(&h, "green"); + let luma = curve_histogram_profile(&h, "master"); + assert!(red[32] > red[150] && red[224] > red[150]); + assert!(green[100] > green[32] && green[100] > green[224]); + assert!(luma.iter().any(|v| *v > 0.0)); + } + + #[test] + fn hdr_backdrop_uses_only_the_sdr_bins_and_never_overflows() { + let h = lightcraft_raster::Histogram::of_hdr(&[[0.1, 0.1, 0.1], [1.0, 1.0, 1.0], [8.0, 8.0, 8.0]], 4.0); + let values = curve_histogram_profile(&h, "parametric"); + assert!(values.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + assert!(values.iter().any(|v| *v > 0.0)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { + let ctx = egui::Context::default(); + let id = PhotoId(7); + let sdr = Histogram::of_hdr(&[], 5.0); + let sdr = Histogram { hdr: None, ..sdr }; + let hdr = |v: f32| Histogram::of_hdr(&[[v, v, v]], 5.0); + // nothing kept yet: what arrives + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(2.0))), Some(hdr(2.0))); + // a cached thumbnail's 8-bit histogram (or none) doesn't swap the panel to SDR + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(2.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, None), Some(hdr(2.0))); + // the next HDR render replaces it: the histogram follows the slider + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(hdr(4.0))), Some(hdr(4.0))); + assert_eq!(steady_hdr_histogram(&ctx, id, true, Some(sdr.clone())), Some(hdr(4.0))); + // HDR turned off: the SDR histogram at once; other photos keep their own + assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); + assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..52edfa460 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -229,7 +229,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let active = app.session.selection.active; // bring the active photo into view when it changes (keyboard, click, command) or the grid // comes back on screen; otherwise the scroll position is the user's - let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active) { + let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active, app.session.source) { active.and_then(|a| ids.iter().position(|x| *x == a)).and_then(|i| lay.cells.get(i).copied()) } else { None @@ -295,16 +295,29 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// the strip was not drawn on the previous pass (view switch, panel shown again). Otherwise the /// scroll position is the user's: time passing, finished renders, edits to the photo or a released /// scroll bar never move it (issue #11). -pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option) -> bool { +pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option, source: lightcraft_engine::LibrarySource) -> bool { let pass = ctx.cumulative_pass_nr(); - let last: Option<(Option, u64)> = ctx.data(|d| d.get_temp(key)); - ctx.data_mut(|d| d.insert_temp(key, (active, pass))); + let last: Option<(Option, lightcraft_engine::LibrarySource, u64)> = ctx.data(|d| d.get_temp(key)); + ctx.data_mut(|d| d.insert_temp(key, (active, source, pass))); match last { - Some((was, drawn)) => was != active || drawn + 1 < pass, + Some((was, where_, drawn)) => follow_changed(was, where_, drawn, active, source, pass), None => true, } } +/// Scroll when the active photo changes, the strip was not drawn last pass, or the same photo +/// sits in a different library source (another album) (#1142). +pub(crate) fn follow_changed( + was: Option, + where_was: lightcraft_engine::LibrarySource, + drawn: u64, + active: Option, + source: lightcraft_engine::LibrarySource, + pass: u64, +) -> bool { + was != active || where_was != source || drawn + 1 < pass +} + /// Height of a date header row (points). pub const HEADER_H: f32 = 40.0; @@ -319,6 +332,36 @@ pub struct GridLayout { pub height: f32, } +/// The cell straight above (`down` false) or below cell `i`: in the adjacent row, the one whose +/// centre is nearest horizontally (justified rows don't line up in columns). None in the first / +/// last row or for an index outside the layout. +pub fn vertical_neighbor(lay: &GridLayout, i: usize, down: bool) -> Option { + let row = lay.rows.iter().position(|r| (r.start..r.end).contains(&i))?; + let next = if down { row.checked_add(1)? } else { row.checked_sub(1)? }; + let target = lay.rows.get(next)?; + let x = lay.cells.get(i)?.center().x; + (target.start..target.end).filter_map(|k| Some((k, (lay.cells.get(k)?.center().x - x).abs()))).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(k, _)| k) +} + +/// Up / Down in the grids: how many photos `library.up` / `library.down` move to reach the cell +/// above / below the active one in the grid as last laid out; None when the grid isn't laid out +/// for the current photos (the filmstrip then steps one photo). +pub fn row_distance(app: &mut LightcraftApp, down: bool) -> Option { + if !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + return None; + } + let active = app.session.selection.active?; + let (generation, ids) = app.session.visible_shared(); + let i = ids.iter().position(|x| *x == active)?; + let (key, lay) = app.caches.grid.layout.as_ref()?; + if key.0.0 != generation { + return None; + } + // at the top / bottom row there's no cell to go to: stay put rather than step sideways + let j = vertical_neighbor(lay, i, down).unwrap_or(i); + Some(j.abs_diff(i) as u64) +} + /// One row of cells: `cells[start..end]`, between `top` and `bottom`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct GridRow { @@ -449,6 +492,11 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: /// [`lightcraft_engine::media::Session::embedded_thumb_is_final`]), which for a folder of raws on a /// network share is the difference between reading every file whole and reading a preview each. pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority: u32) { + // A slider drag changes the photo every frame: its thumbnail follows once the drag ends, so its + // renders (a raw decode the first time) don't compete with the loupe's drafts. + if app.session.interaction.as_ref().is_some_and(|i| i.photo == id) && app.renderer.thumb(id).is_some() { + return; + } let bucket = lightcraft_engine::media::thumb_bucket(size); if let Some(photo) = app.session.catalog.photo(id) && app.renderer.thumb_current(photo, bucket, priority) @@ -948,6 +996,16 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Edit in Other Editor")).clicked() { let _ = app.run("photo.editInExternal", json!({})); } + // the raw itself, unrendered: for a raw developer set as the other editor + if ui + .add_enabled( + crate::menus::ui_enabled(app, "photo.openOriginalInExternal"), + egui::Button::new(crate::i18n::tr("Open Original in Other Editor (RAW, no copy)")), + ) + .clicked() + { + let _ = app.run("photo.openOriginalInExternal", json!({})); + } }); }); if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() { @@ -976,7 +1034,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.menu_button(crate::i18n::tr("Set Flag"), |ui| { for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { let _ = app.run("photo.flag", json!({"flag": f})); } } @@ -1065,7 +1123,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("Split Stack", "stack.split", stacked), ("Expand/Collapse Stack", "stack.toggle", stacked), ] { - if ui.add_enabled(on, egui::Button::new(label)).clicked() { + if ui.add_enabled(on, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(cmd, json!({})); } } @@ -1090,7 +1148,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.menu_button(crate::i18n::tr("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() { + if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(id, json!({})); } } @@ -1137,8 +1195,12 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Delete Permanently")).clicked() { let _ = app.run("photo.deletePermanently", json!({})); } - } else if ui.button(crate::i18n::tr("Delete Photo")).clicked() { - let _ = app.run("photo.delete", json!({})); + } else { + let delete = ui.button(crate::i18n::tr("Delete Photo")); + register(ui.ctx(), "button:contextDeletePhoto", delete.rect); + if delete.clicked() { + let _ = crate::menubar::run_item(app, "photo.delete", json!({})); + } } } @@ -1146,6 +1208,15 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { mod tests { use super::*; + #[test] + fn the_same_photo_in_another_source_scrolls_into_view() { + let id = PhotoId(3); + let all = lightcraft_engine::LibrarySource::All; + let album = lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(9)); + assert!(!follow_changed(Some(id), all, 4, Some(id), all, 5)); + assert!(follow_changed(Some(id), all, 4, Some(id), album, 5)); + } + #[test] fn ungrouped_justified_rows_fill_the_width() { let aspects = [1.5f32, 1.5, 0.66, 1.0, 1.5, 1.5, 1.5]; @@ -1223,6 +1294,18 @@ mod tests { } } + #[test] + fn library_context_menu_labels_have_french_translations() { + let saved = crate::i18n::language(); + crate::i18n::set_language(crate::i18n::Locale::Fr); + for (label, expected) in + [("Pick", "Sélectionnée"), ("Reject", "Rejeter"), ("Group into Stack", "Regrouper en pile"), ("HDR Panorama…", "Panorama HDR…")] + { + assert_eq!(crate::i18n::tr(label), expected); + } + crate::i18n::set_language(saved); + } + #[test] fn square_grid_groups_and_auto_levels() { let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..78c19b7e9 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -21,6 +21,13 @@ pub(crate) fn content_width(ctx: &egui::Context) -> f32 { /// The most a row asks for its name: longer ones are cut when there is no more room. const MAX_NAME_NEED: f32 = 140.0; +/// Height of a sidebar row. +const ROW_HEIGHT: f32 = 29.0; + +/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to +/// this frame: set before the sections are drawn, cleared after. +const SCROLL_TO_ROW: &str = "left-scroll-to-row"; + /// A row says how wide it needs to be. fn note_width(ui: &egui::Ui, w: f32) { ui.data_mut(|d| { @@ -75,8 +82,12 @@ fn row_sensed( ) -> egui::Response { let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), sense); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); + if scroll_asked(ui, id) { + ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); + } register(ui.ctx(), format!("source:{id}"), r); + let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; let said = spoken.unwrap_or(label); let name = match count { Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n), @@ -108,7 +119,6 @@ fn row_sensed( let label_left = r.left() + 42.0 + indent; let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); // a label's dot sits just before the count - let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; if let Some(c) = mark { let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0)); ui.painter().circle_filled(dot.center(), 4.0, c); @@ -122,20 +132,47 @@ fn row_sensed( let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); register(ui.ctx(), format!("label:{id}"), label_rect); let resp = if shown != label { resp.on_hover_text(label) } else { resp }; - // a row asks for room for its name up to a share of a panel, so one very long name does not - // make everything scroll: depth does that - let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0 + mark_w; + note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x))); if let Some(galley) = count_galley { let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size()); - needed += rect.width() + 16.0; register(ui.ctx(), format!("count:{id}"), rect); ui.painter().galley(rect.min, galley, t.text_dim); } - note_width(ui, needed); - let _ = app; resp } +/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed +/// for the control channel and asks for its width (so the content does not widen and narrow as +/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders, +/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is +/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`]. +fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option, indent: f32, mark: bool) -> bool { + let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT)); + if ui.is_rect_visible(r) || scroll_asked(ui, id) { + return false; + } + ui.allocate_space(r.size()); + register(ui.ctx(), format!("source:{id}"), r); + let t = Tokens::get(ui.ctx()); + let painter = ui.painter(); + let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; + let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x); + note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w)); + true +} + +/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]). +fn scroll_asked(ui: &egui::Ui, id: &str) -> bool { + ui.data(|d| d.get_temp::(egui::Id::new(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase()) +} + +/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for +/// room for its name up to a share of a panel, so one very long name does not make everything +/// scroll: depth does that. +fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 { + 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) +} + /// The disclosure triangle at the left of a tree row (`indent` is the row's): right-pointing /// when closed, down when open, registered as widget `widget` for the control channel. Returns /// its response: the caller flips the open state on `clicked()`. @@ -266,7 +303,7 @@ fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSec /// `sidebarEmpty:`), so the section stays where it is instead of disappearing. fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover()); register(ui.ctx(), format!("sidebarEmpty:{}", section.id()), r); let text = crate::i18n::tr(section.empty_hint()); let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); @@ -533,7 +570,7 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option(egui::Id::new(SCROLL_TO_ROW))); // room below the last section (a section dragged to the end is dropped here) ui.add_space(SECTION_GAP * 2.0); pinned_header(app, ui, &placed, view_top); @@ -798,7 +838,7 @@ fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { for (i, (name, path)) in local.places.iter().enumerate() { let transient = local.browse_root.as_deref() == Some(path.as_str()); let reveal = local.owner == Some(i); - folder_tree(app, ui, name, path, 0.0, current, reveal, transient); + folder_tree(app, ui, path, name, path, 0.0, current, reveal, transient); } if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() { @@ -990,10 +1030,14 @@ pub(crate) fn fs_cached(ui: &egui::Ui, kind: &'static /// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the /// way down to it open whenever the browsed folder changes, so it shows highlighted in place. /// `transient`: a top-level row listed for this session only (it offers Keep in Local). +/// `place`: the top-level folder this tree hangs from. A folder can be listed in more than one +/// tree (Pictures on its own and inside Home): each row opens and closes by itself, and their +/// triangles have ids of their own, else the copy drawn last takes the other's clicks (#1093). #[allow(clippy::too_many_arguments)] fn folder_tree( app: &mut LightcraftApp, ui: &mut egui::Ui, + place: &str, name: &str, path: &str, indent: f32, @@ -1001,13 +1045,13 @@ fn folder_tree( reveal: bool, transient: bool, ) { - let open_id = egui::Id::new(("folder-open", path.to_string())); + let open_id = egui::Id::new(("folder-open", place.to_string(), path.to_string())); let sel = current.is_some_and(|c| same_folder(c, path)); let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); let mut open = tree_open(ui, open_id, false); if reveal && on_the_way { // opened once per browsed folder: collapsing it again afterwards sticks - let revealed_id = egui::Id::new(("folder-revealed", path.to_string())); + let revealed_id = egui::Id::new(("folder-revealed", place.to_string(), path.to_string())); let target = current.map(str::to_string); if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { open = true; @@ -1026,7 +1070,7 @@ fn folder_tree( resp.context_menu(|ui| { if (transient || indent > 0.0) && ui - .button(if transient { "Keep in Local" } else { "Add to Local" }) + .button(crate::i18n::tr(if transient { "Keep in Local" } else { "Add to Local" })) .on_hover_text(crate::i18n::tr("List this folder in Local from now on")) .clicked() { @@ -1070,7 +1114,7 @@ fn folder_tree( }); if open && indent < 12.0 * 8.0 { for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, &n, &p, indent + 12.0, current, reveal, false); + folder_tree(app, ui, place, &n, &p, indent + 12.0, current, reveal, false); } } } @@ -1527,6 +1571,38 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { Err(e) => app.toast(ui.ctx(), e), } } + // File ▸ Import's ways in; the import review opens with this album chosen + let menu = ui.menu_button(crate::i18n::tr("Import"), |ui| { + let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { + let r = ui.button(label); + crate::widgets::register(ui.ctx(), widget, r.rect); + if r.clicked() { + if let Err(e) = app.run(command, params) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }; + for (label, command, widget) in [ + ("Import Photos…", "file.addPhotos", "button:albumImportPhotos"), + ("Import from Folder…", "file.addFolder", "button:albumImportFolder"), + ] { + entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); + } + // like File ▸ Import from Device: one entry per camera or card found + let device = ui.menu_button(crate::i18n::tr("Import from Device"), |ui| { + let devices = lightcraft_engine::devices::devices(); + if devices.is_empty() { + ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); + } + for (i, d) in devices.into_iter().enumerate() { + let params = json!({"path": d.path, "album": a.id.0}); + entry(ui, format!("{}…", d.name), format!("button:albumImportDevice:{i}"), "file.addFromDevice", params); + } + }); + crate::widgets::register(ui.ctx(), "button:albumImportDevice", device.response.rect); + }); + crate::widgets::register(ui.ctx(), "button:albumImport", menu.response.rect); } if !a.folder && !a.is_smart() { let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); @@ -1661,44 +1737,51 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], let leads_on = n.own == 0 && n.children.len() == 1; let mut open = tree_open(ui, open_id, n.volume || leads_on); // a row whose path would cover other disks' photos too only opens and closes - let selectable = n.selectable; - let sel = selectable - && app.session.source == LibrarySource::LibraryFolder - && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); - let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; - // the label is said with the name, as it is shown beside it - let spoken = match n.label { - Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), - None => name.clone(), - }; - let mark = n.label.map(crate::theme::label_color); + let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; let id = format!("libfolder:{}", n.path); - let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); - let mut toggled = false; - if !n.children.is_empty() { - let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("libraryFolderToggle:{}", n.path)); - toggled = tr.clicked(); - // the triangle sits on the row and takes its clicks: the menu opens from it too - row_menu(app, &tr, n); + if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) { + folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); } - let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; - let resp = resp.on_hover_text(tip); - if resp.clicked() && !toggled { - if selectable { - // a source like an album or a Local folder: it replaces what the grid showed - let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); - } else { - open = !open; - set_tree_open(ui, open_id, open); - } - } - row_menu(app, &resp, n); if open && !n.children.is_empty() { folder_rows(app, ui, &n.children, indent + 16.0); } } } +/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's. +#[allow(clippy::too_many_arguments)] +fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) { + let sel = n.selectable + && app.session.source == LibrarySource::LibraryFolder + && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); + // the label is said with the name, as it is shown beside it + let spoken = match n.label { + Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), + None => name.to_string(), + }; + let mark = n.label.map(crate::theme::label_color); + let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + let mut toggled = false; + if !n.children.is_empty() { + let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); + toggled = tr.clicked(); + // the triangle sits on the row and takes its clicks: the menu opens from it too + row_menu(app, &tr, n); + } + let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) }; + let resp = resp.on_hover_text(tip); + if resp.clicked() && !toggled { + if n.selectable { + // a source like an album or a Local folder: it replaces what the grid showed + let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); + } else { + *open = !*open; + set_tree_open(ui, open_id, *open); + } + } + row_menu(app, &resp, n); +} + /// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the /// startup disk. fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { @@ -1707,6 +1790,7 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } else if n.path != "/" { resp.context_menu(|ui| { folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); synchronize_item(app, ui, n, &n.name); ui.separator(); if ui @@ -1721,6 +1805,19 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } } +/// Locate Missing Files… of a Folders row: choose where this folder moved, then let the existing +/// background finder relink only this folder's photos (including the folders inside it). +fn locate_missing_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let r = ui.button(crate::i18n::tr("Locate Missing Files…")); + register(ui.ctx(), "folderLocateMissing", r.rect); + if r.clicked() { + if let Err(e) = app.run("file.findMissing", json!({"within": n.path})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + /// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which /// scans the folder in the background. fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { @@ -1812,6 +1909,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F ui.close(); } folder_label_menu(app, ui, n); + locate_missing_item(app, ui, n); synchronize_item(app, ui, n, &label); ui.separator(); if ui @@ -1830,44 +1928,53 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F /// context menu renames, merges or deletes the keyword across the library. fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { for n in nodes { - let open_id = egui::Id::new(("kw-open", n.path.to_lowercase())); - let mut open = tree_open(ui, open_id, false); - let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); - let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent); - if !n.children.is_empty() { - tree_toggle(ui, &resp, indent, open_id, &mut open, format!("keywordToggle:{}", n.path)); + // leaves (most keywords) have no open state to look up + let open_id = (!n.children.is_empty()).then(|| egui::Id::new(("kw-open", n.path.to_lowercase()))); + let mut open = open_id.is_some_and(|k| tree_open(ui, k, false)); + let id = format!("keyword:{}", n.path); + if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) { + keyword_row(app, ui, n, &id, indent, open_id, &mut open); } - let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); - if resp.clicked() { - let v = if sel { serde_json::Value::Null } else { json!(n.path) }; - browse_all_photos(app, !sel); - let _ = app.run("library.filter", json!({"keyword": v})); - } - resp.context_menu(|ui| { - let has_sel = app.session.active().is_some(); - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); - } - if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); - } - ui.separator(); - if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); - } - if ui.button(crate::i18n::tr("Merge into…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); - } - if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { - let _ = app.run("keyword.delete", json!({"keyword": n.path})); - } - }); - if open && !n.children.is_empty() { + if open { keyword_rows(app, ui, &n.children, indent + 16.0); } } } +/// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. +fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option, open: &mut bool) { + let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); + let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); + if let Some(k) = open_id { + tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path)); + } + let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(n.path) }; + browse_all_photos(app, !sel); + let _ = app.run("library.filter", json!({"keyword": v})); + } + resp.context_menu(|ui| { + let has_sel = app.session.active().is_some(); + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]})); + } + if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]})); + } + ui.separator(); + if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() }); + } + if ui.button(crate::i18n::tr("Merge into…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() }); + } + if ui.button(crate::i18n::tr("Delete Keyword")).clicked() { + let _ = app.run("keyword.delete", json!({"keyword": n.path})); + } + }); +} + #[cfg(test)] mod tests { use super::local_places; diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..534a207ca 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -197,6 +197,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() { app.ui.mask_overlay = !on; } + let all = app.ui.mask_overlay_all; + let all_mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let all_base = crate::i18n::tr("Show all overlays"); + let all_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.maskOverlayAll") { + Some(sc) => format!("{all_base} ({})", crate::menubar::shortcut_text(sc, all_mac)), + None => all_base.to_string(), + }; + if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, &all_tip).clicked() { + app.ui.mask_overlay_all = !all; + } }); }); if d.masks.is_empty() { @@ -296,7 +306,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (kind, icon) = kind_label(&c.shape); let label = c.name.clone().unwrap_or_else(|| match &c.shape { MaskShape::Prompt { text, .. } => format!("“{text}”"), - _ => kind.to_string(), + _ => menu_label(kind).to_string(), }); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -403,10 +413,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = Stroke::NONE; } +/// Translate fixed action labels, but never mutate or translate user-entered mask names. +fn menu_label(source: &str) -> &str { + crate::i18n::tr(source) +} + /// The right-click menu of a mask in the Masks list. fn mask_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: u32, name: &str, visible: bool, index: usize, count: usize) { let mut run = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, p: serde_json::Value| { - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + if ui.add_enabled(enabled, egui::Button::new(menu_label(label))).clicked() { let _ = app.run(cmd, p); ui.close(); } @@ -439,10 +454,10 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Overlay")).color(t.text_dim)); - let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label); + let r = crate::widgets::dropdown(ui, "maskOverlayMode", menu_label(view.label()), t.font(12.5), t.text_label); egui::Popup::menu(&r).show(|ui| { for v in MaskView::ALL { - if ui.selectable_label(v == view, v.label()).clicked() { + if ui.selectable_label(v == view, menu_label(v.label())).clicked() { let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()})); } } @@ -633,7 +648,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape /// The right-click / "…" menu of one component of the selected mask. fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: usize, label: &str, count: usize) { let mut run = |ui: &mut egui::Ui, text: &str, p: serde_json::Value| { - if ui.button(text).clicked() { + if ui.button(menu_label(text)).clicked() { let mut p = p; p["id"] = json!(mask); p["component"] = json!(k); @@ -799,7 +814,7 @@ fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { if kind == "brush" && op == "intersect" { continue; } - let b = ui.button(label); + let b = ui.button(menu_label(label)); register(ui.ctx(), format!("maskComp:{op}:{kind}"), b.rect); if b.clicked() { if kind == "brush" { @@ -853,6 +868,25 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + #[test] + fn masking_context_actions_use_catalog_translations() { + let saved = crate::i18n::language(); + crate::i18n::set_language(crate::i18n::Locale::Fr); + for (english, translated) in [ + ("Duplicate Mask", "Dupliquer le masque"), + ("Invert Mask", "Inverser le masque"), + ("Hide Mask", "Masquer le masque"), + ("Move Up", "Monter"), + ("Brush", "Pinceau"), + ("Luminance Range", "Plage de luminance"), + ] { + assert_eq!(menu_label(english), translated, "{english}"); + } + // An arbitrary user name has no catalog entry and is left unchanged. + assert_eq!(menu_label("My custom mask"), "My custom mask"); + crate::i18n::set_language(saved); + } + /// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label /// room by dropping a column rather than drawing it past its tile. #[test] diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 619efe1d0..016323e95 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -8,10 +8,36 @@ use crate::theme::Tokens; use crate::widgets::register; use crate::{LightcraftApp, QuitPrompt}; -/// Collect the library's settings-file warnings (each is shown once). +/// Collect the library's settings-file warnings (each is shown once), and the no-GPU notice once the GPU device has +/// been tried. pub fn logic(app: &mut LightcraftApp) { let new = app.session.take_library_warnings(); app.notices.extend(new); + use lightcraft_engine::gpu; + if app.gpu_notice && app.gpu_applied == Some(true) && gpu::ready() { + app.gpu_notice = false; + if !gpu::available() { + app.notices.extend(gpu_notice(gpu::unavailable_reason().as_deref(), app.ui.settings.gpu)); + } + } +} + +/// What to tell the user when GPU rendering is on (`enabled`) but the GPU could not be used for `reason`: only when no +/// graphics adapter was found at all ("no GPU adapter among …", e.g. no Vulkan loader on Linux), which makes every +/// render much slower without saying so. Nothing when it was turned off (preference, `LIGHTCRAFT_GPU`), a software +/// adapter was skipped, or it failed later; Settings ▸ Performance and Help ▸ System Info show those. +pub fn gpu_notice(reason: Option<&str>, enabled: bool) -> Option { + if !enabled || !reason?.starts_with("no GPU adapter") { + return None; + } + let mut text = + crate::i18n::tr("No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.") + .to_string(); + if cfg!(target_os = "linux") { + text.push(' '); + text.push_str(crate::i18n::tr("On NixOS, start it from `nix develop`.")); + } + Some(text) } /// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that @@ -143,3 +169,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod tests { + use super::gpu_notice; + + #[test] + fn the_gpu_notice_is_only_for_a_missing_adapter() { + let none = "no GPU adapter among Backends(VULKAN | GL) (No suitable graphics adapter found)"; + let text = gpu_notice(Some(none), true).expect("no adapter: say so"); + assert!(text.starts_with("No graphics processor found") && text.contains("System Info"), "{text}"); + assert_eq!(text.contains("nix develop"), cfg!(target_os = "linux"), "{text}"); + assert_eq!(gpu_notice(Some(none), false), None, "the preference is off"); + for reason in [ + "disabled by LIGHTCRAFT_GPU=0", + "disabled by LIGHTCRAFT_GPU_BACKEND=off", + "disabled by the GPU rendering preference (app.gpu)", + "software adapter (llvmpipe (LLVM 19.1.7, 256 bits)) skipped: the CPU pipeline is faster", + "stopped after a GPU failure: device lost", + ] { + assert_eq!(gpu_notice(Some(reason), true), None, "{reason}"); + } + assert_eq!(gpu_notice(None, true), None, "the GPU is in use"); + } +} diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 0968ab67d..3d90c00c3 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -4,9 +4,11 @@ //! files. use std::collections::BTreeMap; +use std::sync::Arc; use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use lightcraft_develop::{ControlSpec, Preset, Section, Track}; +use lightcraft_engine::presets::PresetList; use serde_json::json; use crate::icons::{Icon, paint}; @@ -17,6 +19,34 @@ use crate::{HoverPreview, LightcraftApp}; /// Long edge of preset thumbnails (px). const THUMB_EDGE: usize = 128; +/// The preset amount slider may replace only the undo step it created. +#[derive(Clone)] +struct AppliedPreset { + id: String, + amount: f64, + selected: Vec, + active: Option, + commits: u64, +} + +impl AppliedPreset { + fn new(session: &lightcraft_engine::Session, id: String, amount: f64) -> Self { + Self { id, amount, selected: session.selection.ids.clone(), active: session.active(), commits: session.commits() } + } + + fn is_current(&self, session: &lightcraft_engine::Session) -> bool { + self.selected == session.selection.ids + && self.active == session.active() + && self.commits == session.commits() + && session.interaction.is_none() + && session.redo.is_empty() + && session + .undo + .last() + .is_some_and(|entry| session.presets.iter().any(|preset| preset.id == self.id && entry.label == format!("Preset: {}", preset.name))) + } +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)).show(ui, |ui| { @@ -28,10 +58,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); - if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() { + if icon_button( + &mut hdr, + "presetCreate", + Icon::Plus, + vec2(28.0, 28.0), + false, + app.session.active().is_some(), + crate::i18n::tr("Create Preset…"), + ) + .clicked() + { app.ui.dialog = Some(crate::state::Dialog::create_preset()); } - let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options"); + let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, crate::i18n::tr("More preset options")); egui::Popup::menu(&more).show(|ui| { let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails")); register(ui.ctx(), "presetMenu:thumbnails", r.rect); @@ -47,8 +87,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { divider(ui); // amount slider (applies to the last applied preset) let amt_id = egui::Id::new("preset-amount"); - let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id)); - if let Some((pid, amount)) = last.clone() { + let last: Option = ui.data(|d| d.get_temp(amt_id)); + if let Some(last) = last.clone().filter(|last| last.is_current(&app.session)) { let spec = ControlSpec { id: "presetAmount", label: "Amount", @@ -60,29 +100,34 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { decimals: 0, track: Track::Plain, }; - let out = slider(ui, &spec, amount, true, None); - if let Some(v) = out.value { - // re-apply from the pre-preset state: undo last preset step then apply with the new amount - let _ = app.run("edit.undo", json!({})); - let _ = app.run("preset.apply", json!({"id": pid, "amount": v})); - ui.data_mut(|d| d.insert_temp(amt_id, (pid, v))); + let out = slider(ui, &spec, last.amount, true, None); + if let Some(v) = out.value + && app.run("edit.undo", json!({})).is_ok() + { + // Only replace the most recent preset; never undo intervening edits. + if app.run("preset.apply", json!({"id": &last.id, "amount": v})).is_ok() { + ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, last.id, v))); + } else { + let _ = app.run("edit.redo", json!({})); + ui.data_mut(|d| d.remove::(amt_id)); + } } divider(ui); + } else { + ui.data_mut(|d| d.remove::(amt_id)); } let active = app.session.active(); let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); let thumbs = app.ui.preset_thumbs && active.is_some(); let row_h = if thumbs { 54.0 } else { 26.0 }; + let groups = grouped(ui.ctx(), &app.session.presets); let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - let mut groups: BTreeMap> = BTreeMap::new(); - for p in &app.session.presets { - groups.entry(p.group.clone()).or_default().push(p.clone()); - } - for (g, items) in groups { - // A group holding stock presets is a stock group (shown in the UI language), even - // when user presets share its name; a group of user presets keeps its name. - let builtin = items.iter().any(|p| p.builtin); + // Only the rows in view are laid out, drawn and given thumbnails: a Lightroom collection + // imported whole is 10 000+ presets, and building every row each frame took seconds. + let visible = ui.clip_rect().expand(row_h); + for grp in groups.iter() { + let (g, builtin) = (grp.name.clone(), grp.builtin); let open_id = egui::Id::new(("preset-group", &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); @@ -111,11 +156,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !open { continue; } - for pr in items { + let (first, end) = rows_in_view(ui.cursor().top(), row_h, grp.items.len(), visible); + if first > 0 { + ui.allocate_space(vec2(ui.available_width(), first as f32 * row_h)); + } + for &i in grp.items.get(first..end).unwrap_or_default() { + let Some(pr) = app.session.presets.get(i).cloned() else { continue }; let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); - let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); + let is_last = last.as_ref().is_some_and(|l| l.id == pid); if is_last { ui.painter().rect_filled(r, 0.0, t.tool_active); } else if resp.hovered() { @@ -147,9 +197,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label))); } } - if resp.clicked() { - let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); - ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0))); + if resp.clicked() && app.run("preset.apply", json!({"id": pid, "amount": 100})).is_ok() { + ui.data_mut(|d| d.insert_temp(amt_id, AppliedPreset::new(&app.session, pid.clone(), 100.0))); ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); } @@ -175,10 +224,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name"); } ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { - let mut groups: Vec = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect(); - groups.sort(); - groups.dedup(); - for g in groups.iter().filter(|g| **g != group) { + for g in groups.iter().filter(|x| x.user && x.name != group).map(|x| &x.name) { if ui.button(g).clicked() { let _ = app.run("preset.move", json!({"id": pid, "group": g})); } @@ -201,6 +247,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); } + if end < grp.items.len() { + ui.allocate_space(vec2(ui.available_width(), (grp.items.len() - end) as f32 * row_h)); + } } ui.add_space(30.0); }); @@ -221,6 +270,107 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// A preset group as the panel lists it: indices into the session's presets. +struct Group { + name: String, + /// Holds a stock preset: a stock group (shown in the UI language), even when user presets + /// share its name; a group of user presets keeps its name. + builtin: bool, + /// Holds a user preset (a Move to Group target). + user: bool, + items: Vec, +} + +/// The presets by group (groups sorted by name, presets in list order), rebuilt only when the +/// list changed (its generation). +fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc> { + let id = egui::Id::new("presets-grouped"); + if let Some((generation, groups)) = ctx.data(|d| d.get_temp::<(u64, Arc>)>(id)) + && generation == presets.generation() + { + return groups; + } + let mut by_name: BTreeMap<&str, Group> = BTreeMap::new(); + for (i, p) in presets.iter().enumerate() { + let g = by_name.entry(&p.group).or_insert_with(|| Group { name: p.group.clone(), builtin: false, user: false, items: Vec::new() }); + g.builtin |= p.builtin; + g.user |= !p.builtin; + g.items.push(i); + } + let groups = Arc::new(by_name.into_values().collect::>()); + ctx.data_mut(|d| d.insert_temp(id, (presets.generation(), groups.clone()))); + groups +} + +/// The presets as the rows of a picker (a combo box's list), in list order with a header row +/// wherever the group changes. Only the rows in view are built (10 000+ presets made an open +/// picker take seconds a frame). Returns the id of the preset clicked. +pub fn picker_rows(ui: &mut egui::Ui, salt: &str, presets: &PresetList, selected: Option<&str>) -> Option { + let id = egui::Id::new("preset-picker-rows"); + let rows = match ui.ctx().data(|d| d.get_temp::<(u64, Arc>)>(id)) { + Some((generation, rows)) if generation == presets.generation() => rows, + _ => { + let mut rows = Vec::with_capacity(presets.len() + 16); + let mut group: Option<&str> = None; + for (i, p) in presets.iter().enumerate() { + if group != Some(p.group.as_str()) { + group = Some(&p.group); + rows.push(PickRow::Header(crate::i18n::builtin_label(&p.group, p.builtin).to_string())); + } + rows.push(PickRow::Preset(i)); + } + let rows = Arc::new(rows); + ui.ctx().data_mut(|d| d.insert_temp(id, (presets.generation(), rows.clone()))); + rows + } + }; + let row_h = ui.spacing().interact_size.y; + let mut out = None; + egui::ScrollArea::vertical().id_salt(("preset-picker", salt)).max_height(300.0).auto_shrink([false, true]).show_rows( + ui, + row_h, + rows.len(), + |ui, range| { + for row in rows.get(range).unwrap_or_default() { + let size = vec2(ui.available_width(), row_h); + match row { + PickRow::Header(g) => { + ui.add_sized(size, egui::Label::new(egui::RichText::new(g).size(10.5).weak())); + } + PickRow::Preset(i) => { + let Some(p) = presets.get(*i) else { continue }; + let label = crate::i18n::builtin_label(&p.name, p.builtin); + if ui.add_sized(size, egui::Button::selectable(selected == Some(p.id.as_str()), label)).clicked() { + out = Some(p.id.clone()); + } + } + } + } + }, + ); + out +} + +#[derive(Clone)] +enum PickRow { + /// A group's name (in the UI language for stock groups). + Header(String), + /// An index into the session's presets. + Preset(usize), +} + +/// The rows `first..end` of a block of `n` rows `row_h` high starting at `top` that meet +/// `visible`. +fn rows_in_view(top: f32, row_h: f32, n: usize, visible: Rect) -> (usize, usize) { + if row_h <= 0.0 || !top.is_finite() { + return (0, n); + } + // `as usize` saturates (negative and NaN give 0) + let first = (((visible.top() - top) / row_h).floor() as usize).min(n); + let end = (((visible.bottom() - top) / row_h).ceil() as usize).clamp(first, n); + (first, end) +} + /// The part of a `size` image that covers `r` (centre crop), as texture coordinates. pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { let a = size[0] as f32 / size[1].max(1) as f32; @@ -233,3 +383,32 @@ pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn amount_slider_does_not_undo_a_later_edit() { + let mut session = lightcraft_engine::Session::with_demo(); + let pid = "lc.golden-hour"; + session.execute("preset.apply", &json!({"id": pid})).unwrap(); + let state = AppliedPreset::new(&session, pid.into(), 100.0); + assert!(state.is_current(&session)); + + session.execute("develop.set", &json!({"values": {"light.exposure": 0.75}})).unwrap(); + assert!(!state.is_current(&session), "a newer edit owns the undo step"); + } + + #[test] + fn amount_slider_does_not_undo_on_another_photo() { + let mut session = lightcraft_engine::Session::with_demo(); + let pid = "lc.golden-hour"; + session.execute("preset.apply", &json!({"id": pid})).unwrap(); + let state = AppliedPreset::new(&session, pid.into(), 100.0); + let active = session.active(); + let other = session.visible().iter().copied().find(|id| Some(*id) != active).unwrap(); + session.execute("library.select", &json!({"ids": [other.0]})).unwrap(); + assert!(!state.is_current(&session), "the preset belongs to the earlier selection"); + } +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 501a78c5e..8d0cc0d3a 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -114,9 +114,11 @@ fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Custom")); let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width")); register(ui.ctx(), "field:cropCustomW", a.rect); ui.label("×"); let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0)); + crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height")); register(ui.ctx(), "field:cropCustomH", b.rect); let go = text_button(ui, "cropCustomApply", "Apply", false); apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter))); @@ -362,7 +364,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.toast( ui.ctx(), if n == 0 { - "No dust spots found".to_string() + crate::i18n::tr("No dust spots found").to_string() } else { crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n) }, @@ -385,7 +387,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { lightcraft_develop::SpotMode::Remove => "Remove", lightcraft_develop::SpotMode::Fill => "Content-Aware", }; - super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = mode)); + super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = crate::i18n::tr(mode))); } let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec { id, @@ -644,6 +646,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim)); let mut text: String = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| gps.clone()); let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("latitude, longitude")).desired_width(f32::INFINITY)); + crate::widgets::screen_reader_name(ui.ctx(), r.id, "GPS"); register(ui.ctx(), "field:gps", r.rect); if r.has_focus() { ui.data_mut(|d| d.insert_temp(gid, text.clone())); @@ -796,7 +799,7 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str } else { crate::text_field::TextField::singleline(&widget, &mut text) }; - let r = field.width(f32::INFINITY).show(ui); + let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui); // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) if r.editing { ui.data_mut(|d| d.insert_temp(id, text.clone())); @@ -1105,6 +1108,23 @@ fn short_time(iso: &str) -> String { } } +/// How a History row is drawn: the step the settings are at is highlighted, the steps after it +/// (still there to return to until a new edit) are greyed out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HistoryRow { + Done, + Current, + Undone, +} + +fn history_row(i: usize, cursor: Option) -> HistoryRow { + match cursor { + Some(c) if i == c => HistoryRow::Current, + Some(c) if i > c => HistoryRow::Undone, + _ => HistoryRow::Done, + } +} + fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; header(ui, "History"); @@ -1113,19 +1133,24 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if p.history.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); } + let cursor = p.history_index(); for (i, h) in p.history.iter().enumerate().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); register(ui.ctx(), format!("history:{i}"), r); - if resp.hovered() { + let row = history_row(i, cursor); + if row == HistoryRow::Current { + ui.painter().rect_filled(r, 3.0, t.pressed); + } else if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon); + let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon); ui.painter().text( pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::history_label(&h.label, &app.session.presets), t.font(12.5), - t.text_label, + text, ); if resp.clicked() { let _ = app.run("history.restore", json!({"index": i})); @@ -1149,6 +1174,16 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { #[cfg(test)] mod tests { + // Feature: the History panel greys out the steps after the cursor + #[test] + fn history_rows_after_the_cursor_are_greyed() { + use super::{HistoryRow, history_row}; + let rows: Vec<_> = (0..4).map(|i| history_row(i, Some(1))).collect(); + assert_eq!(rows, [HistoryRow::Done, HistoryRow::Current, HistoryRow::Undone, HistoryRow::Undone]); + assert_eq!(history_row(3, Some(3)), HistoryRow::Current, "at the latest step nothing is greyed"); + assert_eq!(history_row(0, None), HistoryRow::Done); + } + // Feature: the aspect button tells what is locked #[test] fn aspect_label_names_presets_in_either_orientation() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..e2b1e1376 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -165,6 +165,13 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); + hint( + ui, + t, + crate::i18n::tr( + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).", + ), + ); } /// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). @@ -203,15 +210,8 @@ fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Optio if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { out = Some(None); } - let mut group = ""; - for p in &app.session.presets { - if p.group != group { - group = &p.group; - ui.label(RichText::new(crate::i18n::builtin_label(group, p.builtin)).size(10.5).weak()); - } - if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() { - out = Some(Some(p.id.clone())); - } + if let Some(pid) = crate::panels::presets::picker_rows(ui, id, &app.session.presets, current) { + out = Some(Some(pid)); } }); register(ui.ctx(), format!("combo:{id}"), r.response.rect); @@ -283,10 +283,8 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if ui.selectable_label(current.is_none(), crate::i18n::tr("LightCraft Default")).clicked() { pick = Some(json!({"camera": cam, "preset": null})); } - for p in &app.session.presets { - if ui.selectable_label(current == Some(p.id.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)).clicked() { - pick = Some(json!({"camera": cam, "preset": p.id})); - } + if let Some(pid) = crate::panels::presets::picker_rows(ui, &id, &app.session.presets, current) { + pick = Some(json!({"camera": cam, "preset": pid})); } }); register(ui.ctx(), format!("combo:{id}"), r.response.rect); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index a95805f83..03810aca9 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -63,14 +63,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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() { + if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) + .clicked() + { let _ = app.run("view.leftPanel", json!({})); } ui.add_space(8.0); - if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, "Back").clicked() { + if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, crate::i18n::tr("Back")).clicked() { let _ = app.run("view.back", json!({})); } - icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward"); + icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, crate::i18n::tr("Forward")); if !app.native_menu { // no native menu bar (web, Windows, Linux): menus in the top bar ui.add_space(10.0); @@ -103,6 +105,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); } let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) + .label(crate::i18n::tr("Search Photos")) .frame(false) .width(sr.width() - 20.0) .font(t.font(13.5)) @@ -140,7 +143,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let fresp = fresp.on_hover_text(if active > 0 { crate::i18n::tr_format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }, active = active) } else { - "Filter bar".into() + crate::i18n::tr("Filter bar").into() }); if fresp.clicked() { let _ = app.run("view.filterBar", json!({})); @@ -166,7 +169,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("bell", Icon::Bell, "Activity", "panel.activity"), ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); - let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); register(ui.ctx(), format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..64b4a4181 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -11,7 +11,9 @@ use lightcraft_engine::pipeline::PixelWindow; /// Windows start and end on multiples of this many pixels of the zoomed frame. pub const SNAP: usize = 256; /// The most context rendered beyond the visible pixels (a multiple of [`SNAP`]). -pub const MAX_MARGIN: usize = 768; +/// 4.5 % of an 11:1 view of a 24 MP frame, snapped to [`SNAP`]. The old 768 px cap left Dehaze +/// tens of codes off the full-frame render from 4:1 up (#1146). +pub const MAX_MARGIN: usize = 3072; /// The most a window may span on either axis: a bound on its texture whatever the window size. pub const MAX_SPAN: usize = if cfg!(target_arch = "wasm32") { 3072 } else { 6144 }; @@ -157,28 +159,39 @@ impl SizeHold { } } +/// Where the line of a wipe crosses: the middle of the part of the photo that is on screen. At +/// Fit that is the photo's middle; zoomed in, the photo's own middle is mostly off screen, and a +/// line there left the whole view to one side (#1351). +pub fn wipe_line(img: egui::Rect, canvas: egui::Rect) -> egui::Pos2 { + let shown = img.intersect(canvas); + if shown.is_positive() { shown.center() } else { img.center() } +} + /// Where a window of one side of a Before/After view may be drawn: its own pane in side by side /// and stacked views (a window carries margin beyond what its pane shows, which would otherwise /// spill into the neighbour), its side of the line in a wipe, else the canvas. pub fn window_clip(mode: crate::state::BeforeAfter, before_side: bool, pane: egui::Rect, canvas: egui::Rect, img: egui::Rect) -> egui::Rect { use crate::state::BeforeAfter; let (min, max) = (canvas.min, canvas.max); + let line = wipe_line(img, canvas); let clip = match (mode, before_side) { - (BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(img.center().x, max.y)), - (BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, img.center().y)), - (BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(img.center().x, min.y), max), - (BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, img.center().y), max), + (BeforeAfter::Split, true) => egui::Rect::from_min_max(min, egui::pos2(line.x, max.y)), + (BeforeAfter::SplitTopBottom, true) => egui::Rect::from_min_max(min, egui::pos2(max.x, line.y)), + (BeforeAfter::Split, false) => egui::Rect::from_min_max(egui::pos2(line.x, min.y), max), + (BeforeAfter::SplitTopBottom, false) => egui::Rect::from_min_max(egui::pos2(min.x, line.y), max), (BeforeAfter::SideBySide | BeforeAfter::TopBottom, _) => pane, _ => canvas, }; clip.intersect(canvas) } -/// Whether the Before window waits for the After's. Both are cut from the same decoded original; -/// asked for together while it isn't held yet, each worker decodes its own copy (two full-size -/// decodes, two uploads, one thrown away). The Before follows once the original is in. -pub fn defer_before_window(before_side: bool, after_pending: bool, original_held: bool) -> bool { - before_side && after_pending && !original_held +/// Whether a window waits for the one already being rendered. All windows are cut from the same +/// decoded original; asked for while it isn't held yet (the Before and After together, in either +/// order, or a new window while the first is still decoding), each worker decodes its own copy +/// (two or three full-size decodes and uploads, all but one thrown away). The waiting window +/// follows once the original is in. +pub fn defer_window(window_pending: bool, original_held: bool) -> bool { + window_pending && !original_held } /// Whether the picture on screen has the frame's aspect (to the one pixel a texture's whole-pixel @@ -423,18 +436,21 @@ mod tests { assert_eq!((before.max.x, after.min.x), (500.0, 500.0), "the line is the image's middle"); assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, img).max.y, 300.0); assert_eq!(window_clip(SplitTopBottom, false, canvas, canvas, img).min.y, 300.0); + // zoomed in, the photo's middle (x = 1600) is off screen: the line is the view's middle + let zoomed = egui::Rect::from_min_max(egui::pos2(-200.0, -900.0), egui::pos2(3400.0, 1500.0)); + assert_eq!(window_clip(Split, false, canvas, canvas, zoomed).min.x, 500.0); + assert_eq!(window_clip(SplitTopBottom, true, canvas, canvas, zoomed).max.y, 300.0); assert_eq!(window_clip(Off, false, canvas, canvas, img), canvas); assert_eq!(window_clip(Original, true, canvas, canvas, img), canvas); } - // Given Before and After windows asked for at once at first open, only the After's goes: the - // Before's waits for the decoded original instead of decoding another copy + // Given a window asked for while another is rendering and the original isn't decoded yet, it + // waits for the decoded original instead of decoding another copy #[test] - fn the_before_window_waits_for_the_original_the_after_is_decoding() { - assert!(defer_before_window(true, true, false)); - assert!(!defer_before_window(true, true, true), "the original is in"); - assert!(!defer_before_window(true, false, false), "nothing is decoding it"); - assert!(!defer_before_window(false, true, false), "the After never waits"); + fn a_window_waits_for_the_original_another_is_decoding() { + assert!(defer_window(true, false)); + assert!(!defer_window(true, true), "the original is in"); + assert!(!defer_window(false, false), "nothing is decoding it"); } // Hostile numbers give a plan, never a panic @@ -484,7 +500,8 @@ mod tests { fn the_margin_grows_with_the_zoom_up_to_a_cap() { assert_eq!(margin_for(1000), SNAP, "never less than one grid step"); assert!(margin_for(8000) > margin_for(3000)); - assert_eq!(margin_for(48_000), MAX_MARGIN); + assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); + assert_eq!(margin_for(80_000), MAX_MARGIN); assert_eq!(margin_for(0), SNAP); assert!(margin_for(usize::MAX) <= MAX_MARGIN); for l in [100, 3000, 8000, 48_000, 400_000] { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..fd0f0e9bf 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -55,6 +55,9 @@ pub enum Slot { Import(u32), /// The second window's view of the active photo. Second, + /// A slider drag's live HDR histogram: a small full-quality render of an HDR edit, beside the + /// drafts (which have no HDR histogram), with no texture ([`Renderer::live_hdr_histogram`]). + HdrHistogram, } pub struct Tex { @@ -187,6 +190,8 @@ pub struct Renderer { /// Slot → (key, priority) of the request in flight (queued or running). pending: HashMap, pub textures: HashMap, + /// The last [`Slot::HdrHistogram`] result: (photo, job key, histogram). + pub live_hdr_histogram: Option<(PhotoId, u64, Histogram)>, pub last_main_ms: f64, /// Jobs finished since start (for inspect/perf). pub completed: u64, @@ -197,9 +202,10 @@ pub struct Renderer { stages: HashMap>, /// Slot → key of the last quick job requested for it (each is tried once). quick_tried: HashMap, - /// Jobs that failed (unreadable or missing file…), by slot and job key, with the error: not - /// requested again until the key changes (an edit, another size) — the grid asks every frame. - failed: HashMap, + /// Jobs that failed (unreadable or missing file…), by slot and job key, with the error and + /// the photo: not requested again until the key changes (an edit, another size) — the grid + /// asks every frame. + failed: HashMap, /// The catalog revision seen at the last poll: a failure is retried once the catalog changes /// (a relinked or re-imported file). catalog_rev: u64, @@ -231,7 +237,7 @@ impl Slot { /// An interactive view of the open photo: slider drags redo only the stages they feed, and a /// draft that finishes late still replaces older pixels. pub fn is_view(self) -> bool { - matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover) + matches!(self, Slot::Main | Slot::Region | Slot::RegionBefore | Slot::Before | Slot::Hover | Slot::HdrHistogram) } } @@ -262,6 +268,7 @@ impl Renderer { seq: 0, pending: HashMap::new(), textures: HashMap::new(), + live_hdr_histogram: None, last_main_ms: 0.0, completed: 0, keep_pixels: false, @@ -541,6 +548,18 @@ impl Renderer { self.failed.get(&slot).map(|f| f.1.as_str()) } + /// Why the last render of `photo` for `slot` failed: a view slot shows one photo after + /// another, and another photo's error isn't this one's. + pub fn failure_of(&self, slot: Slot, photo: PhotoId) -> Option<&str> { + self.failed.get(&slot).filter(|f| f.3 == photo).map(|f| f.1.as_str()) + } + + /// Whether `slot`'s pending render asks again for the key that last failed (a library change + /// retries failures): what is on screen stays what the failure left until it answers. + pub fn retrying(&self, slot: Slot) -> bool { + self.pending.get(&slot).zip(self.failed.get(&slot)).is_some_and(|(p, f)| p.0 == f.0) + } + /// The slots with a render pending (diagnostics: `ui.inspect`). pub fn pending_slots(&self) -> Vec { self.pending.keys().map(|s| format!("{s:?}")).collect() @@ -649,6 +668,37 @@ impl Renderer { }; #[cfg(not(target_arch = "wasm32"))] let inline_ms = 0.0; + let changed = self.collect(ctx, session, inline_ms); + self.evict_variants(); + #[cfg(not(target_arch = "wasm32"))] + self.trim_when_idle(ctx); + if !self.pending.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + changed + } + + /// In a slider drag, give the loupe's draft (just requested) until `budget` to finish, so that it + /// is drawn in this frame rather than picked up at the start of the next one. Only when the last + /// loupe render took well under the budget (a GPU draft takes a few milliseconds; a CPU render + /// that would only make the frame late is not waited for). + #[cfg(not(target_arch = "wasm32"))] + pub fn wait_for_draft(&mut self, ctx: &egui::Context, session: &mut Session, budget: std::time::Duration) { + if self.offload.is_some() || self.last_main_ms * 1.5 >= budget.as_secs_f64() * 1e3 { + return; + } + let Some(deadline) = std::time::Instant::now().checked_add(budget) else { return }; + while self.is_pending(Slot::Main) { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() || !self.pool.wait(left) { + break; + } + self.collect(ctx, session, 0.0); + } + } + + /// Turn finished jobs into textures. Returns true if anything changed. + fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool { let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms })); @@ -700,7 +750,7 @@ impl Renderer { Err(e) => { if !matches!(slot, Slot::Prefetch(_)) { log::warn!("render {slot:?}: {e}"); - self.failed.insert(slot, (r.key, e, self.catalog_rev)); + self.failed.insert(slot, (r.key, e, self.catalog_rev, r.photo)); } continue; } @@ -708,6 +758,13 @@ impl Renderer { if matches!(slot, Slot::Prefetch(_)) { continue; } + if slot == Slot::HdrHistogram { + if rendered.histogram.hdr.is_some() { + self.live_hdr_histogram = Some((r.photo, r.key, rendered.histogram)); + changed = true; + } + continue; + } if let Slot::ThumbQuick(id) = slot { if self.textures.contains_key(&Slot::Thumb(id)) { continue; // the real thumbnail won the race @@ -757,20 +814,16 @@ impl Renderer { } changed = true; } - self.evict_variants(); - #[cfg(not(target_arch = "wasm32"))] - self.trim_when_idle(ctx); - if !self.pending.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } changed } /// Free the GPU renderer's recycled buffers once nothing has rendered for [`IDLE_TRIM`] (they - /// are reallocated by the next render; while working, renders reuse them). + /// are reallocated by the next render; while working, renders reuse them). A zoomed view + /// showing its windows is not idle: the device copy of the original they are cut from stays for + /// the next pan (a 64 MP raw is 800 MB to upload again, over a second). #[cfg(not(target_arch = "wasm32"))] fn trim_when_idle(&mut self, ctx: &egui::Context) { - if !self.pending.is_empty() { + if !self.pending.is_empty() || self.textures.contains_key(&Slot::Region) || self.textures.contains_key(&Slot::RegionBefore) { self.idle = None; return; } @@ -947,12 +1000,36 @@ mod thumbnail_tests { r.remember_thumb(&p, 384, 77, false); r.pending.insert(Slot::ThumbQuick(p.id), (77, 11)); assert!(!r.thumb_current(&p, 384, 10)); - r.failed.insert(slot, (77, "no decoder".into(), 0)); + r.failed.insert(slot, (77, "no decoder".into(), 0, p.id)); assert!(r.thumb_current(&p, 384, 10)); + assert_eq!(r.failure_of(slot, p.id), Some("no decoder")); + assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error"); r.catalog_rev = 1; assert!(!r.thumb_current(&p, 384, 10)); } + #[test] + fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { + let mut s = Session::new(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); + s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let mut r = Renderer::with_worker_threads(1); + let ctx = egui::Context::default(); + let budget = std::time::Duration::from_secs(10); + // the last loupe render took longer than the budget (a CPU render): not waited for + r.last_main_ms = 1e6; + r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); + // a fast one (GPU drafts): the texture is the new render when the wait returns + r.last_main_ms = 1.0; + r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); + let want = r.wanted(Slot::Main); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(!r.is_pending(Slot::Main)); + assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); + } + #[derive(Default)] struct Work { jobs: Vec<(Slot, RenderJob)>, @@ -1015,6 +1092,26 @@ mod thumbnail_tests { assert!(work.borrow().jobs.is_empty()); } + #[test] + fn a_slider_drag_leaves_the_edited_photos_thumbnail_until_it_ends() { + let (mut app, work, ctx) = fixture(); + request(&mut app); + let (slot, job) = take_job(&work); + finish(&mut app, &work, &ctx, slot, job.run()); + app.session.execute("library.select", &serde_json::json!({"ids": [1], "active": 1})).unwrap(); + app.session.execute("develop.beginInteraction", &serde_json::json!({"label": "Exposure"})).unwrap(); + for v in [0.1, 0.2, 0.3] { + app.session.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": v})).unwrap(); + app.renderer.poll(&ctx, &mut app.session); + request(&mut app); + } + assert_eq!(app.renderer.thumb_jobs_built, 1, "the drag's frames render no thumbnail of the photo"); + assert!(work.borrow().jobs.is_empty()); + app.session.execute("develop.endInteraction", &serde_json::json!({})).unwrap(); + request(&mut app); + assert_eq!(app.renderer.thumb_jobs_built, 2, "the thumbnail follows once the drag ends"); + } + #[test] fn unchanged_queued_frames_build_no_jobs() { let (mut app, _, _) = fixture(); @@ -1057,6 +1154,39 @@ mod thumbnail_tests { let _ = std::fs::remove_dir_all(&lib); } + /// A render that panics (a decoder bug) is a failure like any other: the slot stops waiting, + /// the error is kept (not retried every frame), and the pool's workers live on. Before, the + /// worker died without an answer and the slot stayed pending forever. + #[test] + fn a_panicking_render_is_a_failure_not_a_dead_worker() { + let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); + app.session.media.file_loader = Some(Arc::new(|_, _| panic!("decoder bug"))); + app.renderer.offload = None; // the real job pool + let slot = Slot::Thumb(PhotoId(1)); + let ask = |app: &mut crate::LightcraftApp| { + if let Some(job) = app.session.thumb_job(PhotoId(1), 256) { + app.renderer.request(slot, job, 30); + } + app.renderer.poll(&ctx, &mut app.session); + }; + ask(&mut app); + let t0 = std::time::Instant::now(); + while app.renderer.is_pending(slot) { + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.renderer.poll(&ctx, &mut app.session); + } + assert_eq!(app.renderer.failure(slot), Some("the render failed unexpectedly: decoder bug")); + let workers = app.renderer.live_workers(); + assert!(workers > 0, "the worker that panicked is still there"); + let done = app.renderer.completed; + for _ in 0..30 { + ask(&mut app); + } + assert!(!app.renderer.is_pending(slot) && app.renderer.completed == done, "no retry loop"); + assert_eq!(app.renderer.live_workers(), workers); + } + #[test] fn raw_quick_path_frames_build_no_jobs() { // an unedited raw: the grid shows its embedded preview (held here until released) and stops @@ -1161,6 +1291,33 @@ mod thumbnail_tests { assert!(!app.renderer.is_pending(Slot::Main)); } + #[test] + fn a_drag_gets_its_live_hdr_histogram_beside_the_drafts() { + let (mut app, work, ctx) = fixture(); + let mut jobs = Vec::new(); + for exposure in [1.0, 2.0] { + let mut edit = lightcraft_develop::DevelopSettings::default(); + edit.hdr.enabled = true; + edit.light.exposure = exposure; + app.session.catalog.apply(Op::SetDevelop { id: PhotoId(1), settings: Arc::new(edit), label: "drag".into(), edited: None }).unwrap(); + let job = app.session.render_job(PhotoId(1), 32, 32, false, true).unwrap(); + app.renderer.request(Slot::HdrHistogram, job, 95); + jobs.push(take_job(&work).1); + } + let keys: Vec = jobs.iter().map(|j| j.key).collect(); + let mut jobs = jobs.into_iter(); + let (first, last) = (jobs.next().unwrap(), jobs.next().unwrap()); + // superseded while the drag goes on, it still lands: the histogram keeps moving + finish(&mut app, &work, &ctx, Slot::HdrHistogram, first.run()); + let live = app.renderer.live_hdr_histogram.clone().expect("a live HDR histogram"); + assert_eq!((live.0, live.1), (PhotoId(1), keys[0])); + assert!(live.2.hdr.is_some(), "HDR bins"); + finish(&mut app, &work, &ctx, Slot::HdrHistogram, last.run()); + assert_eq!(app.renderer.live_hdr_histogram.as_ref().map(|l| l.1), Some(keys[1])); + // only the histogram is kept: no texture + assert!(!app.renderer.textures.contains_key(&Slot::HdrHistogram)); + } + #[test] fn library_reset_rejects_old_job_even_when_photo_id_and_key_are_reused() { let (mut app, work, ctx) = fixture(); @@ -1291,6 +1448,25 @@ mod stage_budget_tests { const MB: usize = 1 << 20; + // Given a zoomed view showing a window, the app idling does not free the GPU copy of the + // original the next window is cut from; once the windows are gone the idle trim runs again + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_zoomed_view_is_not_trimmed_while_idle() { + let ctx = egui::Context::default(); + let mut r = Renderer::default(); + let tex = ctx.load_texture("window", egui::ColorImage::new([1, 1], vec![egui::Color32::BLACK]), Default::default()); + r.textures.insert(Slot::Region, Tex { key: 1, photo: PhotoId(1), tex, size: [1, 1], histogram: None, ms: 0.0, quick: None, pixels: None }); + let long_ago = std::time::Instant::now().checked_sub(IDLE_TRIM * 2).unwrap(); + r.idle = Some((long_ago, false)); + r.trim_when_idle(&ctx); + assert_eq!(r.idle, None, "a zoomed view is not idle"); + r.release(Slot::Region); + r.idle = Some((long_ago, false)); + r.trim_when_idle(&ctx); + assert_eq!(r.idle, Some((long_ago, true)), "trimmed once the window is gone"); + } + // Given stage caches within the budget, nothing is cleared #[test] fn nothing_is_trimmed_within_the_budget() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9e..1a21ab2fc 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -198,6 +198,10 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("Shift+E", "dialog.export", "{}"), ("Cmd+E", "app.exportPrevious", "{}"), ("Space", "view.zoomToggle", "{}"), + // Lightroom Classic: F6 toggles the filmstrip, F7 the left library panel. + // Keep existing primary shortcuts and honour custom keymap assignments. + ("F6", "view.filmstrip", "{}"), + ("F7", "view.leftPanel", "{}"), ("Shift+M", "version.create", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#), @@ -532,6 +536,25 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { continue; } } + // Up / Down in the grids go to the cell above / below (the filmstrip steps one photo) + if matches!(f.as_str(), "library.up" | "library.down") { + // over a slider the arrows nudge it (widgets::slider takes the key) + let over_slider = + ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p))); + if over_slider { + continue; + } + match crate::panels::grid::row_distance(app, f == "library.down") { + Some(0) => {} + Some(rows) => { + let _ = app.run(&f, json!({"rows": rows})); + } + None => { + let _ = app.run(&f, json!({})); + } + } + continue; + } // an export that can't start (e.g. no folder) says why instead of doing nothing if let Err(e) = app.run(&f, json!({})) && matches!(f.as_str(), "app.export" | "app.exportPrevious") @@ -620,6 +643,20 @@ mod tests { } } + #[test] + fn classic_f6_and_f7_toggle_the_filmstrip_and_left_panel() { + let mut h = library(); + let (filmstrip, left) = (h.app.ui.filmstrip, h.app.ui.left_panel); + key(&mut h, "F6", false); + assert_eq!(h.app.ui.filmstrip, !filmstrip); + assert_eq!(h.app.ui.left_panel, left); + key(&mut h, "F7", false); + assert_eq!(h.app.ui.left_panel, !left); + key(&mut h, "F6", false); + key(&mut h, "F7", false); + assert_eq!((h.app.ui.filmstrip, h.app.ui.left_panel), (filmstrip, left)); + } + #[test] fn shift_culling_keys_advance_exactly_once() { use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..6c4dd3f6a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -234,7 +234,9 @@ pub struct AppSettings { /// [`RECENT_LIBRARIES`]). pub recent_libraries: Vec, pub startup_view: StartupView, - /// Ask before moving photos to Recently Deleted (keyboard and menu). + /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved + /// `confirmDelete: false` by default (#1239). + #[serde(rename = "askBeforeDelete")] pub confirm_delete: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, @@ -281,7 +283,7 @@ impl Default for AppSettings { library_path: String::new(), recent_libraries: Vec::new(), startup_view: StartupView::Last, - confirm_delete: false, + confirm_delete: true, gpu: true, preview_limit: 0, external_editor: String::new(), @@ -542,6 +544,9 @@ pub struct UiState { pub mask_overlay_color: [u8; 3], pub mask_overlay_opacity: f32, pub mask_pins: bool, + /// Masking: show every visible mask's region as one overlay at once (Ctrl+Shift+O / Cmd+Shift+O), + /// instead of just the selected mask's ([`mask_overlay`]). + pub mask_overlay_all: bool, /// The overlay (on, mode) to go back to when Show Luminance Map is turned off. #[serde(skip)] pub luminance_map_restore: Option<(bool, String)>, @@ -766,12 +771,15 @@ pub enum Dialog { ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, - /// `parent`: the folder to create it in (none: the top level). + /// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album + /// holds the selected photos (Lightroom's "Include selected photos"; folders hold none). NewAlbum { name: String, folder: bool, #[serde(default)] parent: Option, + #[serde(default = "yes")] + include_selected: bool, }, RenameAlbum { id: u64, @@ -1054,6 +1062,7 @@ impl Default for UiState { mask_overlay_color: [230, 30, 40], mask_overlay_opacity: 50.0, mask_pins: true, + mask_overlay_all: false, crop_overlay: CropOverlay::Thirds, crop_default_aspect: None, crop_overlay_orient: 0, diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs index 947e6cf9f..3703f0219 100644 --- a/crates/ui-egui/src/tests_appearance.rs +++ b/crates/ui-egui/src/tests_appearance.rs @@ -27,15 +27,28 @@ fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { assert_eq!((s.appearance_mode, s.dark_theme, s.light_theme), (AppearanceMode::Dark, DarkTheme::Charcoal, LightTheme::Silver)); assert_eq!(s.theme(Some(egui::Theme::Light)), ThemeKind::Charcoal, "Auto is opt-in"); // a ui.json saved before appearance modes existed - let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "confirmDelete": true}}"#).unwrap(); + let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "startupView": "detail"}}"#).unwrap(); assert_eq!(old.settings.appearance_mode, AppearanceMode::Dark); assert_eq!(old.settings.dark_theme, DarkTheme::Charcoal); assert_eq!(old.settings.light_theme, LightTheme::Silver); - assert!(old.settings.confirm_delete); + assert_eq!(old.settings.startup_view, crate::state::StartupView::Detail); // and the Charcoal tokens are the ones LightCraft always had assert_eq!(crate::theme::Tokens::for_kind(ThemeKind::Charcoal).chrome, crate::theme::Tokens::default().chrome); } +/// Delete asks first by default (#1239). Earlier versions saved `"confirmDelete": false` for +/// everyone who never opened Settings, so that key is not read any more: those installs ask too. +/// A choice made from now on survives a restart. +#[test] +fn delete_asks_first_by_default_also_for_settings_saved_by_earlier_versions() { + let old: UiState = serde_json::from_str(r#"{"settings": {"confirmDelete": false}}"#).unwrap(); + assert!(old.settings.confirm_delete, "the old default does not turn the question off"); + let mut ui = UiState::default(); + ui.settings.confirm_delete = false; + let back: UiState = serde_json::from_str(&serde_json::to_string(&ui).unwrap()).unwrap(); + assert!(!back.settings.confirm_delete, "a choice made now is kept"); +} + #[test] fn choices_validate_and_survive_a_restart() { let mut a = app(); diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 96fab9cbe..ae35a35b2 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -69,3 +69,66 @@ fn wb_selector_picks_the_same_point_in_the_before_view() { } } } + +/// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. +fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { + let mut sum = [0.0f32; 3]; + let mut n = 0.0; + for y in (at.y as usize - 20)..=(at.y as usize + 20) { + for x in (at.x as usize - 20)..=(at.x as usize + 20) { + let c = shot[(x, y)]; + sum[0] += f32::from(c.r()); + sum[1] += f32::from(c.g()); + sum[2] += f32::from(c.b()); + n += 1.0; + } + } + sum.map(|s| s / n) +} + +/// Zoomed in to compare a detail, every Before / After view still shows the edited photo on its +/// After side. A wipe drew its line through the middle of the photo, which zoomed in is mostly off +/// screen: the whole view was the Before, the original photo (#1351). +#[test] +fn zoomed_in_the_after_side_shows_the_edits() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 1.5}}), T); + assert_eq!(r["ok"], true, "{r}"); + let shot = |h: &mut Headless, mode: BeforeAfter, zoom: Zoom| { + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + // (far enough from the middle of the photo that, zoomed in, it is off screen) + h.app.ui.pan = (0.25, 0.75); + h.settle(SETTLE); + h.paint() + }; + for zoom in [Zoom::Fit, Zoom::Percent(100.0), Zoom::Percent(400.0)] { + let (edited, original) = (shot(&mut h, BeforeAfter::Off, zoom), shot(&mut h, BeforeAfter::Original, zoom)); + let c = h.app.canvas_rect.expect("canvas").center(); + let luma = |m: [f32; 3]| m.iter().sum::(); + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom, BeforeAfter::Split, BeforeAfter::SplitTopBottom] { + let s = shot(&mut h, mode, zoom); + let canvas = h.app.canvas_rect.expect("canvas"); + // the middle of the After pane, or of the After side of the line + let (after, before) = match mode { + BeforeAfter::SideBySide | BeforeAfter::Split => { + (c + egui::vec2(canvas.width() / 4.0, 0.0), c - egui::vec2(canvas.width() / 4.0, 0.0)) + } + _ => (c + egui::vec2(0.0, canvas.height() / 4.0), c - egui::vec2(0.0, canvas.height() / 4.0)), + }; + let (a, b) = (mean_around(&s, after), mean_around(&s, before)); + if matches!(mode, BeforeAfter::Split | BeforeAfter::SplitTopBottom) { + // a wipe shows the loupe's own pixels on each side of the line + let (e, o) = (mean_around(&edited, after), mean_around(&original, before)); + assert!((luma(a) - luma(e)).abs() < 6.0, "{mode:?} {zoom:?}: the After side {a:?} is the edited photo {e:?}"); + assert!((luma(b) - luma(o)).abs() < 6.0, "{mode:?} {zoom:?}: the Before side {b:?} is the original {o:?}"); + } else { + // two views of the same point of the photo: the After one is the brighter + assert!(luma(a) > luma(b) + 30.0, "{mode:?} {zoom:?}: the After {a:?} is the edited photo, brighter than the Before {b:?}"); + } + } + } +} diff --git a/crates/ui-egui/src/tests_compare.rs b/crates/ui-egui/src/tests_compare.rs index 42f5fc027..59ba3e347 100644 --- a/crates/ui-egui/src/tests_compare.rs +++ b/crates/ui-egui/src/tests_compare.rs @@ -82,3 +82,53 @@ fn filmstrip_click_replaces_the_active_side_in_compare() { let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": others[0], "side": "left"}}), T); assert_eq!(r["ok"], false, "unknown side is an error: {r}"); } + +/// Compare is where sharpness is judged: while a pane's render of an edit is on its way, that pane +/// says "Rendering…" once it has lasted a moment, and stops when the render lands. +#[test] +fn a_compare_pane_says_rendering_while_its_edit_renders() { + use std::{cell::Cell, rc::Rc}; + let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:")); + let mut h = demo(); + 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]], "active": vis[0]}}), T); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!pill(&h), "finished renders"); + let open = Rc::new(Cell::new(false)); + h.app.renderer.set_offload(Box::new(crate::headless::RenderGate::new(open.clone()))); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + for _ in 0..12 { + h.step(); + } + assert!(!pill(&h), "not at once"); + for _ in 0..30 { + h.step(); + } + assert!(pill(&h), "the edited pane's render is on its way"); + open.set(true); + assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands"); +} + +/// A pane whose render fails behind its thumbnail says so, as the loupe does, instead of showing +/// the thumbnail as if it were the photo. +#[test] +fn a_compare_pane_says_when_its_render_failed() { + use crate::render::Slot; + use std::{cell::Cell, rc::Rc}; + let failed = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:renderFailed:")); + let mut h = demo(); + 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]], "active": vis[0]}}), T); + h.request("engine.execute", json!({"command": "view.compare"}), T); + h.settle(SETTLE); + assert!(!failed(&h)); + let open = Rc::new(Cell::new(true)); + h.app.renderer.set_offload(Box::new(crate::headless::RenderGate::failing(open, |s| matches!(s, Slot::Compare(_))))); + // another photo in the second pane: its render fails, its thumbnail stays up + let r = h.request("engine.execute", json!({"command": "compare.set", "params": {"id": vis[2], "side": "candidate"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, failed), "the failure is said"); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:"))); +} diff --git a/crates/ui-egui/src/tests_curve.rs b/crates/ui-egui/src/tests_curve.rs index ca5848bee..fc46d6991 100644 --- a/crates/ui-egui/src/tests_curve.rs +++ b/crates/ui-egui/src/tests_curve.rs @@ -69,6 +69,23 @@ fn dragging_a_point_moves_it_in_both_axes_with_one_undo_step() { assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step per drag"); } +#[test] +fn alt_dragging_a_point_moves_it_a_tenth_as_far() { + let mut h = curve_open("master"); + exec(&mut h, "develop.curve", json!({"channel": "master", "points": [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]})); + h.step(); + let side = curve_side(&mut h); + // the same drag as above with ⌥ held: x 0.5 → ~0.49, y 0.5 → ~0.52 + let r = + h.request("ui.dragWidget", json!({"id": "curve", "fx": 0.5, "fy": 0.5, "dx": -0.1 * side, "dy": -0.2 * side, "steps": 12, "alt": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let c = pts(&develop(&h).curve.master); + assert_eq!(c.len(), 3, "{c:?}"); + assert!(c[1].0 < 0.5 && c[1].0 > 0.485, "x moves a tenth of the pointer: {c:?}"); + assert!(c[1].1 > 0.51 && c[1].1 < 0.525, "y moves a tenth of the pointer: {c:?}"); +} + fn click_widget(h: &mut Headless, id: &str, count: u64) { let r = h.request("ui.clickWidget", json!({"id": id, "count": count}), T); assert_eq!(r["ok"], true, "{id}: {r}"); @@ -152,3 +169,20 @@ fn dragging_a_point_past_its_neighbour_is_clamped() { assert!(c[1].0 < c[2].0 && c[1].0 > 0.55, "clamped just left of its neighbour: {c:?}"); assert_eq!(c[2], (0.6, 0.6), "the neighbour stays: {c:?}"); } + +/// A targeted-adjustment tool takes over clicks and drags on the photo, so turning it on says so +/// (and how to leave), and Esc leaves it: one left on by mistake looked like a frozen canvas. +#[test] +fn targeted_adjustment_announces_itself_and_esc_turns_it_off() { + let mut h = curve_open("master"); + let r = h.request("ui.clickWidget", json!({"id": "icon:tat-curve"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.tool, "tat:curve"); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("Esc")), "{:?}", h.app.ui.toast); + let r = h.request("ui.key", json!({"key": "esc"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.ui.tool.is_empty(), "Esc turns the tool off: {:?}", h.app.ui.tool); + assert!(matches!(h.app.ui.view, crate::state::ViewMode::Detail), "the first Esc only leaves the tool"); +} diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 895a1d864..0014c6bdb 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -94,8 +94,10 @@ fn export_dialog_is_unchanged_in_a_tall_window() { assert_fits(&h, "Export at 1600×1000"); let dialog = rect(&h, "dialog:window"); // (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the - // format row gained JPEG XL, #780) - assert!((dialog.height() - 846.0).abs() <= 1.0, "{dialog:?}"); + // format row gained JPEG XL, #780. On macOS the dialog has one more row, "Add to Apple Photos" + // (#236), so it is 876 tall there) + let height = if cfg!(target_os = "macos") { 876.0 } else { 846.0 }; + assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs index 8b6ec7c7b..6abce5460 100644 --- a/crates/ui-egui/src/tests_grading.rs +++ b/crates/ui-egui/src/tests_grading.rs @@ -104,18 +104,251 @@ fn hovering_a_three_way_wheel_shows_its_hue_and_saturation() { } #[test] -fn shift_drag_keeps_the_hue_and_changes_only_saturation() { - use crate::panels::edit::wheel_pick; +fn wheel_pick_reads_the_angle_and_distance_and_honours_a_lock() { + use crate::panels::edit::{WheelLock, wheel_pick}; let close = |a: f32, b: f32| (a - b).abs() < 0.01; // free: hue from the angle (screen y points down), saturation from the distance let (h, s) = wheel_pick(egui::vec2(0.0, -20.0), 40.0, None); assert!(close(h, 90.0) && close(s, 50.0), "{h} {s}"); - // locked at 0°: a rough drag up and right keeps the hue; only the part along the spoke counts - let (h, s) = wheel_pick(egui::vec2(30.0, -12.0), 40.0, Some(0.0)); + // hue locked at 0°: a rough drag up and right keeps the hue; only the part along the spoke counts + let (h, s) = wheel_pick(egui::vec2(30.0, -12.0), 40.0, Some(WheelLock::Hue(0.0))); assert!(close(h, 0.0) && close(s, 75.0), "{h} {s}"); // past the centre or the rim: clamped to 0..100 - assert_eq!(wheel_pick(egui::vec2(-10.0, 0.0), 40.0, Some(0.0)).1, 0.0); - assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, Some(0.0)).1, 100.0); + assert_eq!(wheel_pick(egui::vec2(-10.0, 0.0), 40.0, Some(WheelLock::Hue(0.0))).1, 0.0); + assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, Some(WheelLock::Hue(0.0))).1, 100.0); + assert_eq!(wheel_pick(egui::vec2(90.0, 0.0), 40.0, None).1, 100.0); + // saturation locked: the hue follows the pointer's angle whatever its distance + let (h, s) = wheel_pick(egui::vec2(0.0, -39.0), 40.0, Some(WheelLock::Sat(25.0))); + assert!(close(h, 90.0) && close(s, 25.0), "{h} {s}"); + let (h, s) = wheel_pick(egui::vec2(-3.0, 0.0), 40.0, Some(WheelLock::Sat(25.0))); + assert!(close(h, 180.0) && close(s, 25.0), "{h} {s}"); // a degenerate radius doesn't divide by zero assert!(wheel_pick(egui::vec2(1.0, 1.0), 0.0, None).1.is_finite()); } + +#[test] +fn wheel_pick_never_returns_nan_or_inf() { + use crate::panels::edit::{WheelLock, wheel_pick}; + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY]; + let locks = [None, Some(WheelLock::Hue(f32::NAN)), Some(WheelLock::Hue(400.0)), Some(WheelLock::Sat(f32::NAN)), Some(WheelLock::Sat(250.0))]; + for lock in locks { + for x in bad { + for rad in [0.0, -5.0, f32::NAN, 40.0] { + for v in [egui::vec2(x, 3.0), egui::vec2(3.0, x), egui::vec2(x, x), egui::vec2(0.0, 0.0)] { + let (h, s) = wheel_pick(v, rad, lock); + assert!(h.is_finite() && s.is_finite() && (0.0..=100.0).contains(&s), "{v:?} {rad} {lock:?} -> {h} {s}"); + } + } + } + } + // at the exact centre a saturation lock still answers + assert_eq!(wheel_pick(egui::Vec2::ZERO, 40.0, Some(WheelLock::Sat(30.0))).1, 30.0); +} + +#[test] +fn a_grab_keeps_the_pucks_own_hue_not_the_pointers_angle() { + use crate::panels::edit::{WheelGrab, WheelLock, WheelMods}; + let shift = WheelMods { keep_hue: true, ..Default::default() }; + // the puck is at 100° / 60 %, the pointer grabs it at 10°: the locked hue is 100° + let g = WheelGrab::new(shift, 100.0, 60.0, egui::vec2(30.0, -5.0), 40.0); + assert_eq!(g.lock(), Some(WheelLock::Hue(100.0))); + // a puck at the centre has no hue yet: the pointer's angle at the grab stands in for it + let g = WheelGrab::new(shift, 100.0, 0.0, egui::vec2(0.0, -20.0), 40.0); + match g.lock() { + Some(WheelLock::Hue(h)) => assert!((h - 90.0).abs() < 0.01, "{h}"), + other => panic!("{other:?}"), + } + // Cmd/Ctrl keeps the puck's saturation; Shift wins when both are down + let m = crate::panels::edit::WheelMods::from_modifiers(egui::Modifiers { shift: true, command: true, ..Default::default() }); + assert!(m.keep_hue && !m.keep_sat); + let m = crate::panels::edit::WheelMods::from_modifiers(egui::Modifiers { command: true, ..Default::default() }); + assert_eq!(WheelGrab::new(m, 100.0, 60.0, egui::Vec2::ZERO, 40.0).lock(), Some(WheelLock::Sat(60.0))); + // nothing held: free + assert_eq!(WheelGrab::new(WheelMods::default(), 100.0, 60.0, egui::Vec2::ZERO, 40.0).lock(), None); +} + +#[test] +fn a_fine_grab_moves_the_puck_a_tenth_as_far_as_the_pointer() { + use crate::panels::edit::{WheelGrab, WheelMods}; + let fine = WheelMods { fine: true, ..Default::default() }; + // the puck at 0° / 50 % on a wheel of radius 40 sits 20 right of the centre; the pointer + // grabs it there and moves 10 up: the puck moves 1 up (hue ≈ 2.9°), not 10 + let g = WheelGrab::new(fine, 0.0, 50.0, egui::vec2(20.0, 0.0), 40.0); + let (h, s) = g.pick(egui::vec2(20.0, -10.0), 40.0); + assert!((h - (1.0f32 / 20.0).atan().to_degrees()).abs() < 0.01 && (s - 50.0 * (1.0 + 0.0025f32).sqrt()).abs() < 0.1, "{h} {s}"); + // with no travel the puck stays put, wherever the pointer grabbed it + let g = WheelGrab::new(fine, 120.0, 30.0, egui::vec2(-5.0, 5.0), 40.0); + let (h, s) = g.pick(egui::vec2(-5.0, 5.0), 40.0); + assert!((h - 120.0).abs() < 0.01 && (s - 30.0).abs() < 0.01, "{h} {s}"); +} + +/// A wheel's size on screen: its radius. +fn wheel_radius(h: &Headless, key: &str) -> f32 { + widget(h, &format!("wheel:{key}")).unwrap().width() / 2.0 +} + +/// Where a puck at `hue` / `sat` sits, from the centre of a wheel of radius `rad`. +fn puck(hue: f64, sat: f64, rad: f32) -> egui::Vec2 { + let a = (hue as f32).to_radians(); + egui::vec2(a.cos(), -a.sin()) * rad * (sat as f32 / 100.0) +} + +fn wheel_value(h: &Headless, key: &str) -> (f64, f64) { + let id = h.app.session.active().unwrap(); + let g = h.app.session.catalog.photo(id).unwrap().develop.grading; + let w = match key { + "shadows" => g.shadows, + "midtones" => g.midtones, + _ => g.highlights, + }; + (w.hue, w.sat) +} + +fn set_wheel(h: &mut Headless, key: &str, hue: f64, sat: f64) { + h.app.session.execute("develop.set", &json!({"values": {format!("grading.{key}.hue"): hue, format!("grading.{key}.sat"): sat}})).unwrap(); + h.step(); + h.step(); +} + +/// Drag the midtones wheel from `from` (an offset from its centre) by `by`, holding `mods` +/// (`shift`, `cmd`, `alt`); the puck's new hue and saturation, and how many undo steps it took. +fn drag_wheel(h: &mut Headless, from: egui::Vec2, by: egui::Vec2, mods: &[&str]) -> ((f64, f64), usize) { + let w = widget(h, "wheel:midtones").unwrap().width(); + let mut req = json!({"id": "wheel:midtones", "fx": 0.5 + from.x / w, "fy": 0.5 + from.y / w, "dx": by.x, "dy": by.y, "steps": 12}); + for m in mods { + req[*m] = json!(true); + } + let undo = h.app.session.undo.len(); + let r = h.request("ui.dragWidget", req, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + (wheel_value(h, "midtones"), h.app.session.undo.len() - undo) +} + +/// The distance a puck moved, in points. +fn travelled(rad: f32, a: (f64, f64), b: (f64, f64)) -> f32 { + (puck(b.0, b.1, rad) - puck(a.0, a.1, rad)).length() +} + +#[test] +fn shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let a = 30f32.to_radians(); + let radial = egui::vec2(a.cos(), -a.sin()); + let tangent = egui::vec2(-a.sin(), -a.cos()); + // grab the puck and go 10 points outward and 15 round the wheel (rad 40: +25 % saturation) + let by = (radial * 10.0 + tangent * 15.0) * (rad / 40.0); + let ((hue, sat), undo) = drag_wheel(&mut h, puck(30.0, 50.0, rad), by, &["shift"]); + assert!((hue - 30.0).abs() <= 1.0, "hue stays: {hue}"); + assert!((sat - 75.0).abs() <= 1.5, "saturation follows the radial part: {sat}"); + assert_eq!(undo, 1, "one undo step per drag"); + // the same drag unconstrained does swing the hue round, so the lock is what held it + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((hue, _), undo) = drag_wheel(&mut h, puck(30.0, 50.0, rad), by, &[]); + assert!(hue > 50.0, "unlocked, the hue follows the pointer: {hue}"); + assert_eq!(undo, 1); +} + +#[test] +fn shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + // the puck is at 30°, the pointer grabs the wheel far off that angle (at 120°, and at 250°): + // the hue stays the puck's own, it doesn't take the angle under the pointer + for grab_angle in [120.0f64, 250.0] { + set_wheel(&mut h, "midtones", 30.0, 50.0); + let from = puck(grab_angle, 60.0, rad); + let ((hue, sat), undo) = drag_wheel(&mut h, from, egui::vec2(8.0, -6.0) * (rad / 40.0), &["shift"]); + assert!((hue - 30.0).abs() <= 1.0, "grabbed at {grab_angle}°, the hue is {hue}"); + let a = 30f32.to_radians(); + let end = from + egui::vec2(8.0, -6.0) * (rad / 40.0); + let along = (end.x * a.cos() - end.y * a.sin()) / rad * 100.0; + assert!((sat as f32 - along.clamp(0.0, 100.0)).abs() <= 1.5, "saturation is the pointer's part along the spoke: {sat} vs {along}"); + assert_eq!(undo, 1); + } +} + +#[test] +fn shift_drag_from_the_neutral_centre_locks_the_hue_where_the_pointer_grabbed() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 0.0, 0.0); + // no hue yet: the angle of the grab (90°, straight up) stands in for it + let ((hue, sat), undo) = drag_wheel(&mut h, puck(90.0, 50.0, rad), egui::vec2(6.0, -10.0) * (rad / 40.0), &["shift"]); + assert!((hue - 90.0).abs() <= 1.0, "{hue}"); + assert!((sat - 75.0).abs() <= 1.5, "{sat}"); + assert_eq!(undo, 1); +} + +#[test] +fn cmd_drag_keeps_the_saturation_and_changes_only_the_hue() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + set_wheel(&mut h, "midtones", 30.0, 50.0); + // drag the puck out to 200° at 90 % of the radius: the hue follows, the saturation stays + let from = puck(30.0, 50.0, rad); + let to = puck(200.0, 90.0, rad); + let ((hue, sat), undo) = drag_wheel(&mut h, from, to - from, &["cmd"]); + assert!((hue - 200.0).abs() <= 1.0, "{hue}"); + assert_eq!(sat, 50.0, "the saturation is held"); + assert_eq!(undo, 1, "one undo step per drag"); + // and the same drag without Cmd takes the pointer's saturation too + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((_, sat), _) = drag_wheel(&mut h, from, to - from, &[]); + assert!((sat - 90.0).abs() <= 1.5, "{sat}"); +} + +#[test] +fn alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + let start = (30.0, 50.0); + let from = puck(start.0, start.1, rad); + let by = egui::vec2(20.0, 0.0) * (rad / 40.0); + set_wheel(&mut h, "midtones", start.0, start.1); + let (free, undo_free) = drag_wheel(&mut h, from, by, &[]); + set_wheel(&mut h, "midtones", start.0, start.1); + let (fine, undo_fine) = drag_wheel(&mut h, from, by, &["alt"]); + let (free_move, fine_move) = (travelled(rad, start, free), travelled(rad, start, fine)); + let expect = by.length() * 0.1; + assert!(free_move > by.length() * 0.7, "unmodified: the puck follows the pointer ({free_move})"); + assert!((fine_move - expect).abs() <= 1.5 * (rad / 40.0), "Alt: a tenth of {} is {expect}, moved {fine_move}", by.length()); + assert!(fine_move < free_move / 5.0); + assert_eq!((undo_free, undo_fine), (1, 1)); +} + +#[test] +fn alt_works_with_shift_and_cmd_constraints() { + let mut h = demo(); + let rad = wheel_radius(&h, "midtones"); + let from = puck(30.0, 50.0, rad); + let a = 30f32.to_radians(); + // 20 points outward along the spoke, then 20 round the wheel + let radial = egui::vec2(a.cos(), -a.sin()) * 20.0 * (rad / 40.0); + let tangent = egui::vec2(-a.sin(), -a.cos()) * 20.0 * (rad / 40.0); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((hue, sat), undo) = drag_wheel(&mut h, from, radial, &["shift", "alt"]); + assert!((hue - 30.0).abs() <= 1.0, "{hue}"); + assert!((sat - 55.0).abs() <= 1.5, "20 points is 50 %, a tenth of it is 5: {sat}"); + assert_eq!(undo, 1); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let ((hue, sat), undo) = drag_wheel(&mut h, from, tangent, &["cmd", "alt"]); + assert_eq!(sat, 50.0); + // 2 points round a circle 20 points from the centre: 0.1 rad ≈ 5.7° + assert!((hue - 35.7).abs() <= 1.5, "{hue}"); + assert_eq!(undo, 1); +} + +#[test] +fn double_click_still_resets_the_wheel_with_a_modifier_held() { + let mut h = demo(); + set_wheel(&mut h, "midtones", 30.0, 50.0); + let r = h.request("ui.clickWidget", json!({"id": "wheel:midtones", "count": 2, "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(wheel_value(&h, "midtones"), (0.0, 0.0)); +} diff --git a/crates/ui-egui/src/tests_keyword_sidebar.rs b/crates/ui-egui/src/tests_keyword_sidebar.rs new file mode 100644 index 000000000..95c81ff0d --- /dev/null +++ b/crates/ui-egui/src/tests_keyword_sidebar.rs @@ -0,0 +1,87 @@ +//! Headless tests of the left sidebar's Keywords section on a large library (issue #1011): with +//! thousands of keywords listed, a frame draws only the rows near the screen, and the rows out +//! of sight still take their room, so scrolling reaches the last keyword. + +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_engine::Session; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); +const SETTLE: Duration = Duration::from_secs(120); + +/// `photos` photos with six keywords each out of `keywords` top-level ones (each with a child). +fn library(photos: u64, keywords: u64) -> Session { + let mut s = Session::new(); + for i in 0..photos { + let path = format!("/lightcraft-kw-test/IMG_{i:05}.jpg"); + let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:05}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.meta.keywords = (0..6).map(|k| format!("Keyword {:05}|Child", (i * 6 + k) % keywords)).collect(); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + s +} + +fn app(photos: u64, keywords: u64) -> Headless { + let app = LightcraftApp::new(library(photos, keywords), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// The keyword rows drawn this frame (every row is listed as `source:…`; only a drawn one has its +/// `label:…`). +fn keyword_rows(h: &Headless) -> Vec { + h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("label:keyword:")).map(str::to_string).collect() +} + +/// Given a library with 2,000 keywords, when the sidebar's Keywords section is open, then a frame +/// draws only the few dozen rows that fit on screen (not all 2,000), and scrolling to the end +/// draws the last keyword: the rows out of sight keep their height. +#[test] +fn keywords_section_draws_only_the_rows_near_the_screen() { + const KEYWORDS: u64 = 2000; + let mut h = app(KEYWORDS / 6 + 1, KEYWORDS); + assert_eq!(h.app.caches.keyword_tree(&h.app.session.catalog).len(), KEYWORDS as usize); + let rows = keyword_rows(&h); + assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len()); + assert!(rows.contains(&"Keyword 00000".to_string()), "{rows:?}"); + // all the way down + crate::tests_panels::scroll_sidebar(&mut h, -(KEYWORDS as f32) * 40.0); + let rows = keyword_rows(&h); + assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len()); + assert!(rows.contains(&"Keyword 01999".to_string()), "the last keyword is reached: {rows:?}"); + assert!(!rows.contains(&"Keyword 00000".to_string()), "{rows:?}"); +} + +/// Timings on a library the size of the report's (58,000 photos): `cargo test --release -p +/// lightcraft-ui-egui --lib probe_keyword_sidebar_cost -- --ignored --nocapture`. +#[test] +#[ignore = "timing probe"] +fn probe_keyword_sidebar_cost() { + let mut h = app(58_000, 4000); + let t = Instant::now(); + let tree = h.app.session.catalog.keyword_tree(); + eprintln!("keyword_tree: {:?} ({} top level)", t.elapsed(), tree.len()); + eprintln!("keyword rows drawn: {}", keyword_rows(&h).len()); + let t = Instant::now(); + for _ in 0..10 { + h.step(); + } + eprintln!("unchanged frame: {:?}", t.elapsed() / 10); + let t = Instant::now(); + for i in 0..10u64 { + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [i + 1]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": (i % 5) + 1}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + eprintln!("frame after a rating: {:?}", t.elapsed() / 10); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..bf808b532 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -168,6 +168,15 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged"); assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. })); + // Ctrl+Shift+O / Cmd+Shift+O shows every visible mask's region at once (the new command), ahead of the selected + // mask overlay so it is not shadowed by the always-on single-mask overlay. + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(h.app.ui.mask_overlay_all, "the all-masks overlay command turns it on"); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(!h.app.ui.mask_overlay_all, "the all-masks overlay command toggles it off"); + assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + // Failed edits and global adjustments must not hide the mask overlay. assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err()); assert!(h.app.ui.mask_overlay); @@ -896,6 +905,48 @@ fn local_folder_tree_expands_and_browses() { h.settle(SETTLE); } +/// A folder listed twice in Local (Pictures as a place of its own and again inside Home) opens +/// and closes on each row by itself: both copies of its triangle used to share one egui id, so +/// the copy drawn later took the clicks of the one drawn first (issue #1093). +#[test] +fn local_folder_listed_twice_opens_and_closes_each_row() { + let base = std::env::temp_dir().join(format!("lc-ui-twice-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let inner = base.join("Inner"); + std::fs::create_dir_all(inner.join("Sub")).unwrap(); + let mut h = detail("panel.edit"); + exec(&mut h, "view.leftPanel", json!({"show": true})); + h.hide_home_above(&base); + let (base_s, inner_s) = (base.to_string_lossy().to_string(), inner.to_string_lossy().to_string()); + let sub = inner.join("Sub").to_string_lossy().to_string(); + exec(&mut h, "local.addRoot", json!({"path": base_s})); + exec(&mut h, "local.addRoot", json!({"path": inner_s})); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T); + assert_eq!(r["ok"], true, "{r}"); + let toggle = format!("folderToggle:{inner_s}"); + h.step_until(SETTLE, |h| h.app.widgets.iter().filter(|(w, _)| *w == toggle).count() == 2); + let rows = |h: &Headless, id: &str| h.app.widgets.iter().filter(|(w, _)| w == id).count(); + assert_eq!(rows(&h, &toggle), 2, "Inner is listed inside the base folder and as a place of its own"); + let sub_row = format!("source:local:{sub}"); + assert_eq!(rows(&h, &sub_row), 0); + // each copy's triangle opens that copy only, and closes it again + for copy in 0..2 { + let at = h.app.widgets.iter().filter(|(w, _)| *w == toggle).nth(copy).map(|(_, r)| r.center()).unwrap(); + let r = h.request("ui.click", json!({"x": at.x, "y": at.y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.widgets.iter().any(|(w, _)| *w == sub_row)); + assert_eq!(rows(&h, &sub_row), 1, "copy {copy} opens, the other stays closed"); + let at = h.app.widgets.iter().filter(|(w, _)| *w == toggle).nth(copy).map(|(_, r)| r.center()).unwrap(); + let r = h.request("ui.click", json!({"x": at.x, "y": at.y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(rows(&h, &sub_row), 0, "copy {copy} closes again"); + } + let _ = std::fs::remove_dir_all(&base); + h.settle(SETTLE); +} + #[test] fn slideshow_advances_pauses_and_ends() { let mut h = detail("panel.edit"); @@ -1024,6 +1075,89 @@ fn edit_in_photocraft_opens_a_psd_copy() { h.settle(SETTLE); } +/// Open Original in Other Editor: each selected photo's own file (a raw stays a raw) goes to the +/// configured editor, untouched and without a copy; demo photos and missing files say why. +#[test] +fn open_original_in_external_editor_hands_over_the_raw_itself() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = std::env::temp_dir().join(format!("lc-ui-orig-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc>> = Default::default(); + let o = opened.clone(); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + check_editor: Some(Box::new(|_: &str| Ok(()))), + ..Default::default() + }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + let demo = session.catalog.photos().map(|p| p.id).next().unwrap(); + let mut file = |name: &str, exists: bool| { + let path = dir.join(name); + if exists { + std::fs::write(&path, b"raw sensor data").unwrap(); + } + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "NEF", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + (id, path.to_string_lossy().into_owned()) + }; + let (a, a_path) = file("DSC_0001.NEF", true); + let (b, b_path) = file("DSC_0002.NEF", true); + let (gone, _) = file("DSC_0003.NEF", false); + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "DxO PhotoLab".into(); + let n = h.app.session.catalog.len(); + + exec(&mut h, "library.select", json!({"ids": [a.0, b.0, demo.0]})); + let r = exec(&mut h, "photo.openOriginalInExternal", json!({})); + let calls = opened.lock().unwrap().clone(); + assert_eq!(calls, vec![(a_path.clone(), "DxO PhotoLab".to_string()), (b_path.clone(), "DxO PhotoLab".to_string())]); + assert_eq!(r["skipped"][0]["id"], demo.0, "{r}"); + assert_eq!(h.app.session.catalog.len(), n, "no copy is added to the library"); + assert_eq!(std::fs::read(&a_path).unwrap(), b"raw sensor data", "the original is left as it is"); + assert!(std::fs::read_dir(&dir).unwrap().count() == 2, "nothing is written next to the originals"); + + // the editor can be named per call; the active photo must have a file for the menu + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [a.0], "app": "/opt/dxo"})); + assert_eq!(r["opened"][0]["path"], a_path.as_str()); + assert_eq!(opened.lock().unwrap().last().unwrap().1, "/opt/dxo"); + exec(&mut h, "library.select", json!({"ids": [a.0]})); + assert!(crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal")); + exec(&mut h, "library.select", json!({"ids": [demo.0]})); + assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal")); + + // a host that can start the editor once with every file does (DxO PhotoLab keeps one window + // and drops the files that arrive while it starts) + let batches: std::sync::Arc, String)>>> = Default::default(); + let bt = batches.clone(); + h.app.services.open_all_with = Some(Box::new(move |paths: &[String], app: &str| { + bt.lock().unwrap().push((paths.to_vec(), app.to_string())); + Ok(()) + })); + let singles = opened.lock().unwrap().len(); + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [a.0, b.0, demo.0]})); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(2), "{r}"); + assert_eq!(*batches.lock().unwrap(), vec![(vec![a_path.clone(), b_path.clone()], "DxO PhotoLab".to_string())]); + assert_eq!(opened.lock().unwrap().len(), singles, "one start for all, not one per file"); + + // a demo photo or a missing file: a clear error, nothing opened + let calls = opened.lock().unwrap().len(); + for (ids, why) in [(json!([demo.0]), "demo"), (json!([gone.0]), "missing")] { + let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": ids}}), T); + assert_eq!(r["ok"], false, "{r}"); + assert!(r.to_string().contains(why), "{r}"); + } + assert_eq!(opened.lock().unwrap().len(), calls); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); @@ -1122,6 +1256,10 @@ fn grid_info_cycles_caption() { exec(&mut h, "view.squareGrid", json!({})); exec(&mut h, "view.gridInfo", json!({"info": "exposure"})); h.settle(SETTLE); + // Photo Grid has no captions: picking one shows it in Square Grid + exec(&mut h, "view.photoGrid", json!({})); + exec(&mut h, "view.gridInfo", json!({"info": "date"})); + assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); } #[test] @@ -1352,9 +1490,12 @@ fn a_selected_develop_slider_accepts_wheel_nudges() { // A wheel event over the selected control adjusts exposure instead of // scrolling the right sidebar (an unselected slider leaves scrolling alone). + // (120 points is more than one notch, `theme::WHEEL_LINE_POINTS`; egui smooths it over a few frames) let result = h.request("ui.scroll", json!({"dy": 120.0}), T); assert_eq!(result["ok"], true, "{result}"); - h.step(); + for _ in 0..10 { + h.step(); + } let after = lightcraft_develop::controls::get(&develop(&h), "light.exposure").expect("exposure"); assert!(after > before, "selected exposure did not increase: {before} -> {after}"); } diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index 894e1e4ec..3e3ed49d6 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -76,6 +76,9 @@ fn offline_originals_never_block_a_frame() { let ui_thread = std::thread::current().id(); h.app.ui.view = ViewMode::PhotoGrid; h.step(); + // The first frame installs fonts; finish the first layout before timing the views. + h.step(); + assert!(h.app.fonts_ready, "the UI is initialized before frame timing starts"); assert!(h.app.session.media.availability.is_background(), "the app checks files off the UI thread"); // the grid: every cell asks for its thumbnail (which asks whether the original is there) diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bfa..b18c4e968 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -729,7 +729,7 @@ fn the_header_of_a_long_section_stays_in_sight() { } /// Scroll the sidebar (the pointer over it) by `dy`. -fn scroll_sidebar(h: &mut Headless, dy: f32) { +pub(crate) fn scroll_sidebar(h: &mut Headless, dy: f32) { h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T); let r = h.request("ui.scroll", json!({"dy": dy}), T); assert_eq!(r["ok"], true, "{r}"); @@ -889,6 +889,10 @@ fn the_keywords_header_creates_a_keyword() { h.step(); h.step(); assert!(has(&h, "source:keyword:zebra crossing"), "the new keyword is listed"); + // the sidebar scrolls to it: it is listed last, below what the panel shows + h.settle(SETTLE); + let (row, panel) = (widget(&h, "source:keyword:zebra crossing"), widget(&h, "panel:left_panel")); + assert!(panel.contains_rect(row), "the new keyword is in sight: {row:?} in {panel:?}"); } /// A folded section's row count sits to the left of its header button; a section without a button @@ -1487,6 +1491,10 @@ fn folders_app(paths: &[&str]) -> Headless { } fn folders_app_sized(paths: &[&str], size: [f32; 2]) -> Headless { + folders_app_with_services(paths, size, Services { png: None, ..Default::default() }) +} + +fn folders_app_with_services(paths: &[&str], size: [f32; 2], services: Services) -> Headless { use lightcraft_catalog::{Op, Photo, Source}; let mut session = lightcraft_engine::Session::new(); for path in paths { @@ -1494,7 +1502,7 @@ fn folders_app_sized(paths: &[&str], size: [f32; 2]) -> Headless { let p = Photo::new(id, Source::File { path: (*path).into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); } - let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let app = LightcraftApp::new(session, services); let mut h = Headless::new(app, size, 1.0); let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); assert_eq!(r["ok"], true, "{r}"); @@ -1699,6 +1707,59 @@ fn right_click_opens_a_folders_menu_from_the_triangle_too() { assert!(!popup_open(&h), "the startup disk offers nothing"); } +/// Issue #1194: a folder's menu chooses the folder's new location, then the bulk finder relinks +/// only the missing photos from that folder and the folders inside it. +#[test] +fn a_folder_menu_locates_its_missing_tree_only() { + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let base = std::env::temp_dir().join(format!("lc-ui-locate-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let _cleanup = Cleanup(base.clone()); + let new_trip = base.join("new/trip"); + std::fs::create_dir_all(new_trip.join("day2")).unwrap(); + for path in [new_trip.join("a.jpg"), new_trip.join("day2/b.jpg"), new_trip.join("c.jpg")] { + std::fs::write(path, b"photo").unwrap(); + } + + // (the missing tree is not on disk: a plain root, so the Folders section lists it the same on every platform) + let old = "/lc-locate-old".to_string(); + let old_trip = format!("{old}/trip"); + let paths = [format!("{old_trip}/a.jpg"), format!("{old_trip}/day2/b.jpg"), format!("{old}/other/c.jpg")]; + let picked = new_trip.to_string_lossy().to_string(); + let services = Services { pick_folder: Some(Box::new(move || Some(picked.clone()))), png: None, ..Default::default() }; + let refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + let mut h = folders_app_with_services(&refs, [1400.0, 900.0], services); + + click(&mut h, &format!("libraryFolderToggle:{old}")); + right_click(&mut h, &format!("source:libfolder:{old_trip}")); + assert!(has(&h, "folderLocateMissing"), "the folder menu offers the action"); + click(&mut h, "folderLocateMissing"); + assert!(h.step_until(T, |h| h.app.ui.last_find_missing.is_some()), "the background search finished"); + let result = h.app.ui.last_find_missing.as_ref().unwrap(); + assert_eq!(result["found"].as_array().map(Vec::len), Some(2), "{result}"); + assert_eq!(result["missing"], 0, "{result}"); + + let sources: Vec = h + .app + .session + .catalog + .photos() + .filter_map(|photo| match &photo.source { + lightcraft_catalog::Source::File { path } => Some(path.clone()), + _ => None, + }) + .collect(); + assert!(sources.contains(&new_trip.join("a.jpg").to_string_lossy().to_string()), "{sources:?}"); + assert!(sources.contains(&new_trip.join("day2").join("b.jpg").to_string_lossy().to_string()), "{sources:?}"); + assert!(sources.contains(&format!("{old}/other/c.jpg")), "another missing folder stays untouched: {sources:?}"); +} + /// Right-click a folder ▸ Set Color Label ▸ a colour: the folder's row shows the label's dot (a /// row without a label shows none), and None takes it off again. #[test] @@ -2129,7 +2190,7 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { h.step(); h.step(); match &mut h.app.ui.dialog { - Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None }) => *name = "Trip".into(), + Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None, .. }) => *name = "Trip".into(), other => panic!("New Album dialog expected, got {other:?}"), } h.step(); @@ -2141,3 +2202,166 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } + +/// An album's context menu ▸ Import ▸ Import from Folder… (and Import Photos…, Import from Device) +/// opens the import review with that album chosen; a folder row doesn't offer it, and an `album` +/// the review can't offer (a folder, or an album deleted while the scan ran) leaves its album empty. +#[test] +fn an_album_menu_imports_into_that_album() { + let dir = std::env::temp_dir().join(format!("lc-ui-album-import-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..2u8 { + let (w, h) = (40usize, 30usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|k| [(k % w * 6) as u8, i * 90, (k / w * 8) as u8, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(format!("card{i}.png")), png).unwrap(); + } + let picked = dir.to_string_lossy().to_string(); + let services = Services { png: None, pick_folder: Some(Box::new(move || Some(picked.clone()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + // the demo's top-level album and album folder, both shown in the sidebar + let id_of = |name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0).expect(name); + let (album, folder) = (id_of("Garden"), id_of("Travel 2026")); + let right_click = |h: &mut Headless, id: &str| { + let row = widget(h, id); + let r = h.request("ui.click", json!({"x": row.center().x, "y": row.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + let review_album = |h: &mut Headless| { + h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review: {:?}", h.app.ui.dialog) }; + let chosen = opts.album; + h.app.ui.dialog = None; + chosen + }; + + // a folder row has no Import + right_click(&mut h, &format!("source:folder:{folder}")); + assert!(!has(&h, "button:albumImport"), "folders hold no photos"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + + // an album's menu: Import ▸ Import from Folder… → the (stubbed) folder dialog → the review, album chosen + right_click(&mut h, &format!("source:album:{album}")); + let r = h.request("ui.hoverWidget", json!({"id": "button:albumImport"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + for way in ["button:albumImportPhotos", "button:albumImportFolder", "button:albumImportDevice"] { + assert!(has(&h, way), "{way} is offered"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:albumImportFolder"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(review_album(&mut h), Some(album)); + + // Import Photos… passes the album the same way + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()], "album": album}}), T); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(review_album(&mut h), Some(album)); + + // an album the review can't offer (here a folder) is not chosen + let r = h.request("engine.execute", json!({"command": "file.addPhotos", "params": {"paths": [dir.to_string_lossy()], "album": folder}}), T); + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(review_album(&mut h), None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Delete asks before photos go to Recently Deleted, by default and from every way in: the key, the +/// Photo menu and a photo's right-click menu (#1239). With the setting off, it deletes at once. +#[test] +fn deleting_a_photo_asks_first_from_the_key_the_menu_and_the_right_click_menu() { + use crate::state::Dialog; + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid"})); + assert!(h.app.ui.settings.confirm_delete, "on by default"); + let photo = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + let deleted = |h: &Headless| h.app.session.catalog.photo(photo).unwrap().deleted; + + // ⌫ and Photo ▸ Delete Photo ask; Cancel keeps the photo + h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(h.app.ui.dialog, Some(Dialog::ConfirmDelete { count: 1 }), "the key asks"); + h.request("ui.dialog.cancel", json!({}), T); + assert!(!deleted(&h)); + // (what a click on the menu bar's item runs; `ui.menu.invoke` is the agents' engine.execute) + crate::menubar::run_item(&mut h.app, "photo.delete", json!({})).unwrap(); + assert_eq!(h.app.ui.dialog, Some(Dialog::ConfirmDelete { count: 1 }), "the menu asks"); + h.request("ui.dialog.cancel", json!({}), T); + assert!(!deleted(&h)); + + // the right-click menu's Delete Photo asks too; OK deletes + let thumb = format!("thumb:{}", photo.0); + right_click(&mut h, &thumb); + click(&mut h, "button:contextDeletePhoto"); + assert_eq!(h.app.ui.dialog, Some(Dialog::ConfirmDelete { count: 1 }), "the right-click menu asks"); + assert!(!deleted(&h), "nothing deleted before the answer"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(deleted(&h)); + + // turned off, the right-click menu deletes at once + exec(&mut h, "edit.undo", json!({})); + assert!(!deleted(&h)); + h.app.ui.settings.confirm_delete = false; + right_click(&mut h, &thumb); + click(&mut h, "button:contextDeletePhoto"); + assert_eq!(h.app.ui.dialog, None); + assert!(deleted(&h)); +} + +/// New Album's "Include selected photos" (as in Lightroom): on by default; unticked, the new album +/// starts empty; with nothing selected the option isn't shown, and a folder never has it. +#[test] +fn new_album_can_leave_the_selected_photos_out() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let photo = lightcraft_catalog::PhotoId(h.app.session.visible_cloned()[1].0); + h.app.session.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let create = |h: &mut Headless, command: &str, name: &str| { + h.app.run(command, json!({"name": name})).unwrap(); + h.step(); + h.step(); + }; + let confirm = |h: &mut Headless| { + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + }; + let photos = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).expect("the album was created").photos.clone(); + + // ticked by default: the selection goes in, as before + create(&mut h, "dialog.newAlbum", "With"); + assert!(has(&h, "check:albumIncludeSelected"), "the option is offered with a selection"); + confirm(&mut h); + assert_eq!(photos(&h, "With"), vec![photo]); + + // unticked: an empty album + create(&mut h, "dialog.newAlbum", "Without"); + let r = h.request("ui.clickWidget", json!({"id": "check:albumIncludeSelected"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + confirm(&mut h); + assert!(photos(&h, "Without").is_empty(), "nothing was added"); + + // a folder never offers it + create(&mut h, "dialog.newFolder", "Folder"); + assert!(!has(&h, "check:albumIncludeSelected"), "folders hold no photos"); + confirm(&mut h); + + // nothing selected: no option, an empty album + h.app.session.selection = lightcraft_engine::Selection { ids: vec![], active: None }; + create(&mut h, "dialog.newAlbum", "Nothing"); + assert!(!has(&h, "check:albumIncludeSelected"), "no selection, no option"); + confirm(&mut h); + assert!(photos(&h, "Nothing").is_empty()); +} diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..22f721e82 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -335,6 +335,21 @@ mod in_the_loupe { assert_eq!(h.app.region_view, None); } + // Given a zoomed view with a window render, when I go to the grid, then its windows are freed + // too (while one is held the app never counts as idle and keeps the GPU copy of the original) + #[test] + fn leaving_a_zoomed_detail_view_frees_its_windows() { + let (mut h, _) = detail(); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some()); + h.request("ui.set", json!({"view": "photoGrid"}), T); + h.step(); + assert_eq!((tile(&h, Slot::Region), tile(&h, Slot::RegionBefore)), (None, None)); + assert_eq!((h.app.region_view, h.app.region_before_view), (None, None)); + } + // Given a zoomed view, when the photo is edited, then the old window (made with the old look) // is no longer current and is replaced by one for the new look #[test] @@ -452,6 +467,28 @@ mod in_the_loupe { assert_eq!(main.size, before_main.size, "and the same whole-frame size under them"); } + // Given Before/After side by side, when I zoom to 1:1, then one window job decodes the original + // and the other side waits for it (the Before, asked for first, used to start a second decode + // beside the After's); both windows still arrive + #[test] + fn side_by_side_at_one_to_one_decodes_the_original_once() { + let (mut h, _) = detail(); + let id = h.app.session.active().expect("a photo is open"); + h.app.ui.before_after = crate::state::BeforeAfter::SideBySide; + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + let pending = |h: &Headless| [Slot::Region, Slot::RegionBefore].into_iter().filter(|s| h.app.renderer.is_pending(*s)).count(); + for _ in 0..100 { + h.step(); + if pending(&h) > 0 { + break; + } + } + assert!(!h.app.session.media.has_source(id, lightcraft_engine::SourceLevel::Full), "the original is still decoding"); + assert_eq!(pending(&h), 1, "one window job decodes it"); + h.settle(SETTLE); + assert!(tile(&h, Slot::Region).is_some() && tile(&h, Slot::RegionBefore).is_some(), "both windows arrived"); + } + // Given the Before view alone (\), only the Before gets a window #[test] fn the_original_view_windows_only_the_before() { @@ -596,6 +633,21 @@ mod in_the_loupe { assert!(h.app.session.memory_report().full_source.bytes > 0, "the original stayed in the cache while the drag went on"); } + // Given an original bigger than the cache budget (a 64 MP raw's 797 MB exceeds the default + // share), zoomed to 1:1 it stays decoded, so a pan does not decode the raw again; back in the + // grid it is let go + #[test] + fn a_zoomed_original_over_the_budget_stays_until_the_view_changes() { + let (mut h, _) = detail(); + h.app.session.media.set_budget(16 << 20); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(h.app.session.memory_report().full_source.bytes > 16 << 20, "the original stayed over budget"); + h.request("ui.set", json!({"view": "photoGrid"}), T); + h.step(); + assert_eq!(h.app.session.memory_report().full_source.bytes, 0, "the grid needs no original"); + } + /// The meshes the last frame drew with `slot`'s texture: (paint order, clip rect, bounds). fn drawn(h: &Headless, slot: Slot) -> Vec<(usize, egui::Rect, egui::Rect)> { let Some(tex) = h.app.renderer.textures.get(&slot) else { return vec![] }; @@ -763,6 +815,53 @@ mod in_the_loupe { assert!(held(&h) > before / 3, "and they are still there"); } + /// Load a Display P3 monitor profile (`app.displayProfile`), as Settings ▸ Display does. + fn use_display_profile(h: &mut Headless) { + let dir = std::env::temp_dir().join(format!("lightcraft-zoom-display-{}-{:?}", std::process::id(), std::thread::current().id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("p3.icc"); + std::fs::write(&path, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).expect("profile written"); + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": path.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.renderer.display_id().is_some()); + } + + // Given a monitor profile, when I zoom to 1:1, then the sharp window is painted over the + // whole-frame render, as without one (a display profile changes the texture's render key, and + // the window used to be looked up by the key it had before: never found, so only the + // whole-frame render, magnified and blurred, was shown) + #[test] + fn a_display_profile_keeps_the_sharp_window_at_one_to_one() { + let (mut h, native) = detail(); + use_display_profile(&mut h); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(rendered_long_edge(&h) < native, "the whole frame is smaller than the photo"); + let view = h.app.region_view.expect("a window render"); + assert_eq!(view.full.0.max(view.full.1), native); + let (whole, window) = (drawn(&h, Slot::Main), drawn(&h, Slot::Region)); + assert!(!whole.is_empty(), "the whole-frame render is painted"); + assert!(!window.is_empty(), "the window is painted"); + assert!(window[0].0 > whole[0].0, "over the whole-frame render"); + } + + // …and in Compare, each pane's window too + #[test] + fn a_display_profile_keeps_the_compare_windows_at_one_to_one() { + let (mut h, _) = detail(); + use_display_profile(&mut h); + let r = h.request("engine.execute", json!({"command": "view.compare"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.zoom = crate::state::Zoom::Percent(100.0); + h.settle(SETTLE); + for i in 0..2u8 { + assert!(h.app.compare_windows[i as usize].is_some(), "pane {i} asked for a window"); + let (whole, window) = (drawn(&h, Slot::Compare(i)), drawn(&h, Slot::CompareRegion(i))); + assert!(!whole.is_empty() && !window.is_empty(), "pane {i}: both are painted"); + assert!(window[0].0 > whole[0].0, "pane {i}: the window is painted over the whole-frame render"); + } + } + /// Compare view of the demo library at `zoom`, settled. fn compare_at(zoom: crate::state::Zoom) -> Headless { let (mut h, _) = detail(); diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests_scroll.rs index 1eff1ec7f..d04a818a4 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests_scroll.rs @@ -208,3 +208,25 @@ fn filmstrip_shows_every_selected_photo() { assert_eq!(r["ok"], true, "{r}"); assert_eq!(fill(&mut h, second), active); } + +/// A mouse wheel notch (a line) scrolls `theme::WHEEL_LINE_POINTS`, not egui's default 40: long +/// panels took 50+ notches. +#[test] +fn a_wheel_notch_scrolls_a_hundred_points() { + let mut h = demo("photoGrid"); + let canvas = h.app.canvas_rect.expect("grid canvas"); + h.request("ui.move", json!({"x": canvas.center().x, "y": canvas.center().y}), T); + let y0 = grid_y(&h); + for _ in 0..3 { + h.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + } + idle(&mut h, 120); + let moved = grid_y(&h) - y0; + assert!(moved >= 3.0 * crate::theme::WHEEL_LINE_POINTS - 1.0, "three notches scrolled {moved} points"); +} diff --git a/crates/ui-egui/src/tests_temp_slider.rs b/crates/ui-egui/src/tests_temp_slider.rs new file mode 100644 index 000000000..c8e4e0d4c --- /dev/null +++ b/crates/ui-egui/src/tests_temp_slider.rs @@ -0,0 +1,125 @@ +//! Headless tests of the Temp slider's scale: the Kelvin temperature of a raw follows Lightroom's track +//! (2000–7000 K on the left half, logarithmic up to 50000 K on the right); the relative temperature of a +//! rendered photo and every other slider stay linear. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The demo library in Detail with the Edit panel's Light and Color sections open, on a raw photo (Kelvin +/// white balance) when `raw`, else on a demo JPEG (relative white balance). +fn edit_panel(raw: bool) -> Headless { + use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = if raw { + // a DNG raw photo (its file need not exist: the sliders only read its settings) + let id = PhotoId(h.app.session.catalog.photos().map(|p| p.id.0).max().unwrap_or(0) + 1); + let path = std::env::temp_dir().join("lc-temp-slider").join("IMG_1.dng"); + let mut photo = + Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, "IMG_1.dng", "DNG", 6000, 4000, "2026-10-01T00:00:00"); + photo.kind = MediaKind::Raw; + h.app.session.commit("setup", Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + id.0 + } else { + h.app.session.visible_cloned()[0].0 + }; + assert_eq!(h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T)["ok"], true); + assert_eq!(h.request("ui.set", json!({"view": "detail", "right": "edit"}), T)["ok"], true); + h.app.ui.open_sections = vec!["light".to_string(), "color".to_string()]; + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Where the slider's thumb (the hollow ring on the track's centre line) is drawn, from 0 to 1 along the track. +fn thumb(h: &Headless, control: &str) -> f32 { + let track = widget(h, &format!("slider:{control}")); + let rings: Vec = h + .view + .painted_shapes() + .iter() + .filter_map(|s| match s { + egui::epaint::Shape::Circle(c) if c.radius == 7.0 && (c.center.y - track.center().y).abs() < 0.5 => Some(c.center.x), + _ => None, + }) + .filter(|x| (track.left() - 0.5..=track.right() + 0.5).contains(x)) + .collect(); + assert!(!rings.is_empty() && rings.iter().all(|x| (x - rings[0]).abs() < 0.01), "one thumb on {control}: {rings:?}"); + (rings[0] - track.left()) / track.width() +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.selection.active.unwrap(); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +fn set(h: &mut Headless, control: &str, value: f64) { + let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": control, "value": value}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// The thumb sits where Lightroom puts it (within 1 % of the model fitted to Lightroom's positions), clicking or +/// dragging to the middle of the track gives 7000 K, and the far end is still 50000 K. +#[test] +fn the_kelvin_temp_slider_has_lightrooms_scale() { + let mut h = edit_panel(true); + for (kelvin, model) in [(3650.0, 0.165), (5400.0, 0.34), (7000.0, 0.5), (15000.0, 0.79), (20000.0, 0.849)] { + set(&mut h, "wb.temp", kelvin); + assert_eq!(develop(&h).wb.temp, kelvin); + let at = thumb(&h, "wb.temp"); + assert!((at - model).abs() < 0.01, "{kelvin} K drawn at {at}, the model says {model}"); + } + set(&mut h, "wb.temp", 4850.0); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:wb.temp", "fx": 0.5}), T)["ok"], true); + h.settle(SETTLE); + assert_eq!(develop(&h).wb.temp, 7000.0, "a click in the middle of the track"); + // drag from As Shot on a Z 6II NEF (about 4850 K, 28 % along the track) to the middle + set(&mut h, "wb.temp", 4850.0); + let track = widget(&h, "slider:wb.temp"); + let from = track.left() + thumb(&h, "wb.temp") * track.width(); + let r = h.request("ui.drag", json!({"x": from, "y": track.center().y, "toX": track.center().x, "toY": track.center().y, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let k = develop(&h).wb.temp; + assert!((k - 7000.0).abs() <= 50.0, "dragged to the middle: {k} K"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:wb.temp", "fx": 1.0}), T)["ok"], true); + h.settle(SETTLE); + assert_eq!(develop(&h).wb.temp, 50000.0); +} + +/// Exposure (like every slider but the Kelvin temperature) keeps its linear track. +#[test] +fn other_sliders_stay_linear() { + let mut h = edit_panel(true); + set(&mut h, "light.exposure", 1.0); + assert!((thumb(&h, "light.exposure") - 0.6).abs() < 0.005, "+1.00 of −5…+5 at 60 %"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:light.exposure", "fx": 0.75}), T)["ok"], true); + h.settle(SETTLE); + assert!((develop(&h).light.exposure - 2.5).abs() < 0.011, "{}", develop(&h).light.exposure); +} + +/// The relative temperature of a rendered photo (−100…+100, 0 = as shot, in the middle) is unchanged. +#[test] +fn the_relative_temp_slider_stays_linear() { + let mut h = edit_panel(false); + assert!(h.app.widgets.iter().all(|(w, _)| w != "slider:wb.temp"), "a JPEG has the relative scale"); + assert!((thumb(&h, "wb.tempRel") - 0.5).abs() < 0.005, "as shot in the middle"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "slider:wb.tempRel", "fx": 0.75}), T)["ok"], true); + h.settle(SETTLE); + assert!((thumb(&h, "wb.tempRel") - 0.75).abs() < 0.005, "the thumb where it was clicked"); + // +50 on the relative scale: 40 mired warmer than 6500 K + let k = develop(&h).wb.temp; + let rel = (1e6 / 6500.0 - 1e6 / k) / 0.8; + assert!((rel - 50.0).abs() < 0.6, "{k} K is {rel} on the relative scale"); +} diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index d22b3b939..268242ef1 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -51,6 +51,7 @@ pub struct TextField<'a> { text: &'a mut String, multiline: bool, hint: Option, + label: Option, width: Option, select_on_focus: bool, begin_edit: bool, @@ -81,6 +82,7 @@ impl<'a> TextField<'a> { text, multiline: false, hint: None, + label: None, width: None, select_on_focus: false, begin_edit: false, @@ -104,6 +106,13 @@ impl<'a> TextField<'a> { self } + /// The name a screen reader announces for the field (its visible caption); the hint + /// stands in when there is none. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + pub fn width(mut self, width: f32) -> Self { self.width = Some(width); self @@ -182,6 +191,7 @@ impl<'a> TextField<'a> { Action::Refocus => {} } } + let name = self.label.clone().or_else(|| self.hint.clone()); let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; edit = edit.id(id); if let Some(hint) = self.hint { @@ -214,6 +224,11 @@ impl<'a> TextField<'a> { let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary)); let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range()); let mut response = ui.add(edit); + // screen readers: the field by its caption, even once it holds text (egui names it by + // nothing but its contents) + if let Some(name) = name.filter(|n| !n.trim().is_empty()) { + ui.ctx().accesskit_node_builder(response.id, |node| node.set_label(name)); + } // The menu may take focus on a primary-button release. egui collapses a // TextEdit selection when focus leaves; retain the range our own menu acts on. if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a87a196ae..282f9c70e 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -292,6 +292,14 @@ pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFo fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes))); } } + // A variable face holds every weight in its one file: strong text draws it heavier, from the + // same bytes. A face with one fixed weight stays as it is. + for name in &mut bold { + let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) else { continue }; + let Some(heavier) = strong_weight(font.bytes) else { continue }; + *name = format!("{name} strong"); + fonts.font_data.insert(name.clone(), Arc::new(heavier)); + } let script = crate::i18n::language().script(); let prefer_chinese = chinese.is_some() && matches!(script, "Hans" | "Hant"); if let Some(font) = chinese { @@ -339,10 +347,33 @@ pub fn font_credits(chinese_loaded: bool) -> String { families.join(" / ") } +/// The weight of strong text in a variable face, to go with Inter SemiBold. +const STRONG_WEIGHT: f32 = 600.0; + +/// `bytes` as a face drawn at [`STRONG_WEIGHT`], when it is a variable font whose weight axis goes +/// heavier than the weight it draws at by default. The bytes are borrowed, not copied. +fn strong_weight(bytes: &'static [u8]) -> Option { + let face = FontData::from_static(bytes); + let axis = face.variation_axes().into_iter().find(|axis| axis.tag.to_be_bytes() == *b"wght")?; + let weight = STRONG_WEIGHT.min(axis.range.max); + if weight.partial_cmp(&axis.default) != Some(std::cmp::Ordering::Greater) { + return None; + } + let tweak = egui::FontTweak { coords: egui::epaint::text::VariationCoords::new([(axis.tag, weight)]), ..Default::default() }; + Some(face.tweak(tweak)) +} + fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } +/// Points one mouse-wheel notch (a line) scrolls. +pub const WHEEL_LINE_POINTS: f32 = 100.0; + +/// Zoom per wheel point (`exp(speed × points)`): egui's 1/200 at its stock 40-point notch, rescaled so +/// one notch still zooms the same amount now that it scrolls [`WHEEL_LINE_POINTS`]. +pub const WHEEL_ZOOM_PER_POINT: f32 = 40.0 / 200.0 / WHEEL_LINE_POINTS; + fn theme_id() -> egui::Id { egui::Id::new("lightcraft-theme") } @@ -362,6 +393,14 @@ pub fn apply(ctx: &egui::Context) { pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { // Keep photo zoom shortcuts from resizing egui itself. ctx.options_mut(|o| o.zoom_with_keyboard = false); + // A mouse wheel notch scrolls 100 points (egui's default 40 took 50+ notches through an open + // Edit panel; Lightroom moves about this much per notch). Trackpads report points and are + // unaffected. + // A modifier-wheel (and the loupe's plain-wheel) zoom notch stays about 1.22x, as it was at 40 points. + ctx.options_mut(|o| { + o.input_options.line_scroll_speed = WHEEL_LINE_POINTS; + o.input_options.scroll_zoom_speed = WHEEL_ZOOM_PER_POINT; + }); let t = Tokens::for_kind(kind); ctx.data_mut(|d| { d.insert_temp(egui::Id::NULL, t); @@ -410,6 +449,19 @@ mod tests { use super::*; use crate::i18n::{Locale, set_language}; + /// Every theme scrolls 100 points a wheel notch, and a zoom notch stays egui's stock ~1.22x + /// (`exp(40 / 200)`) instead of growing with the longer scroll. + #[test] + fn a_wheel_notch_scrolls_further_but_zooms_as_before_in_every_theme() { + for kind in [ThemeKind::Charcoal, ThemeKind::Midnight, ThemeKind::Silver, ThemeKind::Paper] { + let ctx = egui::Context::default(); + apply_kind(&ctx, kind); + let (line, zoom) = ctx.options(|o| (o.input_options.line_scroll_speed, o.input_options.scroll_zoom_speed)); + assert_eq!(line, WHEEL_LINE_POINTS, "{kind:?}"); + assert!(((zoom * line).exp() - (40.0_f32 / 200.0).exp()).abs() < 1e-4, "{kind:?}: a notch zooms {}", (zoom * line).exp()); + } + } + /// #736: a Thai folder name drew boxes because only CJK craft-fonts faces were fallbacks. With a /// craft-fonts build that carries a Thai face, an English UI offers it in every family. #[test] @@ -422,8 +474,11 @@ mod tests { let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); let name = craft_font_name(thai); assert!(definitions.font_data.contains_key(&name)); + // (The strong family has the face's heavier twin instead when it is a variable font.) + let strong = format!("{name} strong"); for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] { - assert!(definitions.families.get(&family).is_some_and(|names| names.contains(&name)), "{family:?} falls back on {name}"); + let names = definitions.families.get(&family).expect("UI font family"); + assert!(names.contains(&name) || (family == semibold_family() && names.contains(&strong)), "{family:?} falls back on {name}"); } } @@ -505,4 +560,128 @@ mod tests { assert_eq!(names.get(2 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT)); set_language(Locale::En); } + + const ARABIC_WORD: &str = "الصور"; + + fn semibold_family() -> FontFamily { + FontFamily::Name(FONT_SEMIBOLD.into()) + } + + /// The width of `text` in `family` at the body size, and the ink its glyphs put in the font + /// atlas (summed coverage), with `definitions` installed. + fn width_and_ink(definitions: FontDefinitions, text: &str, family: FontFamily) -> (f32, u64) { + let ctx = egui::Context::default(); + ctx.set_fonts(definitions); + let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {}); + frame.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let galley = fonts.layout_no_wrap(text.into(), FontId::new(13.0, family), Color32::WHITE); + let atlas = fonts.image(); + let mut ink = 0_u64; + for glyph in galley.rows.iter().flat_map(|row| row.glyphs.iter()) { + let (min, max) = (glyph.uv_rect.min, glyph.uv_rect.max); + for y in min[1]..max[1] { + for x in min[0]..max[0] { + ink += u64::from(atlas[(usize::from(x), usize::from(y))].a()); + } + } + } + (galley.size().x, ink) + }) + } + + /// The craft-fonts face for Arabic, when it is a variable font with a weight axis. + fn variable_arabic_face() -> Option<&'static lightcraft_engine::CraftFont> { + let face = lightcraft_engine::CRAFT_FONTS.iter().find(|font| font.covers("Arab"))?; + FontData::from_static(face.bytes).variation_axes().iter().any(|axis| axis.tag.to_be_bytes() == *b"wght").then_some(face) + } + + /// Strong text in a script whose fallback face is a variable font is really heavier: an Arabic + /// tab name used to be drawn at regular weight beside a bold Latin one. + #[test] + fn strong_arabic_text_is_heavier_than_regular_with_a_variable_face() { + // Given a build with craft-fonts whose Arabic face has a weight axis + if variable_arabic_face().is_none() { + eprintln!("skipped: built without a variable-weight craft-fonts face for Arabic"); + return; + } + set_language(Locale::En); + // When an Arabic word is laid out regular and strong at the same size + let (regular_width, regular_ink) = width_and_ink(font_definitions(lightcraft_engine::CRAFT_FONTS), ARABIC_WORD, FontFamily::Proportional); + let (strong_width, strong_ink) = width_and_ink(font_definitions(lightcraft_engine::CRAFT_FONTS), ARABIC_WORD, semibold_family()); + eprintln!("{ARABIC_WORD}: regular {regular_width} px, ink {regular_ink}; strong {strong_width} px, ink {strong_ink}"); + // Then the strong one is wider and puts down more ink + assert!(strong_width > regular_width, "strong Arabic is wider than regular: strong {strong_width} px, regular {regular_width} px"); + assert!(strong_ink as f64 > regular_ink as f64 * 1.05, "strong Arabic has more ink than regular: strong {strong_ink}, regular {regular_ink}"); + } + + /// The heavier face is for strong text only: regular and monospace text keep the face as the + /// font file has it, and Latin text keeps Inter in both weights. + #[test] + fn regular_text_and_latin_are_unchanged_by_the_strong_variable_face() { + // Given a build with craft-fonts whose Arabic face has a weight axis + let Some(arabic) = variable_arabic_face() else { + eprintln!("skipped: built without a variable-weight craft-fonts face for Arabic"); + return; + }; + set_language(Locale::En); + // When the UI builds its fonts + let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); + // Then the regular Arabic face is the font file untouched, and the only Arabic face of + // the regular and monospace families + let name = craft_font_name(arabic); + assert_eq!(definitions.font_data.get(&name).map(|data| &data.tweak), Some(&egui::FontTweak::default())); + let plain_weight = egui::epaint::text::VariationCoords::default(); + for family in [FontFamily::Proportional, FontFamily::Monospace] { + let names = definitions.families.get(&family).expect("UI font family"); + assert!(names.contains(&name), "{family:?} falls back on {name}"); + for other in names.iter().filter(|other| **other != name) { + let coords = definitions.font_data.get(other).map(|data| data.tweak.coords.clone()).unwrap_or_default(); + assert_eq!(coords, plain_weight, "{family:?}: {other} is drawn at the weight its file has"); + } + } + // And regular Arabic text is as wide as with that face alone after Inter + let mut plain = FontDefinitions::default(); + plain.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + plain.font_data.insert("Arabic".into(), Arc::new(FontData::from_static(arabic.bytes))); + plain.families.insert(FontFamily::Proportional, vec!["Inter".into(), "Arabic".into()]); + let (width, ink) = width_and_ink(definitions.clone(), ARABIC_WORD, FontFamily::Proportional); + assert_eq!((width, ink), width_and_ink(plain, ARABIC_WORD, FontFamily::Proportional)); + // And Latin text, strong and regular, is as it is without craft-fonts + for family in [FontFamily::Proportional, semibold_family()] { + assert_eq!( + width_and_ink(definitions.clone(), "Library", family.clone()), + width_and_ink(font_definitions(&[]), "Library", family.clone()), + "{family:?}" + ); + } + let (regular, _) = width_and_ink(definitions.clone(), "Library", FontFamily::Proportional); + let (strong, _) = width_and_ink(definitions, "Library", semibold_family()); + assert!(strong > regular, "strong Latin stays wider than regular: {strong} px, {regular} px"); + } + + /// A face with one fixed weight is registered once: nothing heavier can be asked of it. Without + /// craft-fonts there is nothing to register at all. + #[test] + fn a_static_face_has_no_strong_twin() { + // Given a face without a weight axis for another script + static FACES: &[lightcraft_engine::CraftFont] = &[lightcraft_engine::CraftFont { + family: "Static Sans", + style: "Regular", + scripts: &["Thai"], + bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), + }]; + set_language(Locale::En); + for craft in [FACES, &[]] { + // When the UI builds its fonts + let definitions = font_definitions(craft); + // Then no face is drawn at another weight than its file has, and the strong family + // holds what the regular one does after Inter + assert!(definitions.font_data.values().all(|data| data.tweak.coords == egui::epaint::text::VariationCoords::default())); + let regular = definitions.families.get(&FontFamily::Proportional).expect("regular family"); + let strong = definitions.families.get(&semibold_family()).expect("strong family"); + assert_eq!(regular.get(1..), strong.get(1..)); + assert_eq!(definitions.font_data.len(), FontDefinitions::default().font_data.len() + 2 + craft.len()); + } + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0872b2ec4..a6af3dc7a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,6 +45,12 @@ pub fn preview_only_variant(reason: &str) -> &str { /// What a preview-only raw means for the user (see `Photo::preview_only`). pub fn preview_only_explanation(reason: &str) -> String { + if reason.starts_with("Nikon High Efficiency NEF (") { + return crate::i18n::tr( + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.", + ) + .to_string(); + } crate::i18n::tr_format!( "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.", preview_only_variant(reason) @@ -185,6 +191,10 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); // A click selects the slider for arrow/wheel adjustments. Keep scroll-to-adjust // opt-in so hovering an unfocused slider still scrolls the containing panel. + // Tab reaching the slider selects it as a click does: the arrow keys then adjust it + if enabled && resp.gained_focus() { + ui.data_mut(|d| d.insert_temp(focused_slider_key(), id)); + } if enabled && (resp.clicked() || resp.drag_started()) { resp.request_focus(); ui.data_mut(|d| d.insert_temp(focused_slider_key(), id)); @@ -192,6 +202,18 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // screen readers: a slider named after its control, with its value let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string(); resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone())); + // and its range, step and the value as shown ("+0.50", "5600") + let shown = shown_value(spec, value); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(spec.min); + node.set_max_numeric_value(spec.max); + node.set_numeric_value_step(spec.step); + node.set_value(shown); + if enabled { + node.add_action(egui::accesskit::Action::Increment); + node.add_action(egui::accesskit::Action::Decrement); + } + }); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one (over the label, so it takes the click) @@ -216,13 +238,16 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); } let mut out = SliderOut::default(); - let span = (spec.max - spec.min).max(1e-9); - let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width(); - let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span; + // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) + let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); + let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); let mut v = value; if let Some((mut text, frames, pressed)) = typing.take() { // Keep the owned opening release focused without restarting its draft or selection. - if pressed && value_released { + // egui gives that release's click to the readout, so the field under it gives up the focus + // whatever frame it lands on (issue #1362): the field takes it back after drawing too. + let opening_release = pressed && value_released; + if opening_release { ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); } let pressed = pressed && !value_released; @@ -237,10 +262,11 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ .select_on_focus(true) .begin_edit(frames == 0) .show(&mut child); - if frames < 2 && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { + if (frames < 2 || opening_release) && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { field.response.request_focus(); - typing = Some((text.clone(), frames + 1, pressed)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1, pressed))); + let frames = frames.saturating_add(1); + typing = Some((text.clone(), frames, pressed)); + ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); } else if field.ending.is_some() || !field.editing || !enabled { // Esc (or the slider turning off) keeps the old value; Return or clicking away applies let cancelled = !enabled || field.cancelled(); @@ -267,13 +293,19 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ && let Some(p) = resp.interact_pointer_pos() { let fine = ui.input(|i| i.modifiers.shift); + let step = spec.step.max(1e-9); let nv = if fine { + // a tenth of the pointer's movement along the track; the unrounded position is kept, so a + // slow drag adds up instead of rounding back to the same step every frame + let fine_id = id.with("fine-pos"); let dx = ui.input(|i| i.pointer.delta().x); - value + dx as f64 * span / track_rect.width() as f64 * 0.1 + let kept = ui.data(|m| m.get_temp::(fine_id)).filter(|&p| ((spec.value_at(p) / step).round() * step - value).abs() < step / 2.0); + let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_rect.width()) * 0.1).clamp(0.0, 1.0); + ui.data_mut(|m| m.insert_temp(fine_id, pos)); + spec.value_at(pos) } else { from_x(p.x) }; - let step = spec.step.max(1e-9); let nv = ((nv / step).round() * step).clamp(spec.min, spec.max); if (nv - value).abs() > 1e-12 { out.value = Some(nv); @@ -292,13 +324,23 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ if resp.drag_stopped() { out.drag_stopped = true; } + // a screen reader's increment / decrement (VoiceOver: Ctrl-Option-Up / Down) + let (inc, dec) = ui.input(|i| { + i.events.iter().fold((false, false), |(inc, dec), e| match e { + egui::Event::AccessKitActionRequest(r) if r.target_node == resp.id.accesskit_id() => { + (inc || r.action == egui::accesskit::Action::Increment, dec || r.action == egui::accesskit::Action::Decrement) + } + _ => (inc, dec), + }) + }); // Arrow keys work with either a hovered or a focused slider. - if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus()) { + if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus() || inc || dec) { let (up, down, shift) = ui.input_mut(|i| { let shift = i.modifiers.shift; let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; (i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift) }); + let (up, down) = (up || inc, down || dec); if up || down { let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 }); if (nv - value).abs() > 1e-12 { @@ -369,10 +411,11 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } /// Convert vertical wheel movement to bounded slider steps, keeping sub-notch -/// trackpad movement between frames without amplifying a single large event. +/// trackpad movement between frames without amplifying a single large event. One mouse-wheel +/// notch ([`crate::theme::WHEEL_LINE_POINTS`]) is one step. fn wheel_nudges(remainder: &mut f32, delta: f32) -> f64 { - const POINTS_PER_STEP: f32 = 40.0; - let total = (*remainder + delta).clamp(-400.0, 400.0); + const POINTS_PER_STEP: f32 = crate::theme::WHEEL_LINE_POINTS; + let total = (*remainder + delta).clamp(-10.0 * POINTS_PER_STEP, 10.0 * POINTS_PER_STEP); let steps = (total / POINTS_PER_STEP).trunc(); *remainder = total - steps * POINTS_PER_STEP; f64::from(steps) @@ -594,6 +637,19 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co mod tests { use super::*; + #[test] + fn high_efficiency_nef_explains_the_codec_limit_and_workarounds() { + let reason = "Nikon High Efficiency NEF (HE / HE★, a JPEG XS-based codec with no public specification; shoot RAW Lossless compressed to edit the raw data)"; + let explanation = preview_only_explanation(reason); + assert!(explanation.contains("TicoRAW"), "{explanation}"); + assert!(explanation.contains("does not currently have a license"), "{explanation}"); + assert!(!explanation.contains("cannot decode"), "{explanation}"); + assert!(explanation.contains("Lossless compressed NEF"), "{explanation}"); + assert!(explanation.contains("DNG"), "{explanation}"); + assert!(!explanation.contains("yet"), "{explanation}"); + assert!(preview_only_explanation("unsupported CR3").contains("unsupported CR3")); + } + /// Issue #647: hovering a star previews the rating a click would set. #[test] fn hovered_star_previews_its_rating() { @@ -625,13 +681,56 @@ mod tests { #[test] fn wheel_steps_accumulate_small_movements_and_preserve_direction() { + // one mouse-wheel notch is one step, whatever a notch scrolls + let notch = crate::theme::WHEEL_LINE_POINTS; let mut remainder = 0.0; - assert_eq!(wheel_nudges(&mut remainder, 15.0), 0.0); - assert_eq!(wheel_nudges(&mut remainder, 25.0), 1.0); + assert_eq!(wheel_nudges(&mut remainder, notch * 0.375), 0.0); + assert_eq!(wheel_nudges(&mut remainder, notch * 0.625), 1.0); assert_eq!(remainder, 0.0); - assert_eq!(wheel_nudges(&mut remainder, -100.0), -2.0); - assert_eq!(wheel_nudges(&mut remainder, -20.0), -1.0); + assert_eq!(wheel_nudges(&mut remainder, -notch * 2.5), -2.0); + assert_eq!(wheel_nudges(&mut remainder, -notch * 0.5), -1.0); assert_eq!(remainder, 0.0); + assert_eq!(wheel_nudges(&mut remainder, notch), 1.0, "a single notch is a single nudge"); + assert_eq!(remainder, 0.0); + assert_eq!(wheel_nudges(&mut remainder, notch * 50.0), 10.0, "a single huge event is capped"); + } + + /// Shift-drag moves a tenth of the pointer's way along the track, also on the Kelvin track (where a pixel of + /// fine drag near the centre is about 4 K): a pointer creeping a pixel a frame still adds up to steps. + #[test] + fn a_slow_shift_drag_adds_up() { + let ctx = egui::Context::default(); + let temp = lightcraft_develop::controls::find("wb.temp").unwrap(); + let mut value = 7000.0; + let frame = |time: f64, events: Vec, value: f64| { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 120.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = SliderOut::default(); + ctx.run_ui(input, |ui| out = slider(ui, temp, value, true, None)).textures_delta.clear(); + let track = take_registry(&ctx).into_iter().find(|(id, _)| id == "slider:wb.temp").unwrap().1; + (out, track) + }; + // Shift is held from the start (the modifiers stay until they change) + let (_, track) = frame(0.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)], value); + let mut at = pos2(track.left() + temp.position(value) as f32 * track.width(), track.center().y); + let press = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT }; + frame(0.1, vec![egui::Event::PointerMoved(at), press(at, true)], value); + let moves = 60; + for i in 0..moves { + at.x += 1.0; + let (out, _) = frame(0.2 + f64::from(i) * 0.02, vec![egui::Event::PointerMoved(at)], value); + value = out.value.unwrap_or(value); + } + frame(2.0, vec![press(at, false)], value); + // the drag starts once the pointer has moved a few pixels; every pixel after that counts + let lo = temp.value_at(temp.position(7000.0) + f64::from(moves - 10) / f64::from(track.width()) * 0.1); + let hi = temp.value_at(temp.position(7000.0) + f64::from(moves) / f64::from(track.width()) * 0.1); + assert!(value >= lo - 50.0 && value <= hi + 50.0 && value > 7000.0, "{value} K, expected {lo:.0}–{hi:.0} K"); } /// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range. @@ -665,6 +764,7 @@ mod access_tests { ctx.enable_accesskit(); let spec = lightcraft_develop::controls::find("light.exposure").unwrap(); let mut found = Vec::new(); + let mut ranges = Vec::new(); for _ in 0..3 { let out = ctx.run_ui(egui::RawInput::default(), |ui| { let _ = slider(ui, spec, 0.5, true, None); @@ -674,6 +774,12 @@ mod access_tests { let mut out = out; out.textures_delta.clear(); if let Some(update) = out.platform_output.accesskit_update.take() { + ranges = update + .nodes + .iter() + .filter(|(_, n)| n.role() == egui::accesskit::Role::Slider) + .map(|(_, n)| (n.min_numeric_value(), n.max_numeric_value())) + .collect(); found = update .nodes .iter() @@ -684,11 +790,25 @@ mod access_tests { let has = |role: &str, label: &str| found.iter().any(|(r, l, _)| r == role && l == label); assert!(has("Slider", "Exposure"), "{found:?}"); assert!(found.iter().any(|(r, l, v)| r == "Slider" && l == "Exposure" && *v == Some(0.5)), "the slider's value: {found:?}"); + assert!(ranges.contains(&(Some(spec.min), Some(spec.max))), "the slider's range: {ranges:?}"); assert!(has("Button", "Auto"), "{found:?}"); assert!(has("Button", "Delete mask"), "{found:?}"); } } +/// Names a widget for screen readers (`name` replaces what egui gave it): for a plain egui field +/// whose caption is a separate label beside it. +pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str) { + ctx.accesskit_node_builder(id, |node| node.set_label(name.to_string())); +} + +/// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn +/// beside it, or whose hint is all it shows. +pub(crate) fn named(r: egui::Response, name: &str) -> egui::Response { + screen_reader_name(&r.ctx, r.id, name); + r +} + /// `text` laid out on one line, cut short with … when it is wider than `max` (the galley's /// `elided` says whether it was): for text painted into a fixed box (a tile, a row before its /// buttons), which a plain `Painter::text` would draw past. diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 1935cde95..5e54ff064 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -75,3 +75,22 @@ fn dragging_away_from_readout_does_not_capture_keyboard() { } assert!(!h.view.ctx.egui_wants_keyboard_input(), "a cancelled readout gesture must not retain keyboard focus"); } + +#[test] +fn typing_after_a_released_click_still_edits() { + // Issue #1362: a normal click (press, a few frames held, release) must leave the field + // editing, so the value can be typed without holding the button down. + let mut h = Harness::new(); + let pos = h.rect.center(); + let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![button(true)]); + for _ in 0..4 { + h.frame(vec![]); + } + assert_eq!(h.frame(vec![button(false)]).value, None); + h.frame(vec![]); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after the click"); + h.frame(vec![Event::Text("2".into())]); + assert_eq!(h.key(Key::Enter).value, Some(2.0)); +} diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs new file mode 100644 index 000000000..e28d060bb --- /dev/null +++ b/crates/ui-egui/tests/accessibility.rs @@ -0,0 +1,189 @@ +//! Accessibility audit: walks the AccessKit tree egui exposes to screen readers (VoiceOver, +//! Narrator, AT-SPI) in each main view and Develop tool, headless, and fails on an interactive +//! node with no name, a slider without its value and range, or a canvas tool no key can reach. +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::{Harness, kittest::NodeT}; +use lightcraft_ui_egui::{ + LightcraftApp, Services, + state::{RightPanel, ViewMode}, +}; + +fn harness(view: ViewMode, right: RightPanel) -> Harness<'static, LightcraftApp> { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.left_panel = true; + app.ui.view = view; + app.ui.right = right; + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + h +} + +fn interactive(role: Role) -> bool { + matches!( + role, + Role::Button + | Role::CheckBox + | Role::Switch + | Role::RadioButton + | Role::Slider + | Role::SpinButton + | Role::TextInput + | Role::MultilineTextInput + | Role::SearchInput + | Role::ComboBox + | Role::EditableComboBox + | Role::Tab + | Role::Link + | Role::MenuItem + | Role::MenuItemCheckBox + | Role::MenuItemRadio + | Role::ListBoxOption + ) +} + +/// Every problem found in the harness's current tree, one line each. +fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec { + let mut problems = Vec::new(); + let mut count = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + let role = n.role(); + if !interactive(role) { + continue; + } + count += 1; + let name = n.label().unwrap_or_default(); + let name = if name.trim().is_empty() { n.value().unwrap_or_default() } else { name }; + if name.trim().is_empty() { + problems.push(format!("{place}: unnamed {role:?} at {:?}", n.bounding_box())); + } + if role == Role::Slider && (n.numeric_value().is_none() || n.min_numeric_value().is_none() || n.max_numeric_value().is_none()) { + problems.push(format!("{place}: slider {name:?} lacks value/range")); + } + } + assert!(count > 5, "{place}: the tree has interactive nodes ({count})"); + problems +} + +#[test] +fn every_interactive_node_has_a_name_in_every_view_and_tool() { + let mut problems = Vec::new(); + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] { + let h = harness(view, RightPanel::None); + problems.extend(audit(&h, &format!("{view:?}"))); + } + for right in [ + RightPanel::Edit, + RightPanel::Profiles, + RightPanel::Crop, + RightPanel::Remove, + RightPanel::Masking, + RightPanel::RedEye, + RightPanel::Versions, + RightPanel::Activity, + RightPanel::Keywords, + RightPanel::Info, + ] { + let h = harness(ViewMode::Detail, right); + problems.extend(audit(&h, &format!("Detail/{right:?}"))); + } + // the dialogs: each a window with a title, every control in it named + for dialog in [ + "dialog.export", + "dialog.copySettings", + "dialog.pasteSettings", + "dialog.createPreset", + "dialog.rename", + "dialog.newAlbum", + "dialog.newSmartAlbum", + "dialog.captureTime", + "dialog.labelNames", + "dialog.allMetadata", + "dialog.saveMetadataPreset", + "dialog.autoStack", + "dialog.contactSheet", + ] { + let mut h = harness(ViewMode::PhotoGrid, RightPanel::None); + if let Err(e) = h.state_mut().run(dialog, serde_json::json!({})) { + problems.push(format!("{dialog}: does not open ({e})")); + continue; + } + h.run_steps(6); + problems.extend(audit(&h, dialog)); + let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count(); + let untitled = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty()) + .count(); + if untitled > 0 { + problems.push(format!("{dialog}: {untitled} of {windows} windows have no title")); + } + } + problems.sort(); + problems.dedup(); + assert!(problems.is_empty(), "{} accessibility problems:\n{}", problems.len(), problems.join("\n")); +} + +fn slider_value(h: &Harness<'_, LightcraftApp>, name: &str) -> Option { + h.root() + .children_recursive() + .map(|n| n.accesskit_node()) + .find(|n| n.role() == Role::Slider && n.label().as_deref() == Some(name)) + .and_then(|n| n.numeric_value()) +} + +/// A screen reader's Increment on a develop slider moves it one step, as an arrow key does. +#[test] +fn screen_reader_increment_moves_a_slider() { + let mut h = harness(ViewMode::Detail, RightPanel::Edit); + let before = slider_value(&h, "Exposure").expect("Exposure is in the tree"); + let target = h + .root() + .children_recursive() + .find(|n| n.accesskit_node().role() == Role::Slider && n.accesskit_node().label().as_deref() == Some("Exposure")) + .unwrap() + .accesskit_node() + .locate(); + h.event(egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest { + action: egui::accesskit::Action::Increment, + target_node: target.0, + target_tree: target.1, + data: None, + })); + h.run_steps(4); + let after = slider_value(&h, "Exposure").unwrap(); + assert!(after > before, "{before} -> {after}"); +} + +/// The canvas tools' settings (crop angle and offsets, spot size, mask amount) are reachable with +/// Tab alone, and the arrow keys then adjust them. +#[test] +fn canvas_tool_sliders_are_reachable_with_tab() { + for right in [RightPanel::Crop, RightPanel::Remove] { + let mut h = harness(ViewMode::Detail, right); + let mut reached = None; + for _ in 0..300 { + h.key_press(egui::Key::Tab); + h.run_steps(1); + let focused = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused() && n.role() == Role::Slider); + if let Some(n) = focused { + reached = Some((n.label().unwrap_or_default(), n.numeric_value())); + break; + } + } + let (name, before) = reached.unwrap_or_else(|| panic!("{right:?}: no slider takes the focus with Tab")); + h.key_press(egui::Key::ArrowUp); + h.run_steps(4); + let after = slider_value(&h, &name); + assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}"); + } +} diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 576b3868f..02336c97c 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -9,7 +9,10 @@ a task that can stop is running. Agents see the same list through `activity.list The registry is `lightcraft_engine::activity` (`crates/engine/src/activity.rs`, `Session::activity`). The stack is `crates/ui-egui/src/panels/activity.rs`. Every job that can last more than a moment uses it; **don't add a progress window, panel or progress toast of your own**. A dialog may still show progress inline (the Synchronize Folder -dialog counts files while it scans), and a toast at the end saying what happened is fine. +dialog counts files while it scans), and a toast at the end saying what happened is fine. A view saying its pixels +aren't final isn't progress either: the loupe's "Rendering…" pill (`loupe:rendering`) marks a stand-in or an +out-of-date render on the photo itself, and the render behind it holds no row (a slider drag replaces it many times a +second). ## Adding a task diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..1210e6b54 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -4,7 +4,8 @@ ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF decoding supply sensor data and c ## Colour and tone are separate -A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. +A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. For colour fitting, near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. +A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 128-million-pixel input limit (61 MP bodies' JPEGs, e.g. the ILCE-7RM6 full frame at 66.5 MP, exceed 64 MP; the codec checks the cap before decoding at full size, about 200 MB transient), converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (5 values × 72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout with an sRGB-encoded value axis, applied in linear ProPhoto RGB after the baseline exposure) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The value axis matters: on a sunny waterfall scene dark yellows had to turn ~12° toward orange while brighter yellow-greens turned ~9° toward green, and one shift per hue made both worse. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels). @@ -22,13 +23,31 @@ One third of the pixels are held out from both fits. The combined model must red **A tone curve from a black-and-white JPEG.** A raw shot in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon Monochrome) records full colour, but its camera JPEG is grey, so neither the full look nor the partial look has colour to fit. Such a photo started from the spectral matrices or the neutral fallback with the default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter than the same body's photos shot in colour (issue #577). The grey JPEG still shows the camera's tone curve. When no look was fitted and every pixel of the JPEG proxy but one in a thousand has its channels within 0.002 of each other (`monochrome`; a sepia-toned JPEG has colour and isn't one), the quantile tone curve is fitted alone, luminance against luminance, on top of the colour the photo gets otherwise: the model's camera profile when it has one (in `fit_preview`, like the profile's colour looks), else its spectral matrices or the neutral fallback's (`monochrome_look`). The chroma curve stays neutral. It has the partial look's checks: the JPEG must rank the pixels like the scene (Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey JPEG relates to nothing), and the curve must cut the held-out squared luminance error below 0.7 of the default curve's, in linear and gamma-encoded values, on all pixels or away from edges. Sony's DRO is handled as for colour JPEGs, and white balance stays relative to the as-shot look. The check reads pixels, not the picture style: a colour JPEG of a scene so colourless that every pixel is grey within 0.002 counts too (such a photo gets no look today either). Photos whose JPEG has colour render as before. +## Deep shadows and the camera toe + +The colour pairs exclude JPEG luminance below 0.015 (about L* 13). Using those same pairs for tone ignored the camera's darkest tones. Below the first knot, `CameraTone::apply` could only interpolate in a straight line to black, lifting shadows (#1307). + +Tone fitting now also tries a separate luminance sample set from the same binned proxies. Each sensor channel must be finite and between 0.0001 and 1.5; JPEG channels must be finite and nonnegative, with luminance between 0.0003 and 0.85. The lower JPEG floor is about one 8-bit sRGB code above black. It leaves compression-clipped black samples out of the fit. Luminance does not need the colour fit's per-channel ratios or its JPEG channel floor. The existing clip mask and, on an attempt away from edges, the same edge exclusion still apply. + +The wider set needs at least 96 dark samples (JPEG luminance below 0.015), including at least 32 held-out dark samples. Two thirds train the curve, with the existing quantile medians or conditional-median fallback. Extra samples are split separately, so the wider set cannot put original colour holdouts into tone training. The held-out dark samples must have luminance rank correlation of at least 0.5, with ties sharing their rank. When the original curve can be fitted, the candidate must reduce its held-out dark error by at least 1%, measured as squared gamma-encoded luminance error. Sparse, flat, uncorrelated or invalid shadow samples keep the original curve. + +If the original curve cannot be fitted, the wider curve can still be accepted after the sample-count and rank checks. In either case, the completed look must pass its existing held-out gates: a full look needs finite errors, linear-display squared error below 70% of the neutral fallback's and per-channel RMS no greater than 0.10. Partial and monochrome tone-only looks need luminance rank correlation of at least 0.5 and finite squared errors below 70% of the fallback's in both linear and gamma-encoded values. The monochrome comparison uses luminance; the full and partial comparisons use colour channels. Colour-pair selection, colour fitting and the original colour training split are unchanged. + +The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 5 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 62 redraws cached renders. + +No process-version bump is needed under the [rule for changes to source data](process-versions.md#the-rule). The decoded source carries the fitted camera tone in `SourceInfo`; both V1 and V2 use it in `finish::base_tone`. Existing raws edited under V1 or V2 can therefore change when decoded again. Offline smart previews keep their stored pixels and tone until rebuilt from the original. The scan on library opening runs once per look version for each smart previews folder. If an original was offline at that scan, run Build Smart Previews after reconnecting it. + +Compared with main `701f78ea` at a 1200-pixel long edge, all 15 public raw.pixls.us files across six makes and the private #1021 file retain their accepted look. The darkest 10% moves closer to the JPEG on all seven public deep-shadow files (at least 10% below L* 13) and #1021. On Sony RX100 V sample 1324, its mean L* changes from 4.79 to 2.13 against the JPEG's 2.14; on a6700 sample 6735, 7.20 to 5.23 against 4.60. On #1021 (numbers only), 9.28 to 3.25 against 3.50; the 10th percentile changes from 11.94 to 5.34 against 5.45, and mean CIEDE2000 at 200 pixels changes from 7.25 to 6.37. Five of eight controls are byte-identical. The largest control changes are on a7 IV sample 6928, which still has 6.8% below L* 13: mean CIEDE2000 improves by 0.048, whole-frame mean L* falls by 0.149, and the 5th percentile falls by 1.56 L*. This is a limited camera-JPEG comparison, not a claim of colour calibration or Lightroom parity. + ## Source metadata and white balance Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render jobs used header-only facts (including a hard-coded RAW WB). Decoded pixels and source facts now travel and remain cached together, including across web-worker renders. Smart previews keep the tone curve in their header (the matrix is already in their pixels), so an offline original renders and exports with the same look; proxies built before this fall back to the generic curve until rebuilt. Pixel-statistics/colour-sampling commands use decoded facts too. Render cache version 6 invalidates old thumbnails and loupe renders. Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. -**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative. +**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative, unless their own white-balance presets give the scale (next). + +**Kelvin through the camera's white-balance presets.** Sony ARWs store, in the encrypted `SR2SubIFD`, the multipliers of every white-balance preset, five of them at a stated temperature (2500, 3200, 4500, 6000 and 8500 K). For a camera without measured matrices whose files carry them (`lightcraft_raw::kelvin_presets`: Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S) they take the matrices' place in everything above: the as-shot multipliers are placed on the curve through the five presets (mired linear along it, in log red/green and blue/green), an offset from the curve is the tint, and the decision, the Bradford adaptation, the pixel-identical upgrade and `Op::SetAsShotWb` are the same. The scale is Sony's own: files shot on the Kelvin setting read back what the dial said (ILCA-77M2 3400 K → 3396 K and 5200 K → 5193 K, SLT-A33 4700 K → 4708 K, the public ILCE-3500 sample's 5600 K → 5594 K; with the colour filter one step towards magenta, the SLT-A33's 3400 K reads 3473 K / +11), and the Shade preset reads 7493 K on the public ILCE-7S sample. Off the curve, two constants come from the 14 Sony bodies that have both measured matrices and presets: the tint is the offset scaled by the locus's own spacing (the matrices give 0.90–1.04 of it), and the isotherms lean against the curve's normal (`ISOTHERM_TILT` 0.74, measured 0.59–0.84). On 150 ILCE-7SM2 photos read both ways, the presets' temperature tracks the matrices' to ±4 mired around Sony's 8 % higher scale, and the tint to −9 ± 4. A listed camera whose file has no presets keeps the Kelvin scale with As Shot at D65, as a covered camera without multipliers does. Bodies with measured matrices keep reading through them. What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). @@ -80,6 +99,14 @@ On the public CC0 EOS R100 / RF-S 18–45 mm sample, the measured framing scale This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony, Nikon and Canon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. +### Sony ILCE-7RM6 (A7R VI) + +Full-frame A7R VI files never reached the fit: their 66.5 MP embedded JPEG exceeded the fit's former 64 MP preview cap, so they opened in the neutral fallback. The cap is now 128 MP. On the full-frame corpus file the holdout RMS went from 0.124 to 0.062, and the render's mean RGB from 85/87/77 to 104/117/79 against the camera JPEG's 108/122/82. + +On a local set of 54 ARW6 and lossless ARW photos (the maintainer's own shots plus the 12 CC0 RawDB ILCE-7RM6 samples), 46 were fitted and 8 were rejected by the gates; the kinds of scene behind the rejections were not analysed. On 15 withheld photos (every third usable photo in sorted path order), mean CIE76 ΔE against the camera JPEG was 5.7 (render at 1200 px, comparison at 200 px, matching orientation); in a separate sanity check at the same settings, the APS-C corpus file scored 5.1 and two portrait RawDB files 4.6 and 6.0 (orientation-matched). These numbers were measured before the ARW6 companding curve was replaced by its clean-room derivation (`docs/arw6-compression.md`), which decodes highlights about 0.3 % lower; the fit was not re-measured. + +A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 11 of 15 photos regressed, 3 improved and 1 was unchanged, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The 42 private photos are one photographer's scenes, 38 of them in the Standard creative style (the 12 RawDB files were not inventoried); revisit when a varied multi-photographer set exists. + ## Camera profiles (pooled per model) One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. @@ -130,9 +157,9 @@ When the file's JPEG is black and white, step 2 fits the tone curve alone with t The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the gates or the file has no usable JPEG. White balance of a covered model is in Kelvin through its matrices whichever -look it got (above, issue #730); for every other raw without matrices of its own it stays relative to the as-shot -look (`Photo::relative_wb`). `lightcraft-cli calibrate` still pools the uncorrected camera colour of covered -models, so a profile can be fitted for them. +look it got (above, issue #730), and so is that of a model whose presets give the scale; for every other raw +without matrices of its own it stays relative to the as-shot look (`Photo::relative_wb`). `lightcraft-cli calibrate` +still pools the uncorrected camera colour of covered models, so a profile can be fitted for them. No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, is recorded here yet. Whether they should come before the file-local fit for these models is open until one is. @@ -151,6 +178,22 @@ A proxy older than the constant is brought up to date by the existing Build Smar - when the original can't be read (offline drive), the proxy and its curve are kept exactly as they are (`staleKept`). The old version in its header is the mark: the next Build Smart Previews with the original online rebuilds it. A rebuilt proxy carries the current version, so nothing is rebuilt twice. A proxy is only ever rebuilt from the original, never from another proxy; - the two never write the same proxy at once: each proxy is checked and written under one lock. +## Photo Merge + +A merge (HDR, panorama, HDR panorama) writes a linear DNG of the sources' camera RGB, with the reference frame's colour +tags. A raw without colour matrices of its own has none to give, so its merge used to render with no camera colour: an +HDR merge of CR2s came out green and dark (issue #965). Each source now goes through `starting_colour` as when it is +opened alone, and `starting_colour_tags` writes the result as DNG tags: one forward matrix (from the look's matrix on +top of the camera transform) with a colour matrix and `AsShotNeutral` that agree with it at the as-shot white, the +look's brightness of white as baseline exposure, its hue/saturation table as `ProfileHueSatMap` and its tone curve as +`ProfileToneCurve`. Raws with their own matrices (DNG) keep their tags. Without a look the tags give the colour the raw +falls back to (spectral matrices or the neutral fallback). + +The DNG renders close to, not exactly like, the source: a DNG hue/saturation table doesn't keep each pixel's luminance +as the fitted table does, and the chroma curve has no DNG tag, so it is left out. On two CC0 CR2s (EOS 550D, 40D), an +HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th +percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf648..5ecf41b0d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -9,7 +9,13 @@ ← {"id": 2, "ok": false, "error": "unknown command `nope`"} ``` -Requests are answered on the UI thread between frames (timeout 60 s). +Requests are answered on the UI thread between frames (timeout 60 s). A request is given up +if the app has not started it and draws no frame for 5 s. The reply is then `{"ok": false, "error": "the app drew no frame for 5 s, …"}`, +and the app never runs that request later, so a retry doesn't do it twice. This happens on +Wayland when the window is on a hidden workspace or minimized: the compositor sends no frame +callbacks, so the app's loop is paused. Keep the window on screen while you drive it, or use +headless snapshots or `lightcraft-cli mcp` without `--connect`. A request that has already started +(a long command blocks frames) still gets the full 60 s. **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without @@ -44,7 +50,8 @@ their command behavior. That API invokes the app command dispatcher directly; it Cocoa menu dispatch. Native menu activation and keyboard accelerator delivery require a separate test. Before capturing a finished Detail view, poll `ui.inspect` until the expected `loupe.photo` is active, -`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty. +`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty — or until +`loupe.failed` is set: that photo's render failed, and what is on screen is all there will be. For the loupe, `cached` is a prior view render keyed by the current photo and develop settings; `thumb`, `small` and `embedded` are separate fallback sources. A presented frame can otherwise contain an in-progress rendering placeholder. Require loaded thumbnail textures only when the @@ -58,7 +65,7 @@ filmstrip is visible. | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `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, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | @@ -72,7 +79,7 @@ filmstrip is visible. | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | -| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | +| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:`, `compare:renderFailed:`) | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | @@ -81,7 +88,8 @@ filmstrip is visible. White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on raws with colour matrices — in the file (DNG) or measured for the camera model (`lightcraft_raw::spectral`; issue -#730) — where As Shot reads the file's as-shot white; rendered files and raws without matrices use a relative scale +#730) — and on raws whose white-balance presets give the scale (`lightcraft_raw::kelvin_presets`), where As Shot +reads the file's as-shot white; rendered files and raws without matrices use a relative scale whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge` or a preset whose `wb` sets `temp` / `tint` **without** `scale` keeps the relative meaning it always had (6500 = as shot) on every raw, so agents that mean @@ -140,6 +148,78 @@ opens as one. With no library open (the library problem window) it is an error i places (`EngineError::LightroomCatalog`, so `lightcraft-cli --library` and `mcp --library` too). `library.importLightroom {status: true}` reports the catalog's `path` after the task ends as well as while it runs. +## External controllers + +External apps can use this channel to send commands and poll the running desktop app for feedback +(e.g. knob values or LEDs). Start the app with `--control PORT`, then keep a JSON-lines connection to +`127.0.0.1:PORT`. Discover command ids, parameters and current enablement with `engine.commands`; +use control ids and ranges from `develop.controls` rather than translated labels or widget positions. + +| Task | Command through `engine.execute` | Result / effect | +|---|---|---| +| Read selection and active photo | `library.state {}` | `selection: {ids, active}`; `active` is `null` when no photo is active | +| Read photo identity and settings together | `photo.inspect {}` | Active photo's `id`, `develop`, rating, flag and history in one reply | +| Read a specific photo without selecting it | `photo.inspect {id}` / `develop.get {id}` | Photo details / full develop JSON; omitting `id` reads the active photo | +| Read global slider metadata and values | `develop.controls {section?}` | Array of `{id, label, section, min, max, default, step, decimals, track, value}` for the active photo | +| Set absolute slider values | `develop.set {values: {"light.exposure": 0.7}}` | Undoable edit of the active photo; also accepts `{control, value}` | +| Move a relative control | `develop.adjust {control: "light.exposure", delta: 0.1}` | Adds to the current value, clamped to the control's range | +| Change photo | `library.select {ids: [photoId]}` / `library.next {}` / `library.previous {}` | Changes the active photo; next/previous follow the current view's order | +| Undo / redo | `edit.undo {}` / `edit.redo {}` | Restore settings; poll again for feedback | + +With Auto Sync enabled, `develop.set` and `develop.adjust` can also change the other selected photos. +Only changed settings propagate; spot removal, red eye and the rendering process stay per photo. +During an interaction, propagation occurs when it ends. Re-read affected photos after the edit. +`develop.autoSync {}` toggles the mode; it is not a state query. + +For example, send these requests one at a time and read each reply before sending the next: + +```json +{"id":1,"method":"engine.commands"} +{"id":2,"method":"engine.execute","params":{"command":"photo.inspect"}} +{"id":3,"method":"engine.execute","params":{"command":"develop.controls","params":{"section":"light"}}} +{"id":4,"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"light.exposure":0.7}}}} +{"id":5,"method":"engine.execute","params":{"command":"photo.inspect"}} +``` + +Replies use `{id, ok, result}` or `{id, ok: false, error}`. In the photo snapshot, exposure is +`result.develop.light.exposure`; the controls array's matching `light.exposure` entry has `value`, +`min: -5`, `max: 5` and `step: 0.01`. `wb.temp` is Kelvin on raws with colour matrices and relative +(6500 = as shot) elsewhere, as described above. Re-read values after edits to reflect the stored result. +Local mask adjustments are in the photo snapshot's `develop.masks`; use the mask commands from the +registry (e.g. `mask.adjust`) for those, rather than treating global control values as mask values. + +Feedback uses polling: the channel sends replies to requests, with no unsolicited change events. +Compare successive `photo.inspect` snapshots for photo-id or develop-setting changes, including GUI +edits, photo navigation, presets and undo/redo. GUI slider updates are visible while a drag is in +progress; ending a changed interaction commits it as one undo step (unchanged drags add none). +A controller can group `develop.set` / `develop.adjust` updates between +`develop.beginInteraction {label}` and `develop.endInteraction {}`. The GUI and controllers share +one interaction: another `develop.beginInteraction` or `library.select` ends the current one. +Update controller feedback from reads without sending the same values back as new edits. + +Each request is a separate snapshot. `photo.inspect` keeps identity and settings together; a +`develop.controls` reply has no photo id, so do not associate it with an earlier cached selection +when the user is changing photos. Refresh the selection and retry those reads after navigation. +With no active photo, `develop.controls` returns default values and `photo.inspect` / `develop.get` +without `id` return an error; check `library.state` before treating defaults as photo feedback. +Photo ids are local to the current library/session. After reconnecting or opening another library, +discard cached photo mappings and re-read state; matching photo ids alone do not identify the library. +For persistent libraries, poll `library.info` for the current `path` as well. Polling observes the state +at each read, not every intermediate change. + +The same queries and edits are available through the connected CLI: + +```sh +lightcraft-cli run --connect 127.0.0.1:7980 library.state photo.inspect develop.controls section=light +lightcraft-cli run --connect 127.0.0.1:7980 develop.adjust control=light.exposure delta=0.1 photo.inspect +``` + +For MCP, use `lightcraft-cli mcp --connect 127.0.0.1:7980`: `run_command` reaches the commands above, +`list_controls` / `set_develop` provide slider helpers, and `resources/read` polls +`lightcraft://library`, `lightcraft://photo/active`, `lightcraft://develop/active` and +`lightcraft://controls` ([mcp.md](mcp.md#resources)). Headless CLI/MCP mode owns a separate engine +session; use connect mode for feedback from edits in the desktop app. + ## Headless rendering (no window, no GPU) The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by diff --git a/docs/cr3.md b/docs/cr3.md index 147a83081..138b5d463 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -5,6 +5,14 @@ The CR3 adapter reads the CRX Bayer track, `CMP1` compression descriptor, black/white levels and the default crop come from the file. A Dual Pixel delta track is never substituted for the primary Bayer track. +The EOS R6 Mark III writes `ColorData` version 66, which keeps version 64's as-shot white balance (word 105) and +black level (word 383) offsets. Measured on 33 files from 12 shoots (ISO 100–12800, 1/1250 s–25 s): the as-shot +R, G, G, B quadruple is followed by its colour temperature (3271–7424 K across the shoots) and then the fixed +preset table (Daylight 5200 K, Shade 7000 K, Cloudy 6000 K, Tungsten 3200 K, ...), and the black levels equal the +masked-column levels in every file (512 at low ISO, 2047–2048 above). Word 661 holds 14351 or 13995, but it is not +the clipping point: highlights pile up at 16383 and run smoothly through the range above 14351, so version 66 +takes its white level from the data. + The in-camera aspect ratio (3:2, 4:3, 16:9, 1:1) is not applied to the sensor data. The maker note's `AspectInfo` (tag `0x009a`: aspect code, cropped width, height, left, top) gives the shot's rectangle inside the recommended crop, and the decoder uses it as the default crop, so the developed image has the shape the photographer diff --git a/docs/display-profiles.md b/docs/display-profiles.md index 9d6b11af7..67892a3cb 100644 --- a/docs/display-profiles.md +++ b/docs/display-profiles.md @@ -8,17 +8,25 @@ colour-manage LightCraft's window (wgpu leaves its Metal layer without a colour unmatched), so on a P3 screen previews used to look more saturated than the exported files in Preview or Photos (issue #787). The profile is read again about once a second, so moving the window to another screen is followed. +**Linux uses the profile the desktop assigns in colord** (GNOME / KDE Settings ▸ Color, or a calibrator such as +DisplayCAL) while none is chosen. A background thread asks colord (`org.freedesktop.ColorManager` on the system bus) +every 3 s, so the UI never waits on D-Bus, and a profile change in the desktop's settings shows within that. eframe +doesn't say which monitor the window is on, so with several displays the profile is used only when all of them that +have one share it; otherwise, and without colord, previews stay sRGB (choose a profile by hand then). A compositor +that colour-manages windows itself with the same profile (KWin with an ICC profile set in Display Configuration) +would correct twice; KWin keeps that profile in its own settings, not colord, so this only happens when both are set. + ## Using it Settings ▸ Display ▸ **Choose Profile…** picks an `.icc` / `.icm` file (the dialog opens where the system keeps them: `~/.local/share/icc` on Linux, ColorSync's folders on macOS, `…\spool\drivers\color` on Windows). **Use sRGB** -(on macOS **Use Screen Profile**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) +(on macOS and Linux **Use Screen Profile**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) and applies at once; a file that can't be used is reported there and previews stay sRGB. Agents and scripts: `app.displayProfile {"path": "/path/to/monitor.icc"}` (`""` or `null`: none; no `path`: report only) returns `{path, description, kind: "matrix" | "lut", primaries: {red, green, blue: [x, y]}}`; the screen's -profile (macOS, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile` -(`apps/lightcraft/src/screen_profile.rs`); headless sessions have none and stay sRGB. +profile (macOS or Linux, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile` +(`apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`); headless sessions have none and stay sRGB. ## How it works @@ -47,5 +55,5 @@ LUT-based profile's correction about 65 ms. Drafts during slider drags skip the ## Not yet -- Picking up the profile the operating system assigns on Linux and Windows (X11 `_ICC_PROFILE`, colord, WCS) and - following the window across monitors there (macOS does both, above). +- Picking up the profile Windows assigns (WCS: needs FFI, so an isolated `unsafe` helper crate) and X11's + `_ICC_PROFILE`; following the window across monitors on Linux and Windows (macOS does both, above). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 79c66b69c..d4bfb8fa9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats measured from the code and the target's Info.plist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Render to DNG as an export format) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every format Lightroom reads or writes, with our support, fidelity and tests. Camera raw formats are deep enough to have their own document: [raw-parity.md](raw-parity.md). @@ -25,7 +25,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target | | GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target | | DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed or LJ92; ⬜ lossy DNG, embedded original | `crates/raw/src/dngwrite.rs` | -| Render to DNG (15.5) | — | ✅ | — | ⬜ | new in Classic 15.5 | +| Render to DNG (15.5) | — | ✅ | — | 🟡 export format "Rendered DNG" (linear float DNG, edit baked in); not yet added to the catalog | `crates/raw/src/dngwrite.rs` (`write_rendered_dng`) | | Original + sidecar | — | ✅ | — | ✅ | | | HDR gain map JPEG (ISO 21496-1) | ✅ | ✅ | ✅ (gain map read) | ✅ | `crates/codecs/src/gainmap.rs` | | PQ / HLG HDR | ✅ | ✅ AVIF, JXL, TIFF | — | 🟡 PQ AVIF, PQ JPEG XL, float TIFF; no HLG | | @@ -77,3 +77,4 @@ Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is writte |---|---|---| | 2026-10-10 | major | Created from the code and the installed Lightroom's document types | | 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR | +| 2026-10-12 | minor | Render to DNG: "Rendered DNG" export format (linear 16-bit float DNG of the rendered photo) | diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..39e6c6486 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (gap 10: Leica Q3 verified, 236 models; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -15,7 +15,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| -| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | | 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) | @@ -24,11 +24,12 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | -| 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | +| 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | @@ -37,11 +38,13 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | | 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | +| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) | +| 22 | **Pen pressure**, OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | -| 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | +| 25 | **File writes**: lossy DNG; AVIF import; Render to DNG as a catalog command (the export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | -| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | +| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -72,6 +75,14 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | +| 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | +| 2026-10-12 | minor | Gap 23 (accessibility): the AccessKit tree audit and its fixes landed; what is left is canvas placement by keyboard and a real screen-reader session; 10–20 h → 4–8 h; gap not closed | +| 2026-10-12 | minor | Gap 22: Linux previews use the display profile from colord | +| 2026-10-12 | minor | Gap 27: the CPU ↔ GPU equivalence suite passes on an AMD Radeon 8060S (Vulkan, Linux), the first non-Apple GPU verified; 15–30 h → 13–27 h; gap not closed | +| 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | +| 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | +| 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 883c3a0bf..1fc80c95b 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -139,7 +139,8 @@ walks the drive. - Kernels (`crates/gpu/src/wgsl/`): `geom` (orientation pixel map, bilinear through the crop/straighten/flip affine or the full lens + perspective warp), `resize` (separable resample with the CPU's taps; Mitchell prefilter, box/bilinear for the fast guided filter), `blur` (box - passes with running sums over pixel chunks; three each way = the CPU's Gaussian), `map` + passes, three each way = the CPU's Gaussian: horizontal ones sum each pixel's window, so adjacent + threads read adjacent pixels; vertical ones slide running sums down chunks of a column), `map` (log luminance, dark channel, guided-filter steps, white balance, luminance / colour NR, airlight sampling), `mask` (linear / radial / luminance / colour range / brush shapes, combine, finalize), `finish` (the whole per-pixel stage) — each mirrors a named CPU function. @@ -232,8 +233,14 @@ from outside the window: the vignette geometry arrives as the `VIG_VIEW` paramet noise-reduction size come from the whole frame, and a window that spots or Auto Mask strokes grew is cut back after the readback. The tests skip (pass with a note) when no adapter exists. +**AMD Radeon 8060S (RADV STRIX_HALO, Vulkan 1.4, Mesa 26.2.4, Linux 7.2), 2026-10-12**, the first +non-Apple GPU verified: all 16 tests pass, 135 compared cases. 124 differ by max 1 LSB, 10 are +bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002% of pixels. Worst +mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds +(`cargo test -p lightcraft-gpu --release --test equivalence -- --nocapture`). + Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on -Metal and running-sum order in the box filters — all far below one 8-bit step. +Metal and summation order in the box filters — all far below one 8-bit step. ## Measurements `render_bench` on `corpus/raw/arw-sony-a7m3-compressed.arw` (24 MP; loupe = 2560 px preview → @@ -251,6 +258,14 @@ so CPU numbers are pessimistic; the GPU numbers are less affected): | per-pixel stage 1920×1280, + vibrance/saturation | 53–65 ms | 5 ms | | export render 6000×4000 | 1037–1414 ms | 291–330 ms | +On Windows (Radeon 780M iGPU, DX12), a full-size JPEG export of the same raw with heavy edits (NR 30 +/ 25, clarity, texture, dehaze, sharpening, highlights / shadows) took 3.6–4.0 s on the GPU against +1.9–2.0 s on the CPU: the horizontal box pass had each thread slide a running sum over 16 pixels, so +the threads of a workgroup read pixels 16 apart, and it took 9–15× as long as the vertical pass. +Summing each pixel's window instead (adjacent threads, adjacent pixels): 1.9–2.0 s, with the GPU's +NR stage 1.5 → 0.4 s and spatial planes 0.89 → 0.29 s (`LIGHTCRAFT_PROFILE`). The ignored test +`bench_kernels` (`crates/gpu/src/render.rs`) times the passes on a 24 MP plane. + GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel compilation: ~0.4 s once per process — the desktop app starts it on a background thread at launch (`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create @@ -267,7 +282,7 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo developed image in ~50 ms. ## Memory (M5.6) -A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles (issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). +A view's stages keep an uploaded source only up to 96 MB (a Preview-level source); a bigger one (the original a zoom window is cut from) is kept once, outside the views' stages, for all views of the photo (the Before and After windows and every pan share it; the Before window of a Before/After waits at first open until the After's job has handed the original over, or each would decode and upload its own copy) and given back when the app idles with no zoom window on screen (a zoomed view keeps it for its next pan: a 64 MP original is 800 MB to upload again; issue #323: kept per view it counted against the stage budget on every tick of a drag, and not kept every new window uploaded 288 MB again). - `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f1defc57..8e3a96b8f 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (monitor profile from colord on Linux); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources: [gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md), @@ -8,11 +8,11 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra | Feature | Target | LightCraft: macOS | Windows | Linux / BSD | Web | Status | |---|---|---|---|---|---|---| -| GPU for display and editing | Metal / DX12, "Use GPU for display / image processing / export" | wgpu Metal: geometry, resize, blur, WB, NR, guided filter, dehaze, masks, finish; CPU fallback per render | DX12 only (Vulkan opt-in, #136) | Vulkan | CPU only (no WebGPU yet) | 🟡 defringe, spot removal, heuristic masks, long brushes and high-bit exports stay on CPU | +| GPU for display and editing | Metal / DX12, "Use GPU for display / image processing / export" | wgpu Metal: geometry, resize, blur, WB, NR, guided filter, dehaze, masks, finish; CPU fallback per render | DX12 only (Vulkan opt-in, #136) | Vulkan (CPU ↔ GPU equivalence verified on an AMD Radeon 8060S, RADV) | CPU only (no WebGPU yet) | 🟡 defringe, spot removal, heuristic masks, long brushes and high-bit exports stay on CPU | | GPU export | ✅ | ✅ (6000 × 4000 in ~0.3 s vs ~1.2 s CPU) | ✅ | ✅ | — | ✅ (black-export fix on an Intel iGPU, #78) | | AI on GPU / neural engine | Metal / DirectML / Core ML, model per feature | AI denoise on GPU or CPU (setting); SAM 3 on Metal via candle; faces on CPU | CPU / GPU denoise | CPU / GPU denoise | — | 🟡 no Core ML / Neural Engine / DirectML | | HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) | -| Monitor profile | automatic per display, follows the window | 🟡 picked by hand | 🟡 | 🟡 | — | 🟡 OS profile not read; no follow across monitors | +| Monitor profile | automatic per display, follows the window | ✅ the screen's profile, followed across screens | 🟡 picked by hand (WCS needs FFI) | 🟡 colord's profile (one display, or all sharing one); no follow across monitors | — | 🟡 Windows OS profile not read; Linux per-monitor follow missing | | Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) | | Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS | @@ -28,14 +28,16 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra **~40% ready** (estimated): the GPU editing path is real and fast (measured: 24 MP exposure drag 3.9 ms on GPU vs 22–42 ms CPU; NR drag 18 ms vs 301–471 ms), but HDR display, pen pressure, tethering, printing, automatic monitor profiles and neural-engine inference are missing. GPU -correctness is proven on Apple GPUs; other vendors rely on user reports. +correctness is proven on Apple GPUs and on one AMD GPU on Linux (Radeon 8060S, Vulkan / RADV: the +equivalence suite passes with the same max 1–2 LSB as on Metal, [gpu-pipeline.md](gpu-pipeline.md#correctness-cpu-oracle-and-equivalence-tests)). +NVIDIA, Intel and Windows DX12 still rely on user reports. ## Remaining (Opus 5.5 agent hours) | Work | Hours | Needs | |---|---:|---| | HDR display (EDR on macOS, Windows HDR, Linux where possible) | 10–20 | an HDR display to verify (human) | -| OS monitor profile, follow window across displays | 5–10 | multi-monitor setup | +| OS monitor profile: Windows WCS (an FFI helper crate, like `crates/sysmem`), follow the window across displays on Linux | 4–8 | multi-monitor setup | | Pen pressure for brush masks (winit tablet events or a pure-Rust tablet layer) | 6–12 | a tablet | | Remaining stages on GPU (defringe, spot removal, masks) | 10–20 | | | Tethered capture (PTP over USB in pure Rust, Canon / Nikon / Sony first) | 25–45 | cameras (human) | @@ -47,4 +49,6 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Monitor profile: Linux reads the display profile from colord; macOS row reflects #787 | +| 2026-10-12 | minor | CPU ↔ GPU equivalence suite run on an AMD Radeon 8060S (Vulkan, RADV, Linux): 16 tests / 135 cases pass, max 2 LSB, mean ≤ 0.0003; first GPU verified outside Apple | | 2026-10-10 | major | Created | diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 878603e01..1f9deeea4 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -52,7 +52,8 @@ RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvi as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported (issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). A raw whose camera has -measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…) edits in Kelvin +measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…), or whose white-balance +presets give a Kelvin scale (`lightcraft_raw::kelvin_presets`: ILCA-99M2, ILCA-77M2…), edits in Kelvin and keeps the Kelvin value too (issue #730) — when its file was found and probed, since the camera is read from the file, not from the Lightroom catalog; a missing file is treated as a relative-scale raw until Reload reads it. diff --git a/docs/mcp.md b/docs/mcp.md index e1fb07373..e09138cfb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,7 +170,11 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | | `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true | -Tools taking `id` make that photo active first; without it they act on the active photo. +`get_develop {id}` and `render_photo {id}` use that photo without changing the selection. +`set_develop`, `crop` and `export` with a supplied photo `id` end the current edit interaction, +replace the selection with that one photo and make it active. Without `id`, +`get_develop`, `set_develop` and `crop` use the active photo; `render_photo` also falls back to the first +selected photo, and `export` uses the selection for batch export, as above. ### Connect mode only @@ -208,6 +212,13 @@ still reaches every command. | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | +Read these resources again to poll current state; they are snapshots, and `initialize` advertises +`resources.subscribe: false`. `lightcraft://photo/active` includes the photo's `id` and `develop` +settings together; `lightcraft://controls` contains slider values for the active photo at that read. +Separate reads can observe different active photos. For controller feedback from GUI edits, use +connect mode; headless mode owns a separate session. See [External controllers](control-protocol.md#external-controllers) +for command mapping, live slider values and polling semantics. + ## Example session ```text diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..76d91ba3e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -44,7 +44,7 @@ cited here still exists. Formerly docs/parity.md. | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | -| S. Export (EXP) | 15 | 3 | 0 | 0 | 7/7 (100%) | 7/7 (100%) | +| S. Export (EXP) | 15 | 4 | 0 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. ## Top gaps and shortcut notes @@ -80,9 +80,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | 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`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | -| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | @@ -94,7 +94,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | @@ -107,7 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | 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` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog) | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | @@ -125,21 +125,21 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own | | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | -| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | +| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | -| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle | +| LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | 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 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | -| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram | -| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | | +| LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | +| LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | |, `apps/lightcraft/src/hdr_display.rs` (`choose`, `preferred_hdr_format`) | SDR/HDR decision (platform, surface format, OS headroom, `LIGHTCRAFT_HDR_DISPLAY=0`) is unit-tested and logged at start; always SDR today because eframe configures an 8-bit surface; no OS headroom query yet; | ## D. Search & filter (FILT) @@ -167,7 +167,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| 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` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal) | +| 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` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -184,20 +184,20 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | +| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag; ⌥-drag moves it a tenth as far for fine adjustment, issue #1363); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON | | LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | | | LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | | | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | 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`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | 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.*`, `cmd:develop.autoBwMix` | eight bands; Auto pushes each hue band's colourful pixels away from the mean lightness (own rule) | | LR-EDIT-COLOR-POINTCOLOR | Point colour | P1 | ✅ | `cmd:pointColor.pick`, `cmd:pointColor.delete` | | -| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; Shift-drag locks the hue and changes saturation only, showing the spoke as a guide; the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | +| LR-EDIT-COLOR-GRADING | Colour grading wheels | P0 | ✅ | `ctl:grading.*`, `crates/ui-egui/src/panels/edit.rs` (`grading`) | 3-Way / Shadows / Midtones / Highlights tabs; a range tab shows one large wheel with Hue, Saturation and Luminance sliders; Blending and Balance in every tab; 3-Way shows the hovered wheel's hue and saturation; every wheel marks its neutral centre; wheel modifiers as in Lightroom: Shift-drag keeps the puck's hue and changes saturation only (the spoke is the guide; a puck at the centre has no hue, so it takes the angle where the pointer pressed), Cmd/Ctrl-drag keeps the saturation and changes hue only (the circle is the guide), Alt/Option-drag fine-adjusts at 0.1x and combines with either lock, and a hover tooltip lists them (issues #559, #1160; tests `shift_dragging_across_the_tangent_keeps_the_hue_and_moves_saturation`, `shift_drag_keeps_the_pucks_hue_wherever_the_pointer_grabs`, `cmd_drag_keeps_the_saturation_and_changes_only_the_hue`, `alt_drag_fine_adjusts_to_a_tenth_of_the_pointers_travel`, `alt_works_with_shift_and_cmd_constraints` in `crates/ui-egui/src/tests_grading.rs`); the large wheel adds 25/50/75 % saturation rings (issue #559); each wheel's eye hides its effect for viewing only (`RenderJob::with_hidden_grading`, not saved, export unaffected) (issue #737). No Global wheel tab yet | | 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` | | @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it), kept with smart previews (see docs/camera-preview-colour.md); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -304,6 +304,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | +| LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | @@ -329,7 +330,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | | LR-VER-PANEL | Versions panel | P1 | ✅ | `cmd:panel.versions`, `cmd:version.create`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update`, `crates/ui-egui/src/panels/right.rs` (`versions`) | Named / Auto tabs, a thumbnail of each version's look, name and date, current marker; resting on a version previews it in the loupe; click restores, double-click renames; row menu (update, rename, set as before, delete). No automatic versions are created yet (LR-VER-AUTO) | | LR-VER-AUTO | Automatic versions | P2 | ✅ | `crates/engine/src/lib.rs` (`auto_version`) | leaving an edited photo keeps its settings as an Auto version (when they differ from the latest version; 20 kept; not an undo step); Versions panel ▸ Auto | -| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | +| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore`, `cmd:history.previous`, `cmd:history.next` | going to a step moves a per-photo cursor without adding a step; later steps stay listed (greyed) until a new edit drops them; `history.list` returns `cursor` | | LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | ## N. Copy / paste / sync (SYNC) @@ -346,7 +347,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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-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; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | | 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 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -366,7 +367,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | | LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | | LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) | -| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 PQ AVIF, 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HLG yet; no HEIC (no permissive HEVC encoder) | +| LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 AVIF with PQ or HLG (`hdrTransfer`, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) | ## R. Video (VID) @@ -390,7 +391,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | -| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | +| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | 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 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | @@ -399,8 +400,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | +| LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -426,7 +428,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | | LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | -| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: picking up the profile the OS assigns on Linux and Windows (X11 `_ICC_PROFILE`, colord, WCS), per-monitor profiles there | +| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | 🟡 | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: Windows' WCS profile (needs FFI, so `unsafe`), X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) | | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.download`, `cmd:faces.models.downloads`, `cmd:faces.models.downloadCancel`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/panels/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with `lightcraft-fetch`), or add any `.onnx` by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (`crates/faces/src/net.rs`, `crates/faces/src/runtime.rs`; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces; the Recognise faces switch is off until a recognition model is chosen and installed (`faces.enable {enabled: true}` refuses otherwise; removing the last recogniser switches it off), issue #678 | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | @@ -457,7 +459,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | -| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | @@ -472,9 +474,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | -| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | +| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | @@ -506,7 +508,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-FILE-EXPORTPREV | Export with Previous | P0 | ✅ | `cmd:app.exportPrevious` | ⌥⇧⌘E | | MENU-FILE-EXPORTPRESETS | Export preset submenu | P0 | ✅ | `cmd:app.export` (`preset`), `crates/ui-egui/src/menubar.rs` (Export with Preset) | built-ins, then user presets, then Custom… (the dialog); exports to the last folder | | MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | | -| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor, and Edit In on a photo's right-click menu | +| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor / Open Original in Other Editor (RAW, no copy), and Edit In on a photo's right-click menu | | 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 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) | @@ -563,7 +565,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-RENAME | Rename N photos… | P1 | ✅ | `cmd:dialog.rename` | live “Rename N Photos…” label | | MENU-PHOTO-CAPTURETIME | Edit capture time… | P1 | ✅ | `cmd:dialog.captureTime` | | | 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-DELETE | Delete N photos… | P0 | ✅ | `cmd:photo.delete` | asks first (Settings ▸ General, on by default, #1239); static label | | MENU-PHOTO-MOVETOCLOUD | Move/copy to cloud | OOS | 🚫 | | | | MENU-WINDOW-MINIMIZE | Minimize / zoom | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Minimize (⌘M) and Zoom items at the top of Window | | MENU-WINDOW-PANELS | Panel switches | P0 | ✅ | `cmd:panel.edit`, `cmd:panel.crop`, `cmd:panel.remove`, `cmd:panel.masking`, `cmd:panel.presets`, `cmd:panel.versions` | | @@ -577,7 +579,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; one Close button, like About (#782); JSON for agents (`open: false`) | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | -| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export`, `cmd:file.addPhotos`, `cmd:file.addFolder`, `cmd:file.addFromDevice` | add selected, Import ▸ Import Photos… / Import from Folder… / Import from Device ▸ (one entry per camera or card, as in File ▸ Import from Device; the import review opens with that album chosen: `{album}` on those commands; `button:albumImport`), export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first | @@ -651,7 +653,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-ERASE | Erase while held — ⌥ | P0 | ✅ | `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/panels/masking.rs` | ⌥ while painting flips Add ↔ Erase for that stroke; Add/Erase buttons too | | 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`), `crates/ui-egui/src/shortcuts.rs` | Shift+Z pick, Shift+X reject, Shift+U clear; Shift+P also picks and advances in the library grids; exactly one step with Auto Advance enabled | -| KEY-NEXTPREV | Next / previous — → / ← | P0 | ✅ | `cmd:library.next`, `cmd:library.previous` | | +| KEY-NEXTPREV | Next / previous — → / ←; above / below — ↑ / ↓ | P0 | ✅ | `cmd:library.next`, `cmd:library.previous`, `cmd:library.up`, `cmd:library.down` | ↑ / ↓ go to the cell above / below in the grids (nearest by position in justified rows; the top / bottom row stays put), one photo in the filmstrip (issue #1171); no vim keys by default (H, J, K, L are taken), but they can be assigned in the keyboard shortcuts editor | | 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` | | @@ -684,10 +686,10 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-IMPORT | Full import dialog | P1 | ✅ | `cmd:library.import` (`mode`, `destination`, `organize`, `rename`, `metadataPreset`, `preset`, `keywords`, `album`), `crates/ui-egui/src/import.rs` | review grid with duplicates skipped; add in place or copy (library Originals or any folder; by day / by month / one folder / custom folder template such as `{date:%Y}/{date:%Y%m%d}` → 2026/20260114, kept inside the destination; example destination shown; rename template with live example, Tags picker (insert at cursor) and unknown-tag warning, numbered across the import); develop preset, metadata preset, keywords, album on import | | LRC-LIB-AUTOIMPORT | Watched-folder import | P2 | ✅ | `cmd:library.autoImport` | see LR-IMP-AUTO | | LRC-LIB-TETHER | Tethered capture | P2 | ⬜ | | | -| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info); no people view | -| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked | +| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) | +| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) | | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | -| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | +| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | @@ -707,12 +709,12 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | -| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | +| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | 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`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | +| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.previous`, `cmd:history.next`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history; clicking a step loads it without adding one (current step highlighted, later steps greyed and returnable until a new edit truncates them; one undo step; cursor saved in the catalog, old catalogs open at the latest step) | | LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings (the display's gamut is the monitor profile's, `cmd:app.displayProfile`, else sRGB), Create Proof Copy (a named virtual copy). printer / paper ICC profiles can be loaded as the proof profile (size-capped, the last few kept), with rendering intent (Perceptual / Relative Colorimetric), Simulate Paper & Ink and a device-gamut warning (`crates/codecs/src/proof.rs`, `crates/engine/src/proof.rs`) | | LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | `cmd:develop.sync`, `cmd:develop.autoSync`, `cmd:develop.pastePrevious` | Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner | | LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | `cmd:develop.matchExposure` | Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's | @@ -787,9 +789,27 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | +| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | +| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Pentax PEFs without area tags lose their masked border lines (issue #1271); status unchanged (🟡) | +| 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | +| 2026-10-11 | minor | LR-IMP-PREVIEW-READ: a preview reads the raw's header and its embedded JPEG instead of the whole file (a 56 MB RAF: 56.5 MB → 5.9 MB, ~10 s → 0.96 s over SMB, byte-identical pixels), and an unedited raw's grid thumbnail is that camera JPEG itself, so no sensor decode is queued behind it. A CR3's JPEG track and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads; the formats it cannot describe (CRW/MRW/X3F, NEF/ORF/PEF) keep the whole-file path | +| 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 | +| 2026-10-12 | minor | LR-PREF-DISPLAY: on Linux, previews follow the display profile the desktop assigns in colord | +| 2026-10-12 | minor | LR-IMP-FORMATS: transparent areas are flattened onto white in linear light instead of rendering black (issue #1212); status unchanged (🟡) | +| 2026-10-12 | minor | LRC-LIB-MISSING: folder and disk context menus locate a moved tree recursively without relinking missing photos outside it (issue #1194) | +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Canon PowerShot G9 as-shot white balance from WBInfo (issue #1273); status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Olympus E-10 as-shot white balance and black level from the main maker note (issue #1273); status unchanged (🟡) | +| 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | +| 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | +| 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | +| 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | +| 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | +| 2026-10-11 | minor | LR-IMP-FORMATS: EOS R6 Mark III CR3 opens with its as-shot white balance and black level (`ColorData` version 66); status unchanged (🟡) | | 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) | | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..e3fdf5fcf 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,11 +14,13 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| Decoded from sensor data and **verified** on a real file | **236** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **941** (65%) | measured from the format rules (631 of them DNG, mostly phones) | | **Preview-only** (embedded JPEG) | **233** (16%) | measured | +| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **232** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 83 / 639 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | | …of which on a body we have verified | **~42%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | @@ -46,7 +48,9 @@ committed table is stale. the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body - there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the + there, 178 files from 118 bodies), and a recorded check of 38 private Leica Q3 DNGs (2026-10-11: every file + decoded and developed at full size, 9520 × 6336 and 6336 × 9520; mean CIE76 ΔE to the camera JPEG 6.3–13.9 + at 400 px; the files are not committed). Bulk-check body lists were matched to Adobe names through the raw.pixls.us folder names. Checks without a body list (≈ 60 NEFs, 35 CR2 bodies for the CFA layout) are not counted as verified. - **Usage weights** ([raw/usage-weights.tsv](raw/usage-weights.tsv)): maker buckets from 2025 market @@ -72,16 +76,16 @@ committed table is stale. | Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | | Google | 88 | 1 | 87 | 0 | 0 | — | | Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | -| Leica | 61 | 13 | 48 | 0 | 0 | — | +| Leica | 61 | 14 | 47 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | -| Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF | +| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | | Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **236 (16%)** | **941 (65%)** | **233 (16%)** | **36 (2%)** | | ## By use (estimated) @@ -95,9 +99,9 @@ committed table is stale. | OM / Olympus | 3% | 3% | 1% | compressed ORF | | Phones | 3% | 93% | 1% | DNG | | Pentax / Ricoh | 1.5% | 94% | 14% | | -| Leica | 1% | 95% | 5% | | +| Leica | 1% | 96% | 21% | the Q3 verified | | DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | +| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F | | **Weighted** | 100% | **85%** | **42%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually @@ -109,7 +113,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | Format | Variant | Status | Evidence | |---|---|---|---| -| CR2 | lossless JPEG slices | ✅ | 8 corpus bodies; CFA phase from `CR2CFAPattern` (#85, 35 bodies); masked-column black level | +| CR2 | lossless JPEG slices | ✅ | 8 corpus bodies; CFA phase from `CR2CFAPattern` (#85, 35 bodies); masked-column black level; white level from the active area only (#1272: masked rows at 16383 hid the image plateau on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1), clipped highlights at 81–98%) | | CR2 | sRAW / mRAW (YCbCr) | ✅ | `vendor/cr2/sraw.rs`, 31 samples from 16 models | | CR3 | CRX lossless, version 0x100 | ✅ | M50, R100, R8 exact against a reference; R5 Mark II (odd-height planes) pinned | | CR3 | C-RAW 0x100 (horizontal tiles), 0x200 (single 14-bit tile, adaptive QP incl. the QP-map escape) | ✅ | same 3 bodies exact; R5 Mark II pinned, QP maps of 33,665 files decode exactly, matches the camera JPEG | @@ -118,10 +122,10 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | CR3 | HEIF / HDR PQ | ⬜ | HEVC tracks recognised, not decoded | | CR3 | dual-pixel raw | ⬜ | never substituted | | CRW | all | ⬜ | preview | -| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633 | +| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4 and D90 image area and white level measured against the camera JPEG | | NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | | NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | -| ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024 | +| ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024; the Hasselblad Lunar, a NEX-7 under Make HASSELBLAD, routed by Sony's raw coding (#1275) | | ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured | | ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` | | ARW | 12-bit compressed of DSLR-A200…A390 | ⬜ | preview (#535) | @@ -133,18 +137,18 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | ORF | uncompressed 16-bit, 12-bit packed, two-field 12-bit, 12.8 bpp | ✅ | E-1, E-400, XZ-2 corpus; #651 | | ORF | **compressed** (every Olympus / OM body since ~2008) | ⬜ | `orf.rs` refuses; the E-M1 and E-M10 III corpus files are `KNOWN_UNSUPPORTED` | | RW2 / RWL / RAW | formats 2, 4, 5, 6, 7, 8 and 16-bit words | ✅ | 118 bodies (#215) | -| PEF | uncompressed, Huffman, packed 12-bit | ✅ | K10D, K-3, K-5 IIs | +| PEF | uncompressed, Huffman, packed 12-bit | ✅ | K10D, K-3, K-5 IIs; files without area tags lose their masked, empty and saturated border lines (#1271: KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) | | PEF / DNG | Pentax pixel shift | ⬜ | frames not combined | | SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 | | SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused | -| DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs | +| DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | | DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | | DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL only; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | | 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | | 3FR / FFF | Hasselblad compression | ⬜ | preview | -| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview | +| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | | DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | ### Compressed ORF: a clean-room starting point @@ -171,8 +175,10 @@ sample files. A pointer for contributors, not legal advice. | Foveon | ✅ (older bodies) | — | ⬜ | | Monochrome / LinearRaw | ✅ | handled, no demosaic | ✅ | | Pixel shift (Sony ARQ, Pentax, OM high-res, Nikon) | ✅ (Sony, Pentax, Nikon) | — | ⬜ | -| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) | +| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed); CR2 white level taken from saturated masked rows (#1272, fixed) | | Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | +| Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) | +| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | | Lens metadata | ✅ read for profile selection | EXIF lens fields read; used only for the in-file corrections below | 🟡 | | Highlight reconstruction | ✅ | neutral clip, chromaticity diffusion (`highlight.rs`) | 🟡 tuned by eye | | AI denoise | ✅ Bayer and X-Trans, model bundled | Bayer only, user-installed weights, pure-Rust CPU / GPU | 🟡 no X-Trans, no shippable weights | @@ -188,7 +194,7 @@ sample files. A pointer for contributors, not legal advice. | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | -| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730); other raws keep the relative scale (#296) | +| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where Sony's white-balance presets in the file give the scale (6 bodies, `kelvin_presets`); other raws keep the relative scale (#296) | **How far the default rendering is from Adobe's** (CIE ΔE; measured where stated): @@ -266,7 +272,14 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged | +| 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged | +| 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged | +| 2026-10-12 | minor | Olympus E-10: as-shot white balance (`0x1017`/`0x1018`) and black level (`0x1012` in `0x102c` ValidBits units) from the main maker note, issue #1273; ΔE76 against its thumbnail 18.5 → 11.5, camera-look fit now accepted; counts unchanged | +| 2026-10-13 | minor | Nikon D90 image area (4288 × 2848 at 10, 10) and white level (3830) measured on 10 of 550 NEFs from one body: its 42 filler columns no longer develop as a pink band | +| 2026-10-12 | minor | Hasselblad Lunar verified (a Sony NEX-7 ARW under Make HASSELBLAD, now decoded): 236 verified, 232 preview; an uncompressed Leaf MOS is recognised as a raw (preview only) instead of opening as a greyscale image of its mosaic (#1275) | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | +| 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/raw-preview-reads.md b/docs/raw-preview-reads.md index bac09ea12..fe82f4662 100644 --- a/docs/raw-preview-reads.md +++ b/docs/raw-preview-reads.md @@ -28,11 +28,12 @@ libraries. Measured on one real file (`DSCF9492.RAF`, 56.5 MB) over a GVFS/SMB s 4. When the prefix already covers the range — the usual case, since it was grown to it — the window is a slice of the prefix. Reading it again would double a 5.6 MB preview. -Formats whose preview pointer is not in the header (CR3 scans boxes; a TIFF pointer tag can sit in -an IFD the prefix cuts short; CRW/MRW/X3F are found by scanning) keep the whole-file path, so -nothing regresses: `preview_window` returns `None` and the loader reads the file as before. A pointer -that lies, a short read or a file that changes underneath us ends in the same fallback — the user's -preview is never lost to a damaged header. +Formats whose preview pointer is not in the header — a Canon CR3's boxes, a TIFF raw whose preview +tag sits in an IFD the prefix cuts short — are planned instead (next section but one), and what the +planner cannot describe keeps the whole-file path, so nothing regresses: `preview_window` returns +`None` and the loader reads the file as before. A pointer that lies, a short read, a plan that does +not fit or a file that changes underneath us ends in the same fallback — the user's preview is never +lost to a damaged header. `LIGHTCRAFT_PROFILE=1` prints what each preview cost: @@ -40,6 +41,53 @@ preview is never lost to a damaged header. [profile] preview …/DSCF9492.RAF: read 5857257 of 59277632 bytes (header 5857257, window 5857109) ``` +## What the header cannot point at: the plan + +A Canon CR3 keeps its preview in a JPEG track, and a TIFF raw (CR2, ARW, DNG, RW2, 3FR …) can keep +its preview tag in an IFD the header route's prefix cuts short. Neither has a field step 2 above can +read, so `preview_window` asks `lightcraft_raw::sparse::preview_plan` — the module the +Lightroom-catalog import probes with — which ranges `embedded_preview` reads, and reads exactly +those: + +- the buffer is the file's length with only the planned ranges filled in; every other byte stays zero + and its pages are never written. Its cost is the bytes read — on macOS a freed buffer of that size + may stay in the allocator's cache instead of going back to the OS (measured on a 38k-photo import, + [#832](https://github.com/storytold/lightcraft/issues/832)) — and the loader's memory gate counts + the whole file either way; +- the module's own guarantee is what this path needs, and its corpus test asserts it: with the + skipped bytes `0` and again `0xFF`, what a partly loaded buffer yields is what the whole file + yields. A second corpus pass now asks `preview_plan` for **every** file, including the ones whose + header probe reads samples and whose header plan is `Whole`, which is where that plan was untested + before; +- the quick path must be able to show what the whole-file path would. Two container facts are not in + the JPEG, so a file that states one where the bounded header cannot see it declines and the whole + file keeps the preview: a preview colour space of **Adobe RGB** (the whole-file path applies it + through `decode_raw_preview`; a JPEG-only decode has the JPEG's own space, else sRGB — a container + that says sRGB does not decline, since that is what the decode assumes anyway), and the file's own + EXIF orientation when the preview JPEG carries none of its own; +- a format the planner cannot describe — NEF, ORF and PEF probe from samples, CRW/MRW/X3F are found + by scanning — a plan that does not fit the buffer or wants more than 60 % of the file (the bound the + import path's own partial read uses), or a window that does not decode all answer `None`, and the + loader reads the file whole exactly as before: the plan can cost bytes but never the picture. + +Measured over the CC0 corpus (111 raws, `cargo xtask corpus --download`): **52 files take the plan +route**, reading a median **7.8 %** of their bytes (0.9 %–27.7 %) — by format RW2 3.3 %, Leica RWL +4.1 %, ARW 7.5 %, CR3 8.4 %, DNG 9.7 %, CR2 14.3 % — while the 21 RAFs stay on the header route +(median 4.5 %). The remaining 38 read whole: NEF, NRW, ORF, PEF and Samsung SRW because the planner +does not describe them (the first four say so; an SRW plans to the whole file, and the 60 % budget +then keeps the whole-file read), and the ARWs, CR2s, CR3s and DNGs that declare Adobe RGB or whose +plan wants more than that budget. Across the whole corpus the plan handed on **the same preview as +the whole file, every time** — and wrote it down: `cargo test -p lightcraft-raw --release --test +sparse_corpus -- --nocapture` prints these numbers. `LIGHTCRAFT_PROFILE=1` says which route answered: + +``` +[profile] preview …/arw-sony-a7rm4a-compressed.arw: read 688128 of 61832192 bytes (plan, window 483441) +``` + +A Canon CR3's preview is a JPEG track instead (issue #832 measured 3 MB of one R5 Mark II file, +~15 %), and Canon is the largest bucket in [gaps.md](gaps.md)'s ranking (33 % of photographers), so +before this change every unedited CR3 thumbnail paid for the whole file. + ## The grid thumbnail is the embedded JPEG too A 512 px grid thumbnail of an unedited raw now keeps the camera's own JPEG instead of rendering the @@ -66,7 +114,14 @@ render queue stayed empty. clamped prefix, a padded extent, a late pointer, a lying pointer and a non-JPEG window are all covered. - `crates/engine/src/files.rs`: on a synthetic RAF with a 200 KB tail, the quick path and the - whole-file path produce identical pixels, and the read stays under half the file. + whole-file path produce identical pixels, and the read stays under half the file. On a synthetic + TIFF/DNG whose preview only its tags locate — the plan path — the same identity holds and the CFA + strip is not read; the same structure without the tag that makes it a raw declines instead of + guessing, and the caller keeps reading it whole. So do a file whose own orientation or Adobe RGB + colour space only the whole-file path can show, while one that states sRGB stays on the fast path. +- `crates/raw/tests/sparse_corpus.rs`: `preview_plan` is asked for **every** corpus file, not only + the ones whose header plan is `Ready`, and the preview it yields must equal the whole file's with + the skipped bytes `0` and `0xFF`. The test prints what each format reads. - `crates/engine/tests/preview_read.rs` (opt-in, `#[ignore]`): the same measurement on a **real** file, typically on a share: diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..7d224dc6e 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -521,7 +521,7 @@ Hasselblad H5D-50c 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Hasselblad Lunar 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad Lunar 3FR 2012 verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad X1D 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only @@ -622,7 +622,7 @@ Leica M11-P DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, Leica Q (Typ 116) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q2 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q2 Monochrom DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica Q3 DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q3 DNG 2022 verified recorded check 2026-10-11: 38 private Q3 DNGs (landscape and portrait) decoded from sensor data and developed at full size without error, mean CIE76 dE to the camera JPEG 6.3-13.9 at 400 px (not committed; docs/raw-parity.md) Leica Q3 43 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q3 Monochrom DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S2 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..4ce812d8c 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -31,6 +31,7 @@ Fujifilm X-T4** verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T50 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Google Pixel 2 XL verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Hasselblad Lunar verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Leica C (Typ 112) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux (Typ 109) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux 3 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body @@ -40,6 +41,7 @@ Leica D-Lux 6 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leic Leica D-Lux 7 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica Digilux 2 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body Leica Digilux 3 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica Q3 verified recorded check 2026-10-11: 38 private Q3 DNGs (landscape and portrait) decoded from sensor data and developed at full size without error, mean CIE76 dE to the camera JPEG 6.3-13.9 at 400 px (not committed; docs/raw-parity.md) Leica V-LUX (Typ 114) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..ba62abb40 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -48,7 +48,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | | ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | -| UI/UX fidelity | ~85% | ~70% | 40–70 h | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | | Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44aa1c703..2236f02a7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -32,11 +32,11 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | | Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item | | Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases | -| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit: sliders, buttons, dropdowns, section headers announce | 30% | canvas tools pointer-only; not audited with a screen reader | +| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab | 55% | crop handles, spot and mask placement on the canvas still need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | | Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test | | **UI/UX overall** | | | **~70%** | estimated, weighted by use (develop and library 60%, tools 25%, rest 15%) | -Remaining: **40–70 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility audit 10–20 +Remaining: **34–58 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 4–8 h, Classic keymap layer 4–8 h, context-menu audit 4–8 h, text-field and IME polish 4–8 h). Output modules and AI tools are counted with their features in [target-app-parity.md](target-app-parity.md). @@ -74,9 +74,37 @@ under the UI command that wraps it (a no-op error); the UI command now wins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). +## Accessibility audit + +`crates/ui-egui/tests/accessibility.rs` walks the AccessKit tree egui hands to VoiceOver, Narrator and +AT-SPI, headless, in Photo Grid, Square Grid, Detail, Compare, Survey and Reference, in each right +panel (Edit, Profiles, Crop, Remove, Masking, Red Eye, Versions, Activity, Keywords, Info) and in 13 +dialogs (Export, Copy / Paste Settings, Create Preset, Rename, New Album, New Smart Album, Capture +Time, Label Names, All Metadata, Save Metadata Preset, Auto Stack, Contact Sheet). It fails on a +button, check box, slider, text field, dropdown, tab, link or menu item without a name, on a slider +without its value and range, and on a dialog window without a title. + +Found and fixed (2026-10-12): 47 problems in the views and tools, 11 more in the dialogs. +- Every text field announced only its contents: the top search box, the Info panel's metadata fields + and GPS, the keyword fields, Crop's custom aspect, and plain egui fields in the dialogs (export file + name, folder, subfolder, watermark, preset names, label names, rename template, metadata filter). + `TextField` now names itself by its caption (`.label`) or hint; plain egui fields are named with + `widgets::named`. +- Develop sliders announced a value but no range: they now carry min, max, step, the value as shown + ("+0.50"), and answer a screen reader's Increment / Decrement (VoiceOver Ctrl-Option-Up / Down) by + one step, as the arrow keys do. A slider reached with Tab takes the arrow keys as a clicked one does. +- Auto Stack's time slider had no name. + +Keyboard reach: Crop (angle, offsets, scale, aspect) and Remove (size, feather, opacity) settings +are reached with Tab and adjusted with the arrow keys (tested). Still pointer-only: dragging crop +handles, placing a spot or a brush stroke, and moving a mask pin on the canvas. Not done: a session +with a real screen reader on each platform, and focus order (egui follows drawing order: top bar, +left panel, canvas, right panel, which is sensible but not tuned). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | diff --git a/docs/whats-new.md b/docs/whats-new.md index 57c61e7ba..e9c5bf329 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,9 +7,106 @@ contains it: `Canon EOS R6` stays `Canon EOS R6`, rather than `Canon Canon EOS R6` (the camera identity subissue in #840). The separate Make and Model tags keep their original values. This does not change colour matrices, camera calibration or RAW noise rendering. +### Slider drags on large raws +- Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you + let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's + previews for up to a second. +- A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file + again. +## 2026-10-12 + +### Canon CR2 highlights +- Blown highlights in CR2 files from the Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), + 1200D (Rebel T5) and 100D (Rebel SL1) now count as clipped (issue #1272). They used to stop at 81–98% of white, so + highlight recovery missed them and, darkened, they took a colour cast instead of going neutral grey. +## 2026-10-12 + +### Pentax raws without black borders +- Raws from the Pentax KP, KF, K-3 Mark III (and Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 and 645Z no longer + show a black (or, on the 645Z, white) band along one or more edges (issue #1271). The sensor's masked border is + left out, so these photos come out a little smaller, closer to the camera's own JPEG. Crops and masks on photos + edited earlier may shift by a few pixels. +## 2026-10-12 + +### Camera-look deep shadows + +- The starting look fitted from a raw's camera JPEG now uses its darkest tones too (issue #1307). + Photos with deep shadows no longer have to extend the first measured tone in a straight line to black. + Existing smart previews are refreshed when their originals are online, and cached renders are redrawn. +### Slider drags follow the pointer more closely +- With a GPU, a slider drag's preview is drawn in the same frame as the slider moves instead of one frame later, + when the preview renders in a few milliseconds (it waits at most 40 % of a frame for it; slower renders, such as + on the CPU, are not waited for). +## 2026-10-12 + +### Kelvin white balance for more Sony cameras +- Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 and ILCE-7S raws now show white balance in Kelvin too, + read from the white-balance presets the camera stores in every ARW: a photo shot at 3400 K on the camera reads + 3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative + scale keep their look, and As Shot shows the real temperature once the file is opened. +## 2026-10-12 + +### Bold text in other scripts +- Arabic names and other Arabic text in headings, chosen tabs and other bold places are now drawn bold, as Latin text + beside them is; they used to stay at regular weight. +## 2026-10-12 + +### Older raw cameras +- Canon PowerShot G9 raws open with the camera's white balance (White Balance ▸ As Shot); it used to start from a + neutral default. +## 2026-10-12 + +### Older raw cameras +- Olympus E-10 raws open with the camera's white balance (White Balance ▸ As Shot) and without the grey haze in + the shadows: the black level recorded in the file is now subtracted. +### Told when there is no GPU +- When GPU rendering is on but no graphics processor can be found, LightCraft now says so once at start-up instead + of quietly rendering everything on the CPU, much more slowly. Help ▸ System Info says why. On NixOS this happens + when a `cargo` build runs outside `nix develop` (no Vulkan loader); start it from there. +## 2026-10-12 + +### Leaf and Hasselblad raws +- The Hasselblad Lunar's raws (a Sony NEX-7 inside) now develop from their sensor data with every editing tool, + instead of opening as their embedded preview. Photos imported earlier switch over with Photo ▸ Reload from Disk. +- An uncompressed Leaf Aptus 22 MOS no longer opens as a dark, colourless picture of its sensor mosaic: it imports + as a raw and shows its embedded preview (preview only, with the reason), as the compressed MOS files do. +## 2026-10-12 + +### Zoomed 64 MP raws stay decoded +- Panning a photo zoomed to 1:1, or moving a slider while zoomed, no longer decodes the whole raw again each time on + high-resolution cameras such as the Sony A7R VI (64 MP). Their decoded original (~800 MB) is bigger than the + default cache share (768 MB), so it was evicted as soon as it was stored. The original of the photo shown in Detail + now stays, like its preview, until another photo or view is shown. +- Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy + beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling) + started yet another decode. +- A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no + longer frees it, which cost a re-upload of about a second on the next pan (800 MB for a 64 MP raw). It is freed + once you zoom out or move on. +- The kept original no longer pushes everything else out of the cache: on a 64 MP raw it filled the cache share + alone, so the photo's preview and thumbnail sources and its neighbours' were decoded from the raw again on every + edit at 1:1, every zoom out and every step to the next photo. ## 2026-10-11 +### HDR and panorama merges of CR2 keep their colour +- Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and + ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come + out green and dark. +### "Rendering…" on the photo +- The loupe says "Rendering…" at the bottom of the photo when what it shows isn't the finished photo yet: the + camera's embedded JPEG while a raw decodes, the previous render while an edit renders, or the magnified frame + while the 1:1 detail is on its way. It appears once that has lasted half a second, so quick renders never show it. +- If LightCraft can't open a photo (its original is on a drive that isn't connected, or the file is damaged) but has + its embedded JPEG or a thumbnail to show, the loupe now says so at the bottom of the photo ("Original offline · no + smart preview" or "This photo can't be opened", the reason on hover) instead of showing that preview as if it were + the photo. +### `--import ` takes HEIC +- `lightcraft-cli run --import ` (and the MCP tools that take folders) now picks up HEIC, HEIF and Nikon NRW + files, the same files as `library.import` (issue #1080). They used to be skipped without a word. +- Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a + magnified preview isn't taken for the photo when checking focus at 1:1. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a @@ -49,10 +146,32 @@ the other editor (Edit in Other Editor, now ⌥⌘E), and where PhotoCraft is if you use a portable copy. - Export can write PSD too (flat, 8- or 16-bit). +### Faster GPU exports +- Exports with noise reduction, Texture, Clarity, Dehaze or Highlights and Shadows can be much faster on the GPU, + depending on the graphics card. On a Windows laptop with an AMD Radeon 780M, a full-size export of a 24 MP raw + with those edits went from about 3.7 s to 1.9 s. Photos look the same as before. + ### Reliability - Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade installs to Program Files. +- A photo that runs into a bug in LightCraft while it renders (in a raw decoder or the editing pipeline) now fails + like a photo that can't be opened, with the reason, instead of rendering forever; its thumbnail stops waiting too. + Each such bug used to take one of the render threads with it, so after a few of them no photo rendered until + LightCraft was restarted. The bug is still written to the log (Help ▸ Open Log Folder) for a report. + +### Library sorting +- Photos that tie on the sort key (equal ratings, one import batch, a burst shot within the same second) now sort by + file name, then id, instead of import order. Recently Added keeps a batch in file name order, and Auto-Stack orders a + burst by file name, so the first file is the cover. + +### Large preset collections +- A library holding thousands of presets (a Lightroom collection imported whole: 11 000 presets, a 42 MB + presets.json) stays responsive. Every command used to re-serialize the whole list to see whether it had changed + (~0.6 s per slider tick); now the list is saved only after it changes, and a slider tick takes ~2 ms. +- The Presets panel builds only the rows in view (and their thumbnails) instead of every preset on every frame; the + preset pickers in Import and Settings ▸ Import scroll the same way. +- Importing a large collection and opening its library no longer compare every preset with every other. ## 2026-10-10 diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d1199..ce235e2d3 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -10,7 +10,7 @@ other tools), and let LightCraft pick up edits made elsewhere. |---|---| | Name | `.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. With stem naming, files sharing a stem don't share a sidecar: see *Shared names* below. | | Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. An existing sidecar is **merged into, never replaced** (see *Saving into an existing sidecar*). Written atomically (temp file, fsync, rename). The result lists `written`, `merged` and `backups`. | -| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. | +| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). | | Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. | What we write (standard namespaces, so other tools can read the metadata): @@ -114,7 +114,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 | | `HDREditMode` | `hdr.enabled` | `1` / `True` = HDR editing on, `0` / `False` off | | `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom | -| `Temperature`, `Tint` | `wb.temp`, `wb.tint` (+ `wb.scale: kelvin`) | Kelvin / tint as written for raws with colour matrices — in the file (DNG) or measured for the camera (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…, issue #730) — and for presets; the mapped settings say `"scale": "kelvin"` so the value stays Kelvin wherever it lands. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW of other cameras: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510). Note that LightCraft's Kelvin for a camera comes from its measured matrices, Lightroom's from Adobe's: the two scales differ (about 7–10 % on the ILCE-7M3), so the number is carried over, not the exact look | +| `Temperature`, `Tint` | `wb.temp`, `wb.tint` (+ `wb.scale: kelvin`) | Kelvin / tint as written for raws with colour matrices — in the file (DNG) or measured for the camera (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…, issue #730) or read from its white-balance presets (`lightcraft_raw::kelvin_presets`: ILCA-99M2, ILCA-77M2…) — and for presets; the mapped settings say `"scale": "kelvin"` so the value stays Kelvin wherever it lands. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW of other cameras: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510). Note that LightCraft's Kelvin for a camera comes from its measured matrices, Lightroom's from Adobe's: the two scales differ (about 7–10 % on the ILCE-7M3), so the number is carried over, not the exact look | | `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider); a rendered file with only `Temperature` shifts from `AsShotTemperature` like the raws above when it is present | | `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 | | `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 | diff --git a/nix/package.nix b/nix/package.nix index 9e769602e..644d49b6a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,8 +4,8 @@ # nix build # → ./result/bin/lightcraft, ./result/bin/lightcraft-cli # # Kept in step with the release workflow (.github/workflows/release.yml) and packaging/env.sh: -# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump — only -# crates.io, no git sources; +# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump. The git +# source (storytold/craft-libs) is fetched at the revision the lock file pins; # * `CRAFT_FONTS_DIR` embeds the CJK fonts from storytold/craft-fonts (the `craft-fonts` # flake input); without it everything builds and runs, but Japanese and Chinese text have no glyphs; # * the desktop file, hicolor icons and AppStream metadata are the same files the .deb/.rpm ship @@ -32,6 +32,9 @@ libxi, libxrandr, vulkan-loader, + libglvnd, + # rfd opens libdbus-1 at run time to reach the xdg-desktop-portal file chooser (Open/Save dialogs). + dbus, # Validates the desktop file and the AppStream metadata we install (as CI does). desktop-file-utils, appstream, @@ -56,8 +59,9 @@ let appId = "ai.storyteller.lightcraft"; # Libraries the binaries open at run time with dlopen(): nothing links them, so no RPATH points at - # them — winit loads libxkbcommon/libxcb, wgpu the Vulkan loader (NixOS patches that loader to - # find the drivers in /run/opengl-driver). The wrapper below points LD_LIBRARY_PATH here. + # them — winit loads libwayland-client/libxkbcommon/libxcb, rfd libdbus-1, wgpu the Vulkan loader + # or libEGL (NixOS patches that loader to find the drivers in /run/opengl-driver). The wrapper + # below points LD_LIBRARY_PATH here. runtimeLibs = lib.optionals isLinux [ libxkbcommon wayland @@ -67,6 +71,8 @@ let libxi libxrandr vulkan-loader + libglvnd + dbus ]; # The two native binaries. The wasm app (apps/lightcraft-web) is built by `cargo xtask web`, not @@ -98,7 +104,11 @@ rustPlatform.buildRustPackage { ]); }; - cargoLock.lockFile = root + "/Cargo.lock"; + cargoLock = { + lockFile = root + "/Cargo.lock"; + # The git dependency (storytold/craft-libs) is pinned by revision in Cargo.lock. + allowBuiltinFetchGit = true; + }; # Both binaries, as the .deb/.rpm install them (`packaging/linux/package.sh`). cargoBuildFlags = [