diff --git a/Cargo.lock b/Cargo.lock index 5f2609a5b..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", ] @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.6.0" +version = "0.7.1" dependencies = [ "eframe", "egui", @@ -3097,7 +3097,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.6.0" +version = "0.7.1" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3114,7 +3114,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.6.0" +version = "0.7.1" dependencies = [ "dhat", "lightcraft-codecs", @@ -3130,7 +3130,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.6.0" +version = "0.7.1" dependencies = [ "avif-serialize", "bytemuck", @@ -3159,7 +3159,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.6.0" +version = "0.7.1" dependencies = [ "lightcraft-geom", "proptest", @@ -3168,7 +3168,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.6.0" +version = "0.7.1" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3185,7 +3185,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.6.0" +version = "0.7.1" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3200,7 +3200,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.6.0" +version = "0.7.1" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3212,7 +3212,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.6.0" +version = "0.7.1" dependencies = [ "ab_glyph", "harfrust", @@ -3252,7 +3252,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.6.0" +version = "0.7.1" dependencies = [ "matrixmultiply", "ndarray", @@ -3266,7 +3266,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.6.0" +version = "0.7.1" dependencies = [ "log", "rustls", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.6.0" +version = "0.7.1" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3285,7 +3285,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.6.0" +version = "0.7.1" dependencies = [ "proptest", "serde", @@ -3293,7 +3293,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.6.0" +version = "0.7.1" dependencies = [ "bytemuck", "lightcraft-color", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.6.0" +version = "0.7.1" dependencies = [ "heic-rs", "rayon", @@ -3319,14 +3319,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.6.0" +version = "0.7.1" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.6.0" +version = "0.7.1" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3335,7 +3335,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.6.0" +version = "0.7.1" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.6.0" +version = "0.7.1" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3362,7 +3362,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.6.0" +version = "0.7.1" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3374,7 +3374,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.6.0" +version = "0.7.1" dependencies = [ "image", "lightcraft-color", @@ -3392,7 +3392,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.6.0" +version = "0.7.1" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3404,7 +3404,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.6.0" +version = "0.7.1" dependencies = [ "libc", "lightcraft-color", @@ -3416,7 +3416,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.6.0" +version = "0.7.1" dependencies = [ "jpeg-encoder", "jxl-oxide", @@ -3438,7 +3438,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.6.0" +version = "0.7.1" dependencies = [ "image", "lightcraft-color", @@ -3449,7 +3449,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.6.0" +version = "0.7.1" dependencies = [ "candle-core", "candle-nn", @@ -3462,11 +3462,11 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.6.0" +version = "0.7.1" [[package]] name = "lightcraft-tiff" -version = "0.6.0" +version = "0.7.1" dependencies = [ "proptest", "serde", @@ -3475,7 +3475,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.6.0" +version = "0.7.1" dependencies = [ "craft-ui", "egui", @@ -3502,7 +3502,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.6.0" +version = "0.7.1" dependencies = [ "crc32fast", "eframe", @@ -6972,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.6.0" +version = "0.7.1" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 7163c757c..7effbe0d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.7.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" 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 a444ec004..b95f72bcd 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-12 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **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 | ~55% 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,7 +163,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | +| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | +| 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 cc71cc06d..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; @@ -64,7 +67,7 @@ impl eframe::App for App { // Quit from the menu bar: save exactly what closing the window saves (settings and // library, after the unsaved-changes check; otherwise its prompt shows), then end the // process instead of closing the viewport while AppKit is terminating (PR #444). - eframe::App::on_exit(self); + eframe::App::on_exit(self, None); std::process::exit(0); } self.0.logic(ctx); @@ -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 f8b3fbbdf..3bb775df4 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,7 +247,8 @@ 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.as_chunks::<3>().0) { + 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])); } @@ -362,3 +364,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 44527cf9c..9876e349e 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -378,6 +378,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); @@ -989,3 +1026,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 ac404ca28..24ba5af7e 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 31b2be4b1..d74d83e4d 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": "Zurück zu dieser Person", "AI Denoise": "KI-Entrauschen", "AI Denoise Model": "KI-Entrauschungsmodell", @@ -2150,5 +2156,8 @@ "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Diese Version hat keinen Speicherort für Gesichtsmodelle, daher ist die Modellliste nicht verfügbar. (Die Desktop-App schon.)", "Unnamed Faces": "Unbenannte Gesichter", "smart album value|No": "Nein", - "smart album value|Yes": "Ja" + "smart album value|Yes": "Ja", + "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 2a81be427..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,13 +197,13 @@ "{} 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}.": "Se descarga de {host}. Una vez recibido y comprobado, LightCraft lo instala y {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: {}.": "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 ninguna foto usa.", - "Denoising {name}: {done} of {total} tiles{}": "Reduciendo ruido de {name}: {done} de {total} mosaicos{}", + "{} · {}. 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}": "{} de {} de {host}", + "{} of {} from {host}": "{} de {} desde {host}", "{} KB": "{} KB", "{} MB": "{} MB", "Making the denoised picture… {}%": "Generando la imagen sin ruido… {}%", @@ -211,7 +211,7 @@ "{n} {noun}": "{n} {noun}", "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, luego descarga {} de {host}, lo instala y empieza a usarlo. No se envía nada más.", + "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}", @@ -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 26e93c9c9..7117da7aa 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -177,7 +177,12 @@ "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", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Fotos CR2 de Canon de las EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II y 1D Mark IV (y otros", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Los archivos CR3 de Canon muestran su JPEG incrustado a tamaño completo (p. ej., 6960 × 4640 en una EOS R6 Mark III) en lugar de la", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Los archivos C-RAW de la Canon EOS R5 Mark II que se abrían como «solo vista previa» (más de la mitad, según la", "Cancel Preview Build": "Cancelar generación de vistas previas", "Candidate": "Candidata", "Caption": "Pie de foto", @@ -197,7 +202,15 @@ "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.", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Mayús+R, conserva la foto y borra sus ediciones).", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtro de etiquetas de color con varias etiquetas a la vez; árbol de carpetas desplegable en Local.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Máscaras de rango de color: hacer clic en la foto para tomar una muestra. Rangos de luminancia: barra de rango, suavizado, mapa de luminancia.", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "NEF de Nikon comprimidos (con y sin pérdida, de 12 y 14 bits; p. ej., D3200, D5100, D7000, D750, D850, Z 50)", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Convertir a DNG, Copiar como DNG, Combinar fotos y las vistas previas inteligentes ya no escriben directamente en el archivo final (issue", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Estado del copyright, condiciones de uso de los derechos y URL de información de copyright en Información, ajustes preestablecidos de metadatos y exportaciones.", "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 +401,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 +416,9 @@ "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", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "ajuste de borde, y reciben en segundo plano una pasada más nítida al ampliar. El modelo se ejecuta dentro de LightCraft en Rust puro", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Editar en editor externo (⇧⌘E): una copia TIFF de 16 bits, apilada, que se actualiza al volver.", "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 +460,11 @@ "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", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Los nombres de los archivos exportados usan las etiquetas de Cambiar nombre de las fotos ({title}, {seq:2}, {date:%Y-%m-%d}…), más las nuevas {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Los archivos exportados se escriben en un archivo temporal y luego se renombran en su lugar, así que un disco lleno o una unidad desconectada", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Las exportaciones y los procesados nunca sobrescriben el original de una foto (issue #93): exportar a la propia carpeta de la foto con", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Las exportaciones nunca salen negras por culpa de la GPU (issue #78): un procesado en GPU que se queda sin", "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 +493,16 @@ "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", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Exportaciones e importaciones desde tarjeta más rápidas, con la misma protección para lo que no se puede volver a crear (issue #134): las exportaciones,", "Faded Matte": "Mate desvaído", "Faded Slide": "Diapositiva desvaída", "Faded Urban": "Urbano desvaído", @@ -562,11 +593,17 @@ "Global Luminance": "Luminancia global", "Global Saturation": "Saturación global", "Glossy": "Brillante", + "Go back to listing them by name": "Volver a la lista por nombre", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Si la biblioteca no se puede abrir al iniciar (abierta en otro programa, ilegible, en una unidad no conectada,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importar ajustes preestablecidos de otros editores: ajustes XMP, archivos clásicos `.lrtemplate`, «ajustes DNG» de aplicaciones móviles y paquetes `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Importar → Copiar verifica cada copia, como Mover (issue #96): cada archivo se escribe como un archivo nuevo, se sincroniza con el disco y", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Importar → Mover: las fotos van al destino (con las mismas carpetas y cambios de nombre que Copiar, p. ej.,", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Importación: copiar a cualquier carpeta, por día / por mes / en una carpeta, cambiar el nombre al importar, ajuste preestablecido de metadatos, Copiar como DNG.", "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 +761,11 @@ "Lens Corrections": "Correcciones de lente", "Level": "Nivel", "Library": "Biblioteca", + "Library Folders": "Carpetas de la biblioteca", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 y anteriores la rechazan («unsupported format … v2»).", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft ya no se bloquea al iniciar en PC con Windows cuyo controlador Vulkan está dañado (issue #136, p. ej., algunas Intel UHD", + "Lossy DNG files and Smart Previews open as raw photos.": "Los archivos DNG con pérdida y las vistas previas inteligentes se abren como fotos raw.", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Looks de Luminar: los archivos `.lmp` y las colecciones `.mplumpack` se importan como ajustes preestablecidos (agrupados por colección); los reguladores", "Library Info": "Información de la biblioteca", "Library Originals": "Originales de la biblioteca", "Library Preferences": "Preferencias de la biblioteca", @@ -764,6 +806,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 +903,15 @@ "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", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Los NEF de Nikon parten de un aspecto de color y tono ajustado al JPEG de la propia cámara, como los ARW de Sony, en lugar de un procesado neutro apagado", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon divide en dos mitades comprimidas de forma distinta siguen usando la vista previa por ahora).", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Los raw de Panasonic y Leica (RW2, RWL y los archivos RAW antiguos, de la DMC-LX1 a la DC-S1R II) se revelan a partir de los datos raw en", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menú Foto. La tira de película también tiene el menú contextual de la foto. Volver a añadir un archivo que está en Eliminadas recientemente ya no", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Las fotos de Eliminadas recientemente se pueden restaurar desde la aplicación: clic derecho → Restaurar (o Eliminar definitivamente), también en el", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "fotos que faltan); la importación (también arrastrando y soltando), Buscar fotos que faltan, Generar / Descartar vistas previas inteligentes, la vista previa de Cambiar nombre,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Colección rápida y álbum de destino (B en la cuadrícula), conjuntos de palabras clave (⌥1–⌥9), conjuntos de etiquetas de color.", "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 +1154,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 +1190,14 @@ "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", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Cambiar nombre de carpeta… y Mover carpeta a… (Local) se pueden deshacer y rehacer (issue #97): la carpeta vuelve a su sitio en el disco con", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Cambiar nombre de las fotos nunca sobrescribe otra foto cuando solo cambian las mayúsculas y minúsculas (issue #95): en", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Cambiar nombre de las fotos indica los archivos que no pudo devolver a su sitio tras un fallo (issue #105), p. ej., cuando una carpeta de red se desconecta", "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 +1232,9 @@ "Rename…": "Cambiar nombre…", "Rendering": "Renderizado", "Rendering…": "Renderizando…", + "Reorder Album": "Reordenar álbum", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Guardar metadatos en un archivo XMP complementario escrito por otra aplicación ya no lo sustituye (issue #92): LightCraft combina sus", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Los archivos de ajustes nunca se restablecen en silencio (issue #103): un `prefs.json`, `presets.json` o `view.json` dañado se conserva", "Reorder Masks": "Reordenar máscaras", "Reset": "Restablecer", "Reset All": "Restablecer todo", @@ -1327,8 +1388,17 @@ "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", + "Slideshow, second window, All Metadata, System Info.": "Presentación, segunda ventana, Todos los metadatos, Información del sistema.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Catálogos más pequeños y rápidos: las fotos que solo se vieron en Local (nunca añadidas, valoradas ni editadas) se olvidan cuando", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Álbumes inteligentes con editor de reglas: coincidir con todas / alguna / ninguna, grupos anidados, 26 campos.", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Los ARW de Sony de antes de 2017 aproximadamente (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II y sus", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Los ARW sin pérdida reducidos de la Sony ILCE-7M4 ahora descodifican los mosaicos YCbCr submuestreados a RGB lineal,", + "Sort Albums A–Z": "Ordenar álbumes A–Z", "Show Photo Counts": "Mostrar recuento de fotos", "Show Photos with Keyword": "Mostrar fotos con la palabra clave", "Show Pins": "Mostrar chinchetas", @@ -1373,6 +1443,8 @@ "Soft Proofing": "Prueba en pantalla", "Soft Skin": "Piel suave", "Sort": "Ordenar", + "Sort Albums by Name": "Ordenar álbumes por nombre", + "Sort Contents A–Z": "Ordenar contenido A–Z", "Sort by": "Ordenar por", "Source": "Origen", "Source code on GitHub": "Código fuente en GitHub", @@ -1406,7 +1478,10 @@ "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.", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "series TZ/ZS, las Leica D-Lux, V-Lux y C-Lux, y la generación GH6, GH7, G9 II, S5 II y S9. Parten de un", "Summer Haze": "Bruma estival", "Sunday": "Domingo", "Survey": "Encuesta", @@ -1488,6 +1563,102 @@ "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", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Importación automática desde carpeta vigilada; Convertir a DNG; Duplicar; Generar vistas previas estándar / 1:1 / inteligentes.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "sin guardar y Salir (issue #100). Ya no inicia en silencio una sesión de demostración que parecía una biblioteca restablecida y", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) junto con sus archivos XMP complementarios; cada original sale de la tarjeta solo cuando su", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; en volúmenes que no distinguen mayúsculas, el cambio de mayúsculas se aplica igualmente.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "una unidad llena) ya no cuenta como generada y Generar vistas previas inteligentes la sustituye.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "después (issue #101): la escritura parcial se recorta antes de que LightCraft lo reintente, así que el siguiente inicio reproduce cada", + "again. Change the period (or turn it off) in Settings → Performance.": "de nuevo. Cambiar el periodo (o desactivarlo) en Ajustes → Rendimiento.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "con los originales de la biblioteca ya no recorre toda la biblioteca cuando no hay nada en esa ruta.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ya tiene una carpeta `thumbs/` con otras imágenes ya no las pierde al recortar o vaciar la caché.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "y comprueba el archivo antes de usarlo. Las máscaras conservan su selección, así que se procesan y exportan sin el modelo.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "y todas las fotos importadas en un zip, y Archivo → Restaurar biblioteca desde copia de seguridad… la recupera (la biblioteca actual", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "y nunca bloquea la ventana. Es opcional y no forma parte de LightCraft (licencia SAM de Meta, unos 3,4 GB): la", + "as `.corrupt-