diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..d2fef73a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + timeout-minutes: 90 + # xtask ci runs cargo test: the demo scene cache is per process, so nextest is slower here. + tools: "" + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev libdbus-1-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index d85332b23..19470cf69 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,23 +1,18 @@ # FreeBSD build and package check in a QEMU VM. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: - branches: [main] - paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/freebsd.yml + - packaging/freebsd/** + schedule: + - cron: "7 4 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 01cb9cd66..2fe7cd3ac 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,6 +18,44 @@ concurrency: cancel-in-progress: true jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + nix: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8fb01326..7356f66fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,47 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d..e78fe1ea3 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 0033346dc..332193998 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -5,23 +5,17 @@ # on GitHub Actions' native windows-11-arm runner. name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "3 5 * * *" push: - branches: [main] - paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/windows-arm64.yml + - packaging/windows/** workflow_dispatch: concurrency: diff --git a/Cargo.toml b/Cargo.toml index ef204030f..22956f31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,9 +125,11 @@ excessive_precision = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] debug = "line-tables-only" diff --git a/README.md b/README.md index c8db5a50a..dd7c538f7 100644 --- a/README.md +++ b/README.md @@ -410,10 +410,12 @@ cargo xtask ci # fmt, clippy, HEIF, tes CI defaults to line-table debug information and one build job per 1.5 GB of RAM available when it starts (at most one per CPU; 4 if memory can't be read), so an 8 GB machine with 6 GB free builds with 4, and a busy machine with little free -builds with fewer. Test threads follow, capped at 4. Full-debuginfo linkers, one -per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. Explicit -`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings -override these defaults. Ordinary development commands keep their own settings. +builds with fewer. Test threads follow, capped at 4. +Full-debuginfo linkers, one per CPU, otherwise exhaust RAM before any test runs. GPU coverage is +unchanged. Explicit `CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS`, `RUST_TEST_THREADS` and +`NEXTEST_TEST_THREADS` settings override these defaults. The tests run under +`cargo test`; `LIGHTCRAFT_NEXTEST=1` runs them under [cargo-nextest](https://nexte.st) instead (one +process per test, slower here because each regenerates the demo scenes). Ordinary development commands keep their own settings. **Release builds on memory-constrained machines:** [#1196](https://github.com/storytold/lightcraft/issues/1196) reports Arch/AUR release-link OOM kills on a 16 GB machine when the desktop and CLI build concurrently diff --git a/README.zh-hans.md b/README.zh-hans.md index 824913f14..bc2f2830e 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -357,7 +357,7 @@ CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一 (每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建, 而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的 完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置 -`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。 +`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS`、`RUST_TEST_THREADS` 和 `NEXTEST_TEST_THREADS` 会覆盖这些默认值。 日常开发命令保持它们各自的设置。 **内存受限机器上的 release 构建:** [#1196](https://github.com/storytold/lightcraft/issues/1196) diff --git a/README.zh-hant.md b/README.zh-hant.md index a17505f3f..12fcfb11b 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -375,7 +375,7 @@ CI 預設使用行表除錯資訊,並依啟動時可用記憶體每 1.5 GB 開 (每個 CPU 最多一個;讀不到記憶體時為 4 個),所以一台 8 GB、剩餘 6 GB 可用的機器會用 4 個任務建置, 而一台忙碌、剩餘記憶體很少的機器會用更少。測試執行緒數隨之調整,上限為 4。若不限制,每個 CPU 各跑 一個帶完整除錯資訊的連結器,測試還沒開始記憶體就會耗盡。GPU 測試的涵蓋範圍不受影響。明確設定 -`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 與 `RUST_TEST_THREADS` 會覆寫這些預設值。 +`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS`、`RUST_TEST_THREADS` 與 `NEXTEST_TEST_THREADS` 會覆寫這些預設值。 日常開發指令保留各自的設定。 **記憶體受限機器上的 release 建置:** [#1196](https://github.com/storytold/lightcraft/issues/1196) diff --git a/ROADMAP.md b/ROADMAP.md index ec392e88d..6d8720b6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -121,7 +121,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on CC0 files. 551 models verified, 201 preview-only; headline numbers unchanged. - 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and - checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of + checked against the camera JPEG (`crates/raw/tests/it/canon_pentax_corpus.rs`). 334 models verified, ~50% of photographers on a verified body; headline numbers unchanged. - 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models verified, 35 unsupported; headline numbers unchanged. @@ -135,7 +135,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%. - 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked - against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800 + against the camera JPEG (`crates/raw/tests/it/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800 decoding wrongly. 354 models verified, ~58% of photographers on a verified body; 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 diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index c8371cbb9..a27e97beb 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -935,7 +935,11 @@ fn snapshot(args: &[String]) -> Result<(), String> { fn commands(args: &[String]) -> Result<(), String> { let mut h = Headless::demo(); - let cmds = h.call("engine.commands", json!({}))?; + let mut cmds = h.call("engine.commands", json!({}))?; + // Listed by id so each family (`photo.*`, `mask.*`, …) reads together; the registry keeps its own order. + if let Some(list) = cmds.as_array_mut() { + list.sort_by(|a, b| a["id"].as_str().unwrap_or("").cmp(b["id"].as_str().unwrap_or(""))); + } let mut out = std::io::stdout().lock(); if args.iter().any(|a| a == "--json") { writeln!(out, "{}", serde_json::to_string_pretty(&cmds).unwrap_or_default()).map_err(|e| e.to_string())?; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/it/cli.rs similarity index 95% rename from apps/lightcraft-cli/tests/cli.rs rename to apps/lightcraft-cli/tests/it/cli.rs index 8f56bafe6..1c8910d9d 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/it/cli.rs @@ -286,6 +286,38 @@ fn commands_subcommand_lists_registry() { assert!(String::from_utf8_lossy(&o.stdout).contains("light.exposure")); } +#[test] +fn commands_subcommand_lists_commands_sorted_by_id() { + let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap(); + assert!(o.status.success()); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + let cmds = v.as_array().unwrap(); + for c in cmds { + for field in ["id", "label", "menu", "shortcut", "params", "enabled"] { + assert!(c.get(field).is_some(), "every command should keep its {field}: {c}"); + } + } + let ids: Vec<&str> = cmds.iter().filter_map(|c| c["id"].as_str()).collect(); + assert_eq!(ids.len(), cmds.len(), "every command should have a string id"); + let unsorted = ids.windows(2).find(|w| w[0] >= w[1]); + assert!(unsorted.is_none(), "--json should list commands sorted by id, but {unsorted:?} are out of order"); + + let o = Command::new(BIN).arg("commands").output().unwrap(); + assert!(o.status.success()); + let text = String::from_utf8_lossy(&o.stdout); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2 * ids.len(), "each command should take an id line and a params line"); + let listed: Vec<&str> = lines.iter().step_by(2).filter_map(|l| l.split_whitespace().next()).collect(); + assert_eq!(listed, ids, "the text output should list the same commands in the same order as --json"); + assert!(lines.iter().skip(1).step_by(2).all(|l| l.trim_start().starts_with("params:")), "each id line should be followed by its params line"); + for (line, c) in lines.iter().step_by(2).zip(cmds) { + assert!(line.contains(c["label"].as_str().unwrap()), "{line:?} should show the label of {c}"); + if let Some(sc) = c["shortcut"].as_str() { + assert!(line.contains(&format!("[{sc}]")), "{line:?} should show the shortcut of {c}"); + } + } +} + #[test] fn snapshot_subcommand_renders_the_ui_headlessly() { let script = tmp("snap.jsonl"); diff --git a/apps/lightcraft-cli/tests/it/main.rs b/apps/lightcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..0c07c6438 --- /dev/null +++ b/apps/lightcraft-cli/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod cli; +mod mcp_shutdown; diff --git a/apps/lightcraft-cli/tests/mcp_shutdown.rs b/apps/lightcraft-cli/tests/it/mcp_shutdown.rs similarity index 100% rename from apps/lightcraft-cli/tests/mcp_shutdown.rs rename to apps/lightcraft-cli/tests/it/mcp_shutdown.rs diff --git a/apps/lightcraft/src/control_server.rs b/apps/lightcraft/src/control_server.rs index 777831942..c7244c703 100644 --- a/apps/lightcraft/src/control_server.rs +++ b/apps/lightcraft/src/control_server.rs @@ -240,11 +240,14 @@ mod tests { } impl Connection { - fn new(open: &Arc, idle: Duration) -> Self { + /// `before` acts on the client before the worker starts reading, so what it sends is + /// already there however late this thread gets scheduled. + fn new(open: &Arc, idle: Duration, before: impl FnOnce(&mut TcpStream)) -> Self { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); client.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); let (stream, _) = listener.accept().unwrap(); + before(&mut client); let (tx, requests) = channel(); let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap(); Self { client: BufReader::new(client), requests, worker: Some(worker) } @@ -271,9 +274,8 @@ mod tests { fn idle_connections_release_their_slots_without_dispatching() { for input in ["", "{\"method\":\"engine.execute\""] { let open = Arc::new(AtomicUsize::new(0)); - let mut connection = Connection::new(&open, Duration::from_millis(100)); + let mut connection = Connection::new(&open, Duration::from_millis(100), |c| c.write_all(input.as_bytes()).unwrap()); assert_eq!(open.load(Ordering::SeqCst), 1); - connection.client.get_mut().write_all(input.as_bytes()).unwrap(); let mut reply = String::new(); let closed = connection.client.read_to_string(&mut reply); assert!(closed.is_ok(), "idle connection stayed open: {closed:?}"); @@ -285,8 +287,7 @@ mod tests { assert!(reply["error"].as_str().unwrap().contains("idle read timed out")); // A subsequent connection is admitted through the same accounting path. - let mut next = Connection::new(&open, Duration::from_secs(1)); - next.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap(); + let mut next = Connection::new(&open, Duration::from_secs(1), |c| c.shutdown(std::net::Shutdown::Write).unwrap()); let mut eof = String::new(); next.client.read_to_string(&mut eof).unwrap(); next.join(); @@ -298,12 +299,20 @@ mod tests { #[test] fn persistent_requests_and_slow_replies_outlive_the_idle_read_timeout() { let open = Arc::new(AtomicUsize::new(0)); - let mut connection = Connection::new(&open, Duration::from_millis(100)); + let request = |id: u64| format!("{}\n", json!({"id": id, "method": "engine.commands"})); + // Each request reaches the socket before the worker reads it (the next one while the worker + // waits for the reply), so only the reply waits outlast the idle timeout, however slowly + // this thread runs. + let mut connection = Connection::new(&open, Duration::from_millis(100), |c| c.write_all(request(1).as_bytes()).unwrap()); for id in [1, 2] { - writeln!(connection.client.get_mut(), "{}", json!({"id": id, "method": "engine.commands"})).unwrap(); - let req = connection.requests.recv_timeout(Duration::from_secs(3)).unwrap(); + let req = connection.requests.recv_timeout(Duration::from_secs(10)).unwrap(); assert_eq!(req.method, "engine.commands"); assert!(req.take()); + if id == 1 { + connection.client.get_mut().write_all(request(2).as_bytes()).unwrap(); + } else { + connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap(); + } // A dispatched command waits for its reply, not for socket input. std::thread::sleep(Duration::from_millis(250)); req.reply.send(json!({"ok": true, "result": id})).unwrap(); @@ -312,7 +321,6 @@ mod tests { let reply: Value = serde_json::from_str(&reply).unwrap(); assert_eq!(reply, json!({"id": id, "ok": true, "result": id})); } - connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap(); let mut eof = String::new(); connection.client.read_to_string(&mut eof).unwrap(); connection.join(); @@ -327,7 +335,8 @@ mod tests { 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)); + // the frame window (50 ms), not the 10 s timeout; the margin is for a slow machine + assert!(t0.elapsed() < Duration::from_secs(5), "{:?}", t0.elapsed()); 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"); diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 4ac40bcac..14debc498 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -39,18 +39,138 @@ enum Handle { struct Item { handle: Handle, + /// The submenu the item sits in, to swap the item when its accelerator has to go (macOS). + parent: Submenu, cmd: String, params: Value, shortcut: Option, + /// The accelerator the item's shortcut maps to. accel: Option, + /// The accelerator the native item has now: `accel`, or `None` while it yields. + current: Option, label: String, enabled: bool, checked: Option, + /// Whether the item may be replaced to drop its key. Not in the Help menu: a swap there left + /// the old row and added a second one (muda 0.21, macOS 27), so those keys stay. + swappable: bool, +} + +impl Item { + fn native_label(&self) -> String { + lightcraft_ui_egui::menubar::display_item_label(&self.cmd, &self.params, &self.label).replace('&', "&&") + } + + /// Puts `want` on the native item. muda's `set_accelerator(None)` does nothing on macOS (the + /// old key equivalent stays and the menu still takes the key), so there the item is replaced + /// by an identical one without an accelerator, at the same position and with the same id, + /// text, enabled and checked state. Only done when the accelerator changes (and again while a + /// native call keeps failing), never per frame otherwise. + fn set_accelerator(&mut self, want: Option) { + if self.current == want { + return; + } + if cfg!(target_os = "macos") && want.is_none() && self.swappable { + // `current` follows the native item: it changes only when the swap went through, so a + // failed swap (the old item, key and all, is still there) is tried again + let done = self.replace_without_accelerator(); + self.current = settled(self.current, want, done); + return; + } + let done = match &self.handle { + Handle::Plain(h) => h.set_accelerator(want), + Handle::Check(h) => h.set_accelerator(want), + } + .is_ok(); + self.current = settled(self.current, want, done); + } + + /// Replaces the native item by one without an accelerator. `self.handle` changes only when the + /// new item is attached; on failure the old item is still the one in the submenu. + fn replace_without_accelerator(&mut self) -> bool { + let (id, old): (_, &dyn muda::IsMenuItem) = match &self.handle { + Handle::Plain(h) => (h.id().clone(), h), + Handle::Check(h) => (h.id().clone(), h), + }; + let Some(position) = self.parent.items().iter().position(|k| *k.id() == id) else { return false }; + let new = match &self.handle { + Handle::Plain(_) => Handle::Plain(MenuItem::with_id(id, self.native_label(), self.enabled, None)), + Handle::Check(h) => Handle::Check(CheckMenuItem::with_id(id, self.native_label(), self.enabled, h.is_checked(), None)), + }; + let new_ref: &dyn muda::IsMenuItem = match &new { + Handle::Plain(h) => h, + Handle::Check(h) => h, + }; + if !swap_item(&self.parent, old, new_ref, position) { + return false; + } + self.handle = new; + true + } +} + +/// The accelerator the native item has after trying to put `want` on it: `want` when the native +/// call worked, else what it had, so the caller sees the mismatch and tries again. +fn settled(had: Option, want: Option, native_call_worked: bool) -> Option { + if native_call_worked { want } else { had } +} + +/// Whether the per-frame accelerator sync has to run: the focus or recording state changed, or an +/// earlier sync left an item whose native accelerator is not the wanted one (a failed swap). +fn sync_needed(focus: bool, capturing: bool, synced_focus: bool, synced_capturing: bool, pending: bool) -> bool { + focus != synced_focus || capturing != synced_capturing || pending +} + +/// The three submenu calls a swap needs, so the failure paths can be tested without a main-thread +/// `muda::Menu`. +trait SwapTarget { + type Row: ?Sized; + fn remove(&self, row: &Self::Row) -> bool; + fn insert(&self, row: &Self::Row, position: usize) -> bool; + fn append(&self, row: &Self::Row) -> bool; +} + +impl SwapTarget for Submenu { + type Row = dyn muda::IsMenuItem; + fn remove(&self, row: &Self::Row) -> bool { + Submenu::remove(self, row).is_ok() + } + fn insert(&self, row: &Self::Row, position: usize) -> bool { + Submenu::insert(self, row, position).is_ok() + } + fn append(&self, row: &Self::Row) -> bool { + Submenu::append(self, row).is_ok() + } +} + +/// Puts `new` where `old` is. Returns whether `new` is attached; when it is not, `old` is back in +/// the menu (at its position, else at the end), so the caller keeps `old` as its handle. +fn swap_item(target: &T, old: &T::Row, new: &T::Row, position: usize) -> bool { + if !target.remove(old) { + return false; + } + // (an item at the end beats a missing one) + if target.insert(new, position) || target.append(new) { + return true; + } + let _ = target.insert(old, position) || target.append(old); + false +} + +/// The accelerator a native item has: none while a shortcut is being recorded, and none for the +/// keys a focused text field needs. +fn wanted_accelerator(accel: Option, sc: &str, text_focus: bool, capturing: bool) -> Option { + if capturing || (text_focus && yields_to_text(sc)) { None } else { accel } } pub struct NativeMenu { menu: Menu, items: HashMap, + /// Settings… and Quit in the application submenu: ordinary items, so they give up ⌘, and ⌘Q + /// to the shortcut recorder like the rest. (The system's own items there and in File/Window, + /// such as Hide ⌘H, Close Window ⌘W and Minimize ⌘M, are muda predefined items whose key + /// cannot be changed or swapped.) + app_items: Vec, rx: Receiver, /// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it. structure: String, @@ -58,6 +178,8 @@ pub struct NativeMenu { /// The keyboard shortcuts editor is recording a key: every accelerator is off so the key /// press reaches it. capturing: bool, + /// An item's native accelerator is not the wanted one (its swap or accelerator call failed): sync again next frame. + accelerators_pending: bool, } /// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape). @@ -216,7 +338,16 @@ impl NativeMenu { let _ = tx.send(e.id.0); repaint.request_repaint(); })); - let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false }; + let mut m = NativeMenu { + menu: Menu::new(), + items: HashMap::new(), + app_items: Vec::new(), + rx, + structure: String::new(), + text_focus: false, + capturing: false, + accelerators_pending: false, + }; m.rebuild(app); app.native_menu = true; m @@ -227,6 +358,7 @@ impl NativeMenu { self.structure = structure_of(&bar); self.menu = Menu::new(); self.items.clear(); + self.app_items.clear(); // the application menu let app_menu = Submenu::new("LightCraft", true); @@ -246,6 +378,22 @@ impl NativeMenu { &PredefinedMenuItem::separator(), &quit, ]); + for (handle, cmd, shortcut, text) in [(&settings, SETTINGS, SETTINGS_KEY, "Settings…"), (&quit, QUIT, "Cmd+Q", "Quit LightCraft")] { + let accel = accelerator(shortcut); + self.app_items.push(Item { + handle: Handle::Plain(handle.clone()), + parent: app_menu.clone(), + swappable: true, + cmd: cmd.to_string(), + params: Value::Null, + shortcut: Some(shortcut.to_string()), + accel, + current: accel, + label: text.to_string(), + enabled: true, + checked: None, + }); + } let _ = self.menu.append(&app_menu); for (title, nodes) in &bar { @@ -264,7 +412,9 @@ impl NativeMenu { .cloned() .collect(); let nodes = tidy_separators(nodes); - self.append_nodes(&sub, &nodes); + // Swapping an item in the Help menu leaves the old row in place and adds a second one + // (measured on macOS 27 with muda 0.21): the Help menu is not addressed row for row. + self.append_nodes(&sub, &nodes, title != "Help"); if title == "File" { // ⌘W, the system's own item let _ = sub.append_items(&[ @@ -287,10 +437,12 @@ impl NativeMenu { self.menu.init_for_nsapp(); self.text_focus = false; self.capturing = false; + self.accelerators_pending = false; self.publish_shortcuts(app); } - fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode]) { + /// `swappable`: whether its items may be replaced to drop their key (see `Item::swappable`). + fn append_nodes(&mut self, sub: &Submenu, nodes: &[MenuNode], swappable: bool) { for n in nodes { match n { MenuNode::Separator => { @@ -298,7 +450,7 @@ impl NativeMenu { } MenuNode::Submenu { label, children } => { let s = Submenu::new(label_text(label), true); - self.append_nodes(&s, children); + self.append_nodes(&s, children, swappable); let _ = sub.append(&s); } MenuNode::Item { id, params, label, shortcut, enabled, checked } => { @@ -334,10 +486,13 @@ impl NativeMenu { key, Item { handle, + parent: sub.clone(), + swappable, cmd: id.clone(), params: params.clone(), shortcut: shortcut.clone(), accel, + current: accel, label: label.clone(), enabled: *enabled, checked: *checked, @@ -379,8 +534,9 @@ impl NativeMenu { } let bar = menu_bar(app); if structure_of(&bar) != self.structure { + // (no early return: a rebuild installs every accelerator and resets the state they were synced + // to, so the sync below runs in this same frame and no key reaches the menu in between) self.rebuild(app); - return quit; } fn walk(nodes: &[MenuNode], items: &mut HashMap) { for n in nodes { @@ -418,20 +574,20 @@ impl NativeMenu { let focus = ctx.egui_wants_keyboard_input(); let capturing = app.recording_shortcut.is_some(); - if focus != self.text_focus || capturing != self.capturing { + if sync_needed(focus, capturing, self.text_focus, self.capturing, self.accelerators_pending) { self.text_focus = focus; self.capturing = capturing; - for it in self.items.values() { + let mut pending = false; + for it in self.items.values_mut().chain(self.app_items.iter_mut()) { let Some(sc) = it.shortcut.as_deref() else { continue }; if it.accel.is_none() { continue; } - let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel }; - let _ = match &it.handle { - Handle::Plain(h) => h.set_accelerator(accel), - Handle::Check(h) => h.set_accelerator(accel), - }; + let want = wanted_accelerator(it.accel, sc, focus, capturing); + it.set_accelerator(want); + pending |= it.current != want; } + self.accelerators_pending = pending; self.publish_shortcuts(app); } quit @@ -456,13 +612,100 @@ mod tests { set_language(*language); assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key"); } - set_language(Locale::ZhHant); - assert_eq!(label_text("File"), "檔案"); - assert_eq!(label_text("Settings…"), "設定…"); - assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft"); set_language(Locale::En); } + #[test] + fn accelerators_yield_to_text_focus_and_shortcut_recording() { + let g = accelerator("G"); + let cmd_s = accelerator("Cmd+S"); + assert_eq!(wanted_accelerator(g, "G", false, false), g); + assert_eq!(wanted_accelerator(g, "G", true, false), None, "typing a G in a field"); + assert_eq!(wanted_accelerator(cmd_s, "Cmd+S", true, false), cmd_s, "⌘S still saves while typing"); + assert_eq!(wanted_accelerator(cmd_s, "Cmd+S", false, true), None, "the editor records the key"); + assert_eq!(wanted_accelerator(cmd_s, "Cmd+S", true, true), None, "recording wins over typing"); + // the application menu's Settings… and Quit are tracked like the other items + for sc in [SETTINGS_KEY, "Cmd+Q"] { + let a = accelerator(sc); + assert!(a.is_some()); + assert_eq!(wanted_accelerator(a, sc, true, false), a, "{sc} stays while typing"); + assert_eq!(wanted_accelerator(a, sc, false, true), None, "{sc} goes while recording"); + } + assert_eq!(wanted_accelerator(None, "Escape", false, false), None, "no accelerator stays none"); + } + + /// A submenu of numbers whose calls can be made to fail, to run `swap_item`'s failure paths. + /// Row 2 is the old item and row 9 its replacement. + #[derive(Default)] + struct Fake { + rows: std::cell::RefCell>, + fail_remove: bool, + /// The new item (9) cannot be inserted / appended. + reject_new_insert: bool, + reject_new_append: bool, + /// The old item (2) cannot be inserted back. + reject_old_insert: bool, + } + impl SwapTarget for Fake { + type Row = u32; + fn remove(&self, row: &u32) -> bool { + if self.fail_remove { + return false; + } + self.rows.borrow_mut().retain(|x| x != row); + true + } + fn insert(&self, row: &u32, position: usize) -> bool { + if (self.reject_new_insert && *row == 9) || (self.reject_old_insert && *row == 2) { + return false; + } + self.rows.borrow_mut().insert(position, *row); + true + } + fn append(&self, row: &u32) -> bool { + if self.reject_new_append && *row == 9 { + return false; + } + self.rows.borrow_mut().push(*row); + true + } + } + + fn swap(fake: Fake) -> (bool, Vec) { + *fake.rows.borrow_mut() = vec![1, 2, 3]; + let done = swap_item(&fake, &2, &9, 1); + (done, fake.rows.into_inner()) + } + + #[test] + fn a_swap_changes_the_item_only_when_the_new_one_is_attached() { + assert_eq!(swap(Fake::default()), (true, vec![1, 9, 3]), "in place"); + assert_eq!(swap(Fake { fail_remove: true, ..Default::default() }), (false, vec![1, 2, 3]), "remove fails: the old item stays"); + assert_eq!(swap(Fake { reject_new_insert: true, ..Default::default() }), (true, vec![1, 3, 9]), "insert fails: the new item goes last"); + assert_eq!( + swap(Fake { reject_new_insert: true, reject_new_append: true, ..Default::default() }), + (false, vec![1, 2, 3]), + "neither works: the old item is back where it was" + ); + assert_eq!( + swap(Fake { reject_new_insert: true, reject_new_append: true, reject_old_insert: true, ..Default::default() }), + (false, vec![1, 3, 2]), + "the old item cannot go back in place either: it is appended" + ); + } + + #[test] + fn a_failed_native_call_is_retried_while_the_state_holds() { + let g = accelerator("G"); + assert_eq!(settled(g, None, true), None, "the swap worked"); + assert_eq!(settled(g, None, false), g, "it failed: the native item still has its key"); + assert_eq!(settled(None, g, false), None, "restoring a key failed"); + assert!(!sync_needed(true, false, true, false, false), "nothing changed"); + assert!(sync_needed(true, false, false, false, false), "focus changed"); + assert!(sync_needed(false, true, false, false, false), "recording changed"); + assert!(sync_needed(true, false, true, false, true), "a swap failed last time: try again with the state unchanged"); + } + #[test] fn shortcuts_map_to_accelerators() { assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ))); diff --git a/contributors/people.toml b/contributors/people.toml index 4eb002cef..cc71e4cc2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -38,6 +38,9 @@ sync_github_name = true [people.Losminek] sync_github_name = true +[people.nicolasfalesy] +sync_github_name = true + [people.FooVoo] real_name = "Aleksandr Batyrev" sync_github_name = true diff --git a/crates/catalog/src/lock.rs b/crates/catalog/src/lock.rs index d699da049..f701087a6 100644 --- a/crates/catalog/src/lock.rs +++ b/crates/catalog/src/lock.rs @@ -25,7 +25,7 @@ //! - Browser storage (OPFS) has no file locks: see the web host for its own guard. use std::fs::{File, OpenOptions, TryLockError}; -use std::io; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; /// The lock file in the library directory. @@ -116,9 +116,11 @@ impl LibraryLock { } Err(TryLockError::Error(e)) => return Ok(unlocked(e)), } + // A new file in place of whatever is at the name: a link planted there in a library folder + // someone else prepared is removed, never written through (as for the staging files, #1502). let owner = LockOwner::this_process(program); if let Ok(json) = serde_json::to_vec_pretty(&owner) - && let Err(e) = std::fs::write(dir.join(OWNER), json) + && let Err(e) = crate::store::create_staging(&dir.join(OWNER)).and_then(|mut f| f.write_all(&json)) { log::warn!("library {}: can't write {OWNER}: {e}", dir.display()); } diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a94421369..830857386 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -29,6 +29,10 @@ pub struct Filter { /// Only these photos (Find Similar results…); empty = no constraint. #[serde(skip_serializing_if = "Vec::is_empty")] pub only: Vec, + /// Show nothing: an explicit empty result (Find Similar with no matches), since an empty + /// `only` means "no constraint". + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub only_none: bool, /// Any of these labels (the filter bar's multi-select); empty = no constraint. #[serde(skip_serializing_if = "Vec::is_empty")] pub labels: Vec, @@ -374,6 +378,9 @@ impl Filter { if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) { return false; } + if self.only_none { + return false; + } if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) { return false; } diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 667509d6f..bdf4b8c88 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -670,6 +670,11 @@ fn number(v: &Value) -> Option { } } +/// Widens an `f32` to the `f64` nearest its shortest decimal form (1.4f32 -> 1.4), so it compares equal to the value a user types. +fn f32_as_decimal(v: f32) -> f64 { + v.to_string().parse().unwrap_or(v as f64) +} + fn num_op(op: &str, have: Option, value: &Value) -> bool { if op == "between" { let (a, b) = match value { @@ -953,8 +958,8 @@ impl Rule { "editDate" => date_op(op, p.edited.as_deref(), value), "shutterSpeed" => num_op(op, crate::parse_shutter_seconds(&m.shutter), &shutter_value(value)), "iso" => num_op(op, m.iso.map(|v| v as f64), value), - "aperture" => num_op(op, m.aperture.map(|v| v as f64), value), - "focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value), + "aperture" => num_op(op, m.aperture.map(f32_as_decimal), value), + "focalLength" => num_op(op, m.focal_mm.map(f32_as_decimal), value), "longEdge" | "shortEdge" => { let (w, h) = p.shown_size(); num_op(op, Some(if field == "longEdge" { w.max(h) } else { w.min(h) }.round()), value) @@ -1129,6 +1134,25 @@ mod tests { serde_json::from_value(v).unwrap() } + #[test] + fn f32_boundary_values_match_gte_lte() { + let cat = Catalog::new(); + let matches = + |p: &Photo, field: &str, op: &str, value: f64| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat); + for v in [1.4f32, 1.8, 2.8, 5.6, 0.95] { + let mut p = photo(1); + p.meta.aperture = Some(v); + p.meta.focal_mm = Some(v); + let w = v.to_string().parse::().unwrap(); + for field in ["aperture", "focalLength"] { + assert!(matches(&p, field, "gte", w), "{field} gte {w}"); + assert!(matches(&p, field, "lte", w), "{field} lte {w}"); + assert!(!matches(&p, field, "gt", w), "{field} gt {w}"); + assert!(!matches(&p, field, "lt", w), "{field} lt {w}"); + } + } + } + #[test] fn fields_ops_and_groups() { let cat = Catalog::new(); diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index 553eb241f..ad008cf0c 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -75,8 +75,8 @@ impl FsStore { /// A new staging file at `tmp`: whatever is already there (a leftover from a crashed save, or a link) is removed first /// (a link itself, never what it points to), then the file is created exclusively, so a write never follows a link -/// planted at that name into another file (issue #1502). -fn create_staging(tmp: &std::path::Path) -> io::Result { +/// planted at that name into another file (issue #1502). Also writes the lock's owner note. +pub(crate) fn create_staging(tmp: &std::path::Path) -> io::Result { if std::fs::symlink_metadata(tmp).is_ok() { std::fs::remove_file(tmp)?; } diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index baa8da659..2472ba92a 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -62,6 +62,28 @@ fn leftover_lock_files_are_not_a_lock() { let _ = std::fs::remove_dir_all(&dir); } +/// A link planted at the owner note's name (`catalog.lock.owner`) in a library folder someone else +/// prepared is removed, never written through: the file it points to keeps its bytes, and the note +/// still lands in the library (as for the catalog's staging files, issue #1502). +#[cfg(unix)] +#[test] +fn a_link_at_the_owner_note_is_never_written_through() { + let _serial = serial(); + let dir = temp_dir("owner-link"); + let victim = dir.join("victim.txt"); + std::fs::write(&victim, b"keep").unwrap(); + std::os::unix::fs::symlink(&victim, dir.join(OWNER)).unwrap(); + let l = LibraryLock::acquire(&dir, "LightCraft").unwrap(); + assert!(l.held()); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + assert!(!std::fs::symlink_metadata(dir.join(OWNER)).unwrap().file_type().is_symlink(), "the link was replaced by the note"); + let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap(); + assert_eq!(owner.pid, std::process::id()); + drop(l); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Another process holds the lock; when it is killed (a crash), the OS releases it. #[test] fn lock_held_by_another_process_is_released_when_it_dies() { diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 5902243fc..c9d5cec7d 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -100,7 +100,7 @@ jpegxl-rs / imagequant (GPL / C), dav1d / libheif (C). ## Performance (Apple M-series, release, synthetic 24 MP JPEG q90 4:2:0) -Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`. +Run `cargo test --release -p lightcraft-codecs --test it bench:: -- --ignored --nocapture`. | Operation | Time | |---|---| diff --git a/crates/codecs/tests/bench.rs b/crates/codecs/tests/it/bench.rs similarity index 97% rename from crates/codecs/tests/bench.rs rename to crates/codecs/tests/it/bench.rs index 4c6f03fa1..c66feeb77 100644 --- a/crates/codecs/tests/bench.rs +++ b/crates/codecs/tests/it/bench.rs @@ -1,5 +1,5 @@ //! Benchmark-ish, ignored by default. Run with: -//! `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture` +//! `cargo test --release -p lightcraft-codecs --test it bench:: -- --ignored --nocapture` use lightcraft_codecs::*; use std::time::Instant; diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/it/corpus.rs similarity index 100% rename from crates/codecs/tests/corpus.rs rename to crates/codecs/tests/it/corpus.rs diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/it/float_tiff.rs similarity index 100% rename from crates/codecs/tests/float_tiff.rs rename to crates/codecs/tests/it/float_tiff.rs diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/it/fuzz.rs similarity index 100% rename from crates/codecs/tests/fuzz.rs rename to crates/codecs/tests/it/fuzz.rs diff --git a/crates/codecs/tests/header.rs b/crates/codecs/tests/it/header.rs similarity index 100% rename from crates/codecs/tests/header.rs rename to crates/codecs/tests/it/header.rs diff --git a/crates/codecs/tests/heif.rs b/crates/codecs/tests/it/heif.rs similarity index 99% rename from crates/codecs/tests/heif.rs rename to crates/codecs/tests/it/heif.rs index d14f51ac4..3364aec4d 100644 --- a/crates/codecs/tests/heif.rs +++ b/crates/codecs/tests/it/heif.rs @@ -1,7 +1,6 @@ //! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from //! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a //! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input. -#![cfg(feature = "heif")] use lightcraft_codecs::*; use lightcraft_heif::testdata::{Prop, Spec, build}; diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/it/jxl_streaming.rs similarity index 98% rename from crates/codecs/tests/jxl_streaming.rs rename to crates/codecs/tests/it/jxl_streaming.rs index 298034656..a16a82e72 100644 --- a/crates/codecs/tests/jxl_streaming.rs +++ b/crates/codecs/tests/it/jxl_streaming.rs @@ -1,5 +1,3 @@ -#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] - use lightcraft_codecs::exif::minimal_exif; use lightcraft_codecs::*; use lightcraft_raster::Rgba8; diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs new file mode 100644 index 000000000..607562025 --- /dev/null +++ b/crates/codecs/tests/it/main.rs @@ -0,0 +1,13 @@ +//! The integration tests, one module per file, linked into one test binary. +//! `tests/jxl_tail_heap.rs` stays a binary of its own: it installs dhat as the global allocator. + +mod bench; +mod corpus; +mod float_tiff; +mod fuzz; +mod header; +#[cfg(feature = "heif")] +mod heif; +#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))] +mod jxl_streaming; +mod roundtrip; diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs similarity index 100% rename from crates/codecs/tests/roundtrip.rs rename to crates/codecs/tests/it/roundtrip.rs diff --git a/crates/denoise/tests/it/main.rs b/crates/denoise/tests/it/main.rs new file mode 100644 index 000000000..010fac0e2 --- /dev/null +++ b/crates/denoise/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The integration tests, one module per file, linked into one test binary. + +#[cfg(feature = "runtime")] +mod real_model; diff --git a/crates/denoise/tests/real_model.rs b/crates/denoise/tests/it/real_model.rs similarity index 99% rename from crates/denoise/tests/real_model.rs rename to crates/denoise/tests/it/real_model.rs index 203e99378..ee6c27694 100644 --- a/crates/denoise/tests/real_model.rs +++ b/crates/denoise/tests/it/real_model.rs @@ -2,7 +2,7 @@ //! //! ```text //! LC_DENOISE_MODEL= LC_DENOISE_MOSAICS= LC_DENOISE_OUT= \ -//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture +//! cargo test --release -p lightcraft-denoise --features runtime --test it real_model:: -- --ignored --nocapture //! ``` //! //! `LC_DENOISE_MOSAICS` holds `.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white @@ -11,8 +11,6 @@ //! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none), //! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop). -#![cfg(feature = "runtime")] - use std::path::{Path, PathBuf}; use std::time::Instant; diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index d074567a7..1d1d7468e 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -3066,10 +3066,14 @@ mod tests { let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); let used = crate::camera_profiles::get(model).expect("a profile for the model"); assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); - let raw = lightcraft_raw::decode(&bytes).unwrap(); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); assert_eq!(raw.metadata.model.as_deref(), Some(model)); + // as the app does (`files.rs`): the camera's own lens warp pairs the sensor with its JPEG (Nikon's in-file + // distortion for the Z 6II and Z 8), and the lens corrections leave the opcodes before the proxy is binned + let lens = lens_geometry(&raw.opcodes.list3); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); + let look = fit_preview(&raw, &bytes, &transform, lens.as_ref()).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); for (row, want) in look.matrix.0.iter().zip(expected.0) { for (got, want) in row.iter().zip(want) { diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 01c67c267..6f3f88963 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -482,6 +482,9 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { // a library folder chosen at or below it follows too, as a source or as a filter follow_library_folder(&mut s.library_folder, from, to); follow_library_folder(&mut s.filter.library_folder, from, to); + if let Some(f) = s.shelved_filter.as_mut() { + follow_library_folder(&mut f.library_folder, from, to); + } if let Some(b) = &mut s.browse && let Ok(rel) = Path::new(&b.path).strip_prefix(from) { diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 142accd5a..f6600c6b1 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -109,13 +109,16 @@ fn find_similar(s: &mut Session, p: &Value) -> Result { if let Some(v) = sig(s, q) { let sim = lightcraft_pipeline::cull::similarity(&me, &v); if sim >= min { - hits.push((q, sim)); + hits.push((q, sim.clamp(0.0, 1.0))); } } } hits.sort_by(|a, b| b.1.total_cmp(&a.1)); if p.get("filter").and_then(Value::as_bool).unwrap_or(true) { + s.shelved_filter = None; s.filter.only = hits.iter().map(|h| h.0).collect(); + // an empty `only` means "no constraint": no hits is its own state that shows no photos + s.filter.only_none = s.filter.only.is_empty(); } Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::>()})) } diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e66bb56f3..e3b385e2c 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -176,6 +176,11 @@ fn rename_person(s: &mut Session, p: &Value) -> Result { if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) { s.filter.person = Some(to.clone()); } + if let Some(f) = s.shelved_filter.as_mut() + && f.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) + { + f.person = Some(to.clone()); + } Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other})) } diff --git a/crates/engine/src/cmd/filters.rs b/crates/engine/src/cmd/filters.rs index 73bae384f..afe2011ac 100644 --- a/crates/engine/src/cmd/filters.rs +++ b/crates/engine/src/cmd/filters.rs @@ -38,6 +38,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?; s.filter = f.filter.clone(); + s.shelved_filter = None; Ok(json!({"photos": s.visible().len()})) } diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index c9ffb6b7e..feb9b033e 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -63,6 +63,13 @@ impl PlaceNames { self.lock().running } + /// Whether a place names download has been started in this session (running, or ended). + #[cfg(test)] + pub(crate) fn download_started(&self) -> bool { + let sh = self.lock(); + sh.running || sh.last.is_some() + } + /// The gazetteer in `dir`, parsed once and re-read only when the cities file's size or time changes. fn load(&mut self, dir: &Path) -> std::result::Result, lightcraft_geocode::GeocodeError> { let file = dir.join(GEONAMES_FILES[0]); diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index fe09a92f9..0f3b8827a 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -32,6 +32,12 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol if let Some(k) = s.filter.keyword.clone() { s.filter.keyword = follow(&k); } + // a filter put aside by Enable Filters follows the keyword too + if let Some(f) = s.shelved_filter.as_mut() + && let Some(k) = f.keyword.clone() + { + f.keyword = follow(&k); + } Ok(json!({"changed": photos, "listed": listed})) } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index e437dccc2..334d9c92d 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -426,6 +426,7 @@ pub fn specs() -> Vec { return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); } s.filter = f; + s.shelved_filter = None; Ok(json!({"count": s.visible().len()})) } ), @@ -469,6 +470,15 @@ pub fn specs() -> Vec { if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { s.filter.library_folder = None; } + // a filter put aside by Enable Filters must not bring the removed folder back + if let Some(f) = s.shelved_filter.as_mut() + && f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) + { + f.library_folder = None; + } + if s.shelved_filter.as_ref() == Some(&Filter::default()) { + s.shelved_filter = None; + } // the folder being shown is gone: back to everything, as for a deleted album if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { s.library_folder = None; @@ -510,8 +520,30 @@ pub fn specs() -> Vec { ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { s.filter = Default::default(); + s.shelved_filter = None; Ok(json!({"count": s.visible().len()})) }), + // Classic: Library > Enable Filters (Cmd+L) switches the filter off and on again as it was + cmd!( + "library.toggleFilters", + "Enable Filters", + ["View"], + Some("Cmd+L"), + "{} — with a filter set, puts it aside and shows everything; with one put aside, brings it back as it was (stars, flag, text… together). Setting or clearing a filter while it is off forgets the one put aside → {enabled, count}", + always, + |s, _| { + if s.filter != Filter::default() { + s.shelved_filter = Some(std::mem::take(&mut s.filter)); + } else if let Some(mut f) = s.shelved_filter.take() { + // one folder at a time: the folder shown now wins over the one the filter had + if s.source == LibrarySource::LibraryFolder { + f.library_folder = None; + } + s.filter = f; + } + Ok(json!({"enabled": s.shelved_filter.is_none(), "count": s.visible().len()})) + } + ), cmd!( "library.sort", "Sort", @@ -1771,3 +1803,7 @@ mod relative_rating_tests; #[cfg(test)] #[path = "library/tests_flag_cycle.rs"] mod flag_cycle_tests; + +#[cfg(test)] +#[path = "library/tests_enable_filters.rs"] +mod enable_filters_tests; diff --git a/crates/engine/src/cmd/library/tests_enable_filters.rs b/crates/engine/src/cmd/library/tests_enable_filters.rs new file mode 100644 index 000000000..9eb3c1ad0 --- /dev/null +++ b/crates/engine/src/cmd/library/tests_enable_filters.rs @@ -0,0 +1,154 @@ +//! `library.toggleFilters` (Library > Enable Filters, Cmd+L) through the real engine. +use lightcraft_catalog::{Filter, Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{LibrarySource, Result, Session}; + +fn session() -> Result { + let mut s = Session::new(); + for (id, rating, flag) in [(1, 5, Flag::Pick), (2, 5, Flag::None), (3, 2, Flag::Pick), (4, 1, Flag::None)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-enable-filters/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-23", + ); + photo.rating = rating; + photo.flag = flag; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + Ok(s) +} + +fn stars_and_pick(s: &mut Session) { + s.execute("library.filter", &json!({"rating": 4, "flag": "pick"})).unwrap(); +} + +#[test] +fn off_shows_everything_and_on_brings_back_the_same_filter() { + let mut s = session().unwrap(); + stars_and_pick(&mut s); + let set = s.filter.clone(); + assert_eq!(s.visible().len(), 1, "the filter hides photos, or off and on would look alike"); + let r = s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!((r["enabled"].clone(), r["count"].clone()), (json!(false), json!(4))); + assert_eq!(s.filter, Filter::default()); + assert_eq!(s.visible().len(), 4); + let r = s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!((r["enabled"].clone(), r["count"].clone()), (json!(true), json!(1))); + assert_eq!(s.filter, set, "stars and flag come back together"); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn with_no_filter_it_does_nothing_and_leaves_nothing_to_bring_back() { + let mut s = session().unwrap(); + let r = s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!((r["enabled"].clone(), r["count"].clone()), (json!(true), json!(4))); + let r = s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(r["enabled"], json!(true)); + assert_eq!(s.filter, Filter::default()); + assert!(s.shelved_filter.is_none()); +} + +#[test] +fn setting_a_filter_while_off_turns_filtering_on_and_forgets_the_old_one() { + let mut s = session().unwrap(); + stars_and_pick(&mut s); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.filter", &json!({"rating": 2})).unwrap(); + assert_eq!(s.visible().len(), 3); + // off again, then on: the filter that was set last, not the first one + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(s.filter.rating, 2); + assert_eq!(s.filter.flag, None); +} + +#[test] +fn emptying_the_filter_by_hand_while_off_leaves_nothing_to_bring_back() { + let mut s = session().unwrap(); + stars_and_pick(&mut s); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.filter", &json!({"rating": 2})).unwrap(); + s.execute("library.filter", &json!({"rating": 0})).unwrap(); + assert_eq!(s.filter, Filter::default()); + s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(s.filter, Filter::default(), "the filter from before it was switched off is not back"); + assert_eq!(s.visible().len(), 4); +} + +#[test] +fn clearing_the_filter_while_off_leaves_nothing_to_bring_back() { + let mut s = session().unwrap(); + stars_and_pick(&mut s); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.clearFilter", &json!({})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(s.filter, Filter::default()); + assert_eq!(s.visible().len(), 4); +} + +#[test] +fn applying_a_preset_while_off_turns_filtering_on_with_the_preset() { + let mut s = session().unwrap(); + s.filter_presets.push(super::super::filters::FilterPreset { name: "Fives".into(), filter: Filter { rating: 5, ..Default::default() } }); + stars_and_pick(&mut s); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("filter.applyPreset", &json!({"name": "Fives"})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(s.filter.rating, 5); + assert_eq!(s.filter.flag, None); +} + +#[test] +fn a_folder_filter_does_not_come_back_over_the_folder_now_shown() { + let mut s = session().unwrap(); + s.execute("library.filter", &json!({"rating": 4, "libraryFolder": "/lightcraft-enable-filters"})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.source = LibrarySource::LibraryFolder; + s.library_folder = Some("/lightcraft-enable-filters".into()); + s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(s.filter.rating, 4); + assert_eq!(s.filter.library_folder, None); +} + +#[test] +fn removing_a_folder_while_off_drops_it_from_the_filter_put_aside() { + let mut s = session().unwrap(); + s.execute("library.filter", &json!({"rating": 4, "libraryFolder": "/lightcraft-enable-filters"})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.removeFolder", &json!({"path": "/lightcraft-enable-filters"})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(s.filter.library_folder, None, "the removed folder is not brought back"); + assert_eq!(s.filter.rating, 4, "the rest of the filter is"); +} + +#[test] +fn removing_the_only_criterion_put_aside_leaves_filtering_on() { + let mut s = session().unwrap(); + s.execute("library.filter", &json!({"libraryFolder": "/lightcraft-enable-filters"})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.removeFolder", &json!({"path": "/lightcraft-enable-filters"})).unwrap(); + assert!(s.shelved_filter.is_none(), "an empty filter is not left put aside"); +} + +#[test] +fn a_keyword_renamed_while_off_is_followed_by_the_filter_put_aside() { + let mut s = session().unwrap(); + let mut photo = Photo::new(PhotoId(5), Source::File { path: "/lightcraft-enable-filters/5.jpg".into() }, "5.jpg", "JPEG", 40, 30, "2026-10-23"); + photo.meta.keywords = vec!["cat".into()]; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + s.filter.keyword = Some("cat".into()); + assert_eq!(s.visible().len(), 1); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("keyword.rename", &json!({"from": "cat", "to": "dog"})).unwrap(); + let r = s.execute("library.toggleFilters", &json!({})).unwrap(); + assert_eq!(r["enabled"], json!(true)); + assert_eq!(s.filter.keyword.as_deref(), Some("dog"), "the put-aside filter follows the rename"); + assert_eq!(s.visible().len(), 1); +} diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index dc403df9b..2fd44d566 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -8,6 +8,18 @@ use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_p use crate::segment::{Click, MAX_CLICKS}; use crate::{Result, Session}; +/// A colour-range mask compares every pixel against every sample, so render and export cost is +/// pixels x samples. The interactive sampler stops at 5; automation (`mask.add`/`mask.update`) +/// passed its `samples` array straight through, so a few hundred samples on a large render hung. +const MAX_COLOR_RANGE_SAMPLES: usize = 64; + +/// Caps a colour-range shape's sample list at [`MAX_COLOR_RANGE_SAMPLES`] (render cost is pixels x samples). +fn cap_color_range_samples(shape: &mut MaskShape) { + if let MaskShape::ColorRange { samples, .. } = shape { + samples.truncate(MAX_COLOR_RANGE_SAMPLES); + } +} + fn shape_from(kind: &str, p: &Value, c: &str) -> Result { Ok(match kind { "brush" => MaskShape::Brush { strokes: vec![] }, @@ -35,7 +47,12 @@ fn shape_from(kind: &str, p: &Value, c: &str) -> Result { let samples: Vec<[f64; 3]> = p .get("samples") .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|s| Some([s.get(0)?.as_f64()?, s.get(1)?.as_f64()?, s.get(2)?.as_f64()?])).collect()) + .map(|a| { + a.iter() + .filter_map(|s| Some([s.get(0)?.as_f64()?, s.get(1)?.as_f64()?, s.get(2)?.as_f64()?])) + .take(MAX_COLOR_RANGE_SAMPLES) + .collect() + }) .unwrap_or_default(); MaskShape::ColorRange { samples, refine: f64_or(p, "refine", 50.0) } } @@ -381,8 +398,9 @@ pub fn specs() -> Vec { } ), cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| { - let shape: MaskShape = + let mut shape: MaskShape = serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?; + cap_color_range_samples(&mut shape); let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0); let mid = mask_id(p, s.active_mask, "mask.update")?; masks_edit(s, "mask.update", "Edit Mask", |masks, _| { @@ -1123,3 +1141,30 @@ fn pressure_list(v: Option<&Value>, points: usize) -> Result> { .collect::>>() .ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1")) } + +#[cfg(test)] +mod tests_color_range { + use super::*; + + /// Automation (`mask.add`/`mask.update`) used to take any number of colour-range samples; render + /// cost is pixels x samples, so a few hundred hung a large render. The list is now capped. + #[test] + fn color_range_samples_are_capped() { + let samples: Vec = (0..1000).map(|i| json!([i as f64 / 1000.0, 0.1, 0.2])).collect(); + let shape = shape_from("colorRange", &json!({ "samples": samples }), "mask.add").unwrap(); + let MaskShape::ColorRange { samples, .. } = shape else { panic!("expected ColorRange") }; + assert_eq!(samples.len(), MAX_COLOR_RANGE_SAMPLES); + // A reasonable request is unchanged. + let few = shape_from("colorRange", &json!({ "samples": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] }), "mask.add").unwrap(); + let MaskShape::ColorRange { samples, .. } = few else { panic!() }; + assert_eq!(samples.len(), 2); + // mask.update deserializes the shape directly; it is capped the same way. + let mut shape = shape_from("colorRange", &json!({ "samples": [[0.1, 0.2, 0.3]] }), "mask.add").unwrap(); + if let MaskShape::ColorRange { samples, .. } = &mut shape { + samples.resize(1000, samples[0]); + } + cap_color_range_samples(&mut shape); + let MaskShape::ColorRange { samples, .. } = shape else { panic!() }; + assert_eq!(samples.len(), MAX_COLOR_RANGE_SAMPLES); + } +} diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 1bff30d2b..c6c692c35 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -197,13 +197,24 @@ impl FindPlan { } } +/// How Find Missing compares file names: the part after the last `/` or `\`, in lower case. A +/// library made on Windows keeps Windows paths, whose `\` isn't a separator to `Path` on macOS or +/// Linux. Files on disk are named by the same rule, so a macOS or Linux file name that holds a +/// `\` (`a\b.jpg`) still meets its photo, and a `b.jpg` beside it makes that match ambiguous +/// rather than a guess. With no `a\b.jpg` there, such a photo may be found as `b.jpg`, if the +/// size on record agrees. +fn name_key(path: &str) -> String { + path.rsplit(['/', '\\']).next().unwrap_or_default().to_lowercase() +} + /// The file-system half of Find Missing Photos: which `candidates` are gone, and which file in /// `folder` (recursively) is each one's. Touches only the disk — never the session — so the app /// runs it on a worker thread (walking a big folder on a network share and hashing look-alikes /// takes a while) and relinks the result with `library.findMissing {found, missing, ambiguous}`. /// /// Matching, per photo: -/// 1. a file with the same name (any letter case), the same size and the same content hash; +/// 1. a file with the same name ([`name_key`]: any letter case), the same size and the same +/// content hash; /// 2. renamed: a file of the same size with the same content hash (only same-size files are /// read, so this stays cheap); /// 3. without a hash to confirm (or the file was edited in place): the one file with the same @@ -228,8 +239,9 @@ pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::res let mut by_name: HashMap> = HashMap::new(); let files: Vec = crate::import::expand(&[folder.to_string()], None).into_iter().filter(|f| !in_use.contains(f.as_str())).collect(); for f in &files { - if let Some(n) = Path::new(f).file_name() { - by_name.entry(n.to_string_lossy().to_lowercase()).or_default().push(f.clone()); + let name = name_key(f); + if !name.is_empty() { + by_name.entry(name).or_default().push(f.clone()); } } let mut by_size: Option>> = None; @@ -239,7 +251,7 @@ pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::res let mut used: HashSet = HashSet::new(); let mut plan = FindPlan::default(); for c in &lost { - let name = Path::new(&c.path).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + let name = name_key(&c.path); let size = c.size; let hash = c.hash.as_deref(); let named: Vec<&String> = by_name diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index c2b0e9f69..d3f455b81 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -38,13 +38,13 @@ impl std::error::Error for NotFollowed {} /// was opened from. pub fn resolve(stored: &str, document: Option<&Path>) -> Result { let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); - let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; - // A path that is still relative would be taken from the working directory, which the - // document does not choose. - if path.is_relative() { - return Err(NotFollowed::NotHere); + match craft_docpath::resolve_absolute(stored, document.as_deref()) { + Ok(path) => Ok(path), + // A usable path that is not absolute here: a Windows path on macOS or Linux, or a + // relative path with no document folder to take it from. + Err(Refused::Invalid) if craft_docpath::resolve(stored, document.as_deref()).is_ok() => Err(NotFollowed::NotHere), + Err(r) => Err(NotFollowed::Refused(r)), } - Ok(path) } #[cfg(test)] @@ -59,5 +59,12 @@ mod tests { assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device))); + #[cfg(windows)] + assert_eq!(resolve("Photos/2024:05/a.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); + #[cfg(not(windows))] + assert_eq!(resolve(r"C:\Photos\a.jpg", Some(&doc)), Err(NotFollowed::NotHere)); + let gvfs = Path::new("/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/catalog.lrcat"); + #[cfg(not(windows))] + assert_eq!(resolve("a.jpg", Some(gvfs)), Ok(gvfs.with_file_name("a.jpg"))); } } diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index bfeade59c..0e2254afd 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1827,10 +1827,14 @@ impl PreparedExport { }; let Some(dng) = dng else { // the file is copied byte for byte: refuse rather than leak a location it embeds - if remove_location && lightcraft_meta::extract(&bytes).gps.is_some() { - return Err(format!( - "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" - )); + if remove_location { + let metadata = lightcraft_meta::try_extract(&bytes) + .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?; + if metadata.gps.is_some() { + return Err(format!( + "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" + )); + } } return Ok(Exported { file_name, diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 0670fb763..82d4c57d6 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1177,6 +1177,22 @@ pub(crate) mod tests { assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); } + #[test] + fn psd_probe_reads_embedded_capture_time_gps_and_title() { + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let metadata = lightcraft_meta::Metadata { + capture_time: Some(capture), + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSD metadata".into()), + ..Default::default() + }; + let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); + let probe = probe_bytes("metadata.psd", &bytes).unwrap(); + assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); + assert_eq!(probe.meta.gps, Some((45.0, 7.0))); + assert_eq!(probe.meta.title, "PSD metadata"); + } + use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; @@ -2809,7 +2825,7 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs` + /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs` /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). fn sony_arw(model: &str) -> Vec { use lightcraft_tiff::tags::{self as t, photometric}; diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index bc8c1c962..62bd33820 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -36,7 +36,7 @@ use crate::media::ProbeInfo; /// This includes preview-only raws; the file probe decides whether sensor decoding is available. pub const EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", - "srw", "psd", "jxl", "gif", "bmp", + "srw", "psd", "psb", "jxl", "gif", "bmp", // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 323ed90ef..4ad5695dc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -207,6 +207,8 @@ pub struct Session { pub catalog: Catalog, pub source: LibrarySource, pub filter: Filter, + /// The filter `library.toggleFilters` put aside (Library > Enable Filters off); not persisted. + pub(crate) shelved_filter: Option, pub sort: Sort, pub selection: Selection, /// The grid order for the current source/filter/sort (cached by catalog revision). @@ -422,6 +424,7 @@ impl Session { catalog: Catalog::new(), source: LibrarySource::All, filter: Filter::default(), + shelved_filter: None, sort: Sort::default(), selection: Selection::default(), visible: Vec::new().into(), @@ -1027,6 +1030,11 @@ impl Session { // ---------------------------------------------------------------- library view + /// Library > Enable Filters is off: a filter is put aside (`library.toggleFilters`). + pub fn filters_off(&self) -> bool { + self.shelved_filter.is_some() + } + /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { let mut key = diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 713c697a7..f20ad9c95 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -845,13 +845,12 @@ impl SyncCommit { } } SyncStep::Remove(gone) => { - let ops: Vec = gone - .iter() - .filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library())) - .map(|id| Op::SetDeleted { id: *id, deleted: true }) - .collect(); + let ids: Vec = gone.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|p| p.in_library())).collect(); + let mut ops: Vec = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); let n = ops.len(); if n > 0 { + // Like `photo.delete`, take them out of their stacks. + ops.extend(s.catalog.remove_from_stacks_ops(&ids)); match s.commit("Remove Missing Photos", Op::Batch { ops }) { Ok(()) => report.removed += n, Err(e) => report.failed.push((String::new(), e.to_string())), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1e8f4b7e3..218965812 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -452,8 +452,14 @@ fn command_sweep() { for c in command_specs() { assert!(ids.insert(c.id), "duplicate {}", c.id); assert!(!c.label.is_empty() && !c.params.is_empty(), "{}", c.id); + // empty params make this one download from download.geonames.org into the real settings folder; its own tests run it against a loopback server + if c.id == "metadata.downloadPlaceNames" { + continue; + } let _ = s.execute(c.id, &Value::Null); } + // empty params must never start a download from the internet + assert!(!s.place_names.download_started(), "the sweep started a place names download"); assert!(s.commands().len() >= 70, "{}", s.commands().len()); } @@ -1244,6 +1250,43 @@ fn find_similar_filters_to_look_alikes() { assert!(s.visible_cloned().len() > vis.len()); } +#[test] +fn find_similar_with_no_hits_shows_no_photos() { + let mut s = demo(); + let r = s.execute("library.findSimilar", &json!({"similarity": 1.5})).unwrap(); + assert_eq!(r["photos"].as_array().map(Vec::len), Some(0)); + assert_eq!(s.visible_cloned().len(), 0, "an empty Find Similar result must show no photos, not the whole library"); + s.execute("library.clearFilter", &json!({})).unwrap(); + assert_eq!(s.visible_cloned().len(), s.catalog.len(), "Clear Filters brings everything back"); +} + +#[test] +fn find_similar_of_a_flat_photo_shows_an_empty_view_and_counts_zero() { + let mut s = demo(); + let photo = s.catalog.photos().next().unwrap().clone(); + // a flat photo's signature is all zeros: it is not similar even to itself + s.signatures.insert(crate::media::content_key(&photo), [0.0; 64]); + let r = s.execute("library.findSimilar", &json!({"id": photo.id.0})).unwrap(); + assert_eq!(r["photos"].as_array().map(Vec::len), Some(0)); + assert_eq!(s.visible_cloned().len(), 0, "no matches show nothing, not the whole library"); + assert_eq!(s.catalog.count(&s.filter), 0); + let chips = crate::filter_chips(&s.filter, &s.catalog); + assert_eq!(chips.iter().map(|c| c.label.as_str()).collect::>(), ["Only 0 photos"]); + s.execute("library.filter", &chips[0].clear).unwrap(); + assert_eq!(s.filter, Default::default(), "the chip clears the empty state"); + assert_eq!(s.visible_cloned().len(), s.catalog.len()); +} + +#[test] +fn find_similar_values_stay_within_zero_and_one() { + let mut s = demo(); + let id = s.catalog.photos().next().unwrap().id; + let r = s.execute("library.findSimilar", &json!({"id": id.0, "similarity": -1.0})).unwrap(); + let sims: Vec = r["photos"].as_array().unwrap().iter().map(|h| h["similarity"].as_f64().unwrap()).collect(); + assert_eq!(sims.len(), s.catalog.len()); + assert!(sims.iter().all(|v| (0.0..=1.0).contains(v)), "{sims:?}"); +} + #[test] fn quick_develop_adds_to_each_photo() { use lightcraft_catalog::PhotoId; diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index cc9ad2eed..bf0ae3a71 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -1,7 +1,7 @@ //! AI denoise through the engine: installing a model, making a photo's denoised picture in the background and for an //! export, the Amount slider mixing it in, and the cache. A tiny stand-in model (it halves every sample) takes the //! place of a real one, which is enough to see what the machinery does with its answer; how well real models denoise is -//! measured separately (`crates/denoise/tests/real_model.rs`). +//! measured separately (`crates/denoise/tests/it/real_model.rs`). use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 4830490b5..a835ecba4 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -4,7 +4,7 @@ use serde_json::json; use crate::Session; use crate::export::{ExportFormat, ExportOptions, export_photo}; -use crate::tests_xmp::{synthetic_dng_with, temp_dir}; +use crate::tests_xmp::{synthetic_dng_with, synthetic_psd_with_metadata, temp_dir}; #[test] fn full_size_export_of_a_cropped_photo_is_not_upscaled() { @@ -657,6 +657,92 @@ fn remove_location_refuses_a_raw_that_embeds_gps() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn remove_location_refuses_a_psd_that_embeds_gps() { + let dir = temp_dir("export-noloc-psd"); + let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), ..Default::default() }; + let path = dir.join("Geo.psd"); + let bytes = synthetic_psd_with_metadata(&gps); + assert_eq!(lightcraft_meta::extract(&bytes).gps, gps.gps, "the fixture embeds EXIF GPS"); + std::fs::write(&path, &bytes).unwrap(); + let mut session = Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = session.catalog.photos().next().unwrap().id; + let err = export_photo(&mut session, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap(); + assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn remove_location_refuses_a_psd_with_unreadable_xmp() { + let dir = temp_dir("export-noloc-psd-xmp"); + let path = dir.join("Unreadable.psd"); + let pixels = [40, 80, 120]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U8(&pixels)); + let xmp = " path.clone(), + _ => String::new(), + }; + let r = s.execute("library.findMissing", &json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); + assert_eq!(path_of(&s, a), dir.join("found").join("a.jpg").to_string_lossy(), "found by its name: {r}"); + assert_eq!(path_of(&s, b), dir.join("found").join("deep").join("b.jpg").to_string_lossy(), "found by its name, any case, and size: {r}"); + assert_eq!(path_of(&s, twin), r"C:\Users\me\Photos\twin.jpg", "two files of its name: not guessed: {r}"); + assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "both look-alikes reported: {r}"); + assert_eq!(r["missing"], 1, "{r}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!( + (path_of(&s, a), path_of(&s, b)), + (r"C:\Users\me\Photos\a.jpg".to_string(), r"D:\Shoot\2019\B.JPG".to_string()), + "one undo step puts back the Windows paths as they were spelled" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// On macOS and Linux `\` may be part of a file name. Find Missing names the files it finds by +/// the same rule as the missing photos, so `a\b.jpg` isn't relinked to a `b.jpg` beside it +/// unasked: the two are reported as look-alikes. +#[cfg(unix)] +#[test] +fn find_missing_does_not_take_a_look_alike_for_a_file_name_with_a_backslash() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = temp_dir("missing-backslash"); + std::fs::create_dir_all(dir.join("found")).unwrap(); + for name in [r"a\b.jpg", "b.jpg"] { + std::fs::write(dir.join("found").join(name), b"x").unwrap(); + } + let mut s = Session::new(); + let gone = dir.join("gone").join(r"a\b.jpg").to_string_lossy().to_string(); + let id = s.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: gone.clone() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let r = s.execute("library.findMissing", &json!({"folder": dir.join("found").to_string_lossy()})).unwrap(); + assert!(matches!(&s.catalog.photo(id).unwrap().source, Source::File { path } if *path == gone), "not relinked to b.jpg: {r}"); + assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "both reported: {r}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn missing_files_are_found_and_relinked() { let dir = std::env::temp_dir().join(format!("lc-missing-{}", std::process::id())); diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 88c184335..8b1ec4f67 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -739,3 +739,21 @@ fn a_cancelled_scan_does_not_check_the_cached_probes_against_the_disk() { let new = out.candidates.iter().find(|c| c.name == "new.png").expect("new.png is a candidate"); assert!(new.error.is_some(), "the cached probe was used after ✕: {new:?}"); } + +#[test] +fn removing_missing_photos_takes_them_out_of_their_stacks() { + let dir = Scratch::new("remove-stacked"); + let mut s = library(&dir); + let id = |s: &Session, name: &str| s.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let (a, b) = (id(&s, "a.png"), id(&s, "b.png")); + let group = s.catalog.group_ops(a, &[b], true).unwrap(); + s.catalog.apply(group).unwrap(); + assert!(s.catalog.stack_of(b).is_some()); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap(); + assert_eq!(r["removed"].as_u64(), Some(1), "{r}"); + assert!(s.catalog.photo(b).unwrap().deleted); + assert!(s.catalog.stack_of(b).is_none(), "like photo.delete, a removed photo leaves its stack: {:?}", s.catalog.stack_of(b)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.stack_of(b).is_some(), "one undo step puts b back in its stack"); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 1a4d86fa3..129662e4d 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -43,6 +43,34 @@ fn write_png(path: &Path, seed: u8) { std::fs::write(path, bytes).unwrap(); } +pub(crate) fn synthetic_psd_with_metadata(metadata: &lightcraft_meta::Metadata) -> Vec { + let exif = lightcraft_meta::write_exif(metadata); + let xmp = lightcraft_meta::write_xmp(metadata, None); + let pixels = [40, 80, 120]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U8(&pixels)); + let embedded = lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }; + lightcraft_codecs::encode_psd(&image, &embedded).unwrap() +} + +pub(crate) fn synthetic_psb_with_metadata(metadata: &lightcraft_meta::Metadata) -> Vec { + let exif = lightcraft_meta::write_exif(metadata); + let xmp = lightcraft_meta::write_xmp(metadata, None); + let pixels = [1024u16, 2048, 3072]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U16(&pixels)); + let embedded = lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }; + let psd = lightcraft_codecs::encode_psd(&image, &embedded).unwrap(); + let colour_length = usize::try_from(u32::from_be_bytes(psd[26..30].try_into().unwrap())).unwrap(); + let resource_length_offset = 30 + colour_length; + let resource_length = usize::try_from(u32::from_be_bytes(psd[resource_length_offset..resource_length_offset + 4].try_into().unwrap())).unwrap(); + let resource_end = resource_length_offset + 4 + resource_length; + + let mut psb = psd[..resource_end].to_vec(); + psb[4..6].copy_from_slice(&2u16.to_be_bytes()); + psb.extend_from_slice(&0u64.to_be_bytes()); // PSB layer and mask information length + psb.extend_from_slice(&psd[resource_end + 4..]); // compression and uncompressed 16-bit pixel planes + psb +} + fn only(s: &Session) -> PhotoId { s.catalog.photos().next().unwrap().id } @@ -1036,10 +1064,21 @@ fn external_edit_copies_every_selected_photo() { fn external_edit_copy_as_psd_round_trips() { let dir = temp_dir("extedit-psd"); let src = dir.join("pier.png"); - write_png(&src, 40); + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let source_meta = lightcraft_meta::Metadata { capture_time: Some(capture), title: Some("Dated original".into()), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&source_meta); + let xmp = lightcraft_meta::write_xmp(&source_meta, None); + let rgba = lightcraft_raster::Rgba8::from_fn(40, 24, |x, y| [(x * 6) as u8, (y * 9) as u8, 40, 255]); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + &lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }, + ) + .unwrap(); + std::fs::write(&src, png).unwrap(); let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); let orig = s.active().unwrap(); + assert_eq!(s.catalog.photo(orig).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); let r = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 16})).unwrap(); let path = r["path"].as_str().unwrap().to_string(); @@ -1048,6 +1087,13 @@ fn external_edit_copy_as_psd_round_trips() { assert_eq!(d.bit_depth, 16); assert!(d.icc.is_some(), "the colour space travels with the file"); let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + assert_eq!(s.catalog.photo(new).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); + let all_metadata = s.execute("photo.allMetadata", &json!({"id": new.0})).unwrap(); + assert!(all_metadata["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003)), "{all_metadata}"); + assert!( + all_metadata["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "dc:title" && row["value"] == "Dated original"), + "{all_metadata}" + ); assert_eq!(s.catalog.stack_of(new).map(|st| (st.top(), st.photos.contains(&orig))), Some((new, true))); // the editor saves the document back over the same file: Reload shows its pixels let before = s.render_now(new, 16, 16).unwrap().image.data; @@ -1069,6 +1115,33 @@ fn external_edit_copy_as_psd_round_trips() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn psb_import_retains_capture_time_and_all_metadata() { + let dir = temp_dir("import-psb-metadata"); + let path = dir.join("dated.psb"); + let capture = lightcraft_meta::DateTime::parse_exif("2023:05:14 14:40:39"); + let metadata = lightcraft_meta::Metadata { + capture_time: capture, + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSB original".into()), + ..Default::default() + }; + let bytes = synthetic_psb_with_metadata(&metadata); + assert_eq!(lightcraft_meta::extract(&bytes).capture_time, capture); + assert_eq!(lightcraft_meta::extract(&bytes).gps, metadata.gps); + std::fs::write(&path, bytes).unwrap(); + + let mut session = Session::new().with_fs(); + let imported = session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert_eq!(imported["imported"].as_array().map(Vec::len), Some(1), "{imported}"); + let id = session.catalog.photos().next().unwrap().id; + assert_eq!(session.catalog.photo(id).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); + let all_metadata = session.execute("photo.allMetadata", &json!({"id": id.0})).unwrap(); + assert!(all_metadata["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003)), "{all_metadata}"); + assert!(all_metadata["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "dc:title" && row["value"] == "PSB original"), "{all_metadata}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the /// cached source is dropped and renders change; unchanged files are left alone. #[test] @@ -1375,6 +1448,38 @@ const OTHER_APP_SIDECAR: &str = r#" "#; +/// A foreign namespace may use a familiar prefix without becoming LightCraft-owned metadata. +#[test] +fn saving_preserves_foreign_namespace_prefix_collisions() { + let src = temp_dir("namespace-src"); + let lib = temp_dir("namespace-lib"); + write_png(&src.join("shot.png"), 1); + let foreign = r#"also keep"#; + let packet = format!(r#"{foreign}"#); + let path = src.join("shot.xmp"); + std::fs::write(&path, packet).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.execute("library.import", &json!({"paths": [src.join("shot.png")]})).unwrap(); + let id = only(&s); + assert_eq!(s.catalog.photo(id).unwrap().rating, 0, "a foreign rating must not become catalog metadata"); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 5})).unwrap(); + for _ in 0..2 { + let result = s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); + assert_eq!(result["failed"], json!([]), "{result}"); + assert_eq!(result["backups"], json!([]), "a valid packet must merge: {result}"); + assert_eq!(result["merged"], json!([path.to_string_lossy()]), "{result}"); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains(foreign), "lost foreign bytes: {saved}"); + assert_eq!(lightcraft_meta::parse_xmp(&saved).unwrap().metadata.rating, Some(5)); + } + drop(s); + std::fs::remove_dir_all(src).unwrap(); + std::fs::remove_dir_all(lib).unwrap(); +} + /// Issue #92: saving merges into another app's sidecar instead of replacing it. #[test] fn saving_merges_into_another_apps_sidecar() { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 5b32999bb..0d5cde3cf 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -214,8 +214,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null})); } - if !f.only.is_empty() { - add(format!("Only {} photos", f.only.len()), json!({"only": []})); + if f.only_none || !f.only.is_empty() { + let n = if f.only_none { 0 } else { f.only.len() }; + add(format!("Only {n} photos"), json!({"only": [], "onlyNone": false})); } v } diff --git a/crates/engine/tests/denoise_cpu.rs b/crates/engine/tests/it/denoise_cpu.rs similarity index 97% rename from crates/engine/tests/denoise_cpu.rs rename to crates/engine/tests/it/denoise_cpu.rs index d01b7c5a5..1da6da78d 100644 --- a/crates/engine/tests/denoise_cpu.rs +++ b/crates/engine/tests/it/denoise_cpu.rs @@ -1,10 +1,9 @@ //! Opt-in same-machine denoise model-stage benchmark on one public Bayer raw. //! LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -//! -p lightcraft-engine --features denoise --test denoise_cpu -- --ignored --nocapture +//! -p lightcraft-engine --features denoise --test it denoise_cpu:: -- --ignored --nocapture //! LC_DENOISE_ONLY selects a file (default arw-sony-a7m3-compressed.arw). //! The whole model stage includes packing, scale/clip protection, blending and normalization; //! file decode, model loading and GPU setup are outside the timer. It uses the application's tile pool convention. -#![cfg(feature = "denoise")] use lightcraft_denoise::{ TileRunner, diff --git a/crates/engine/tests/denoise_real.rs b/crates/engine/tests/it/denoise_real.rs similarity index 98% rename from crates/engine/tests/denoise_real.rs rename to crates/engine/tests/it/denoise_real.rs index 8d8581753..cb045cbd5 100644 --- a/crates/engine/tests/denoise_real.rs +++ b/crates/engine/tests/it/denoise_real.rs @@ -4,12 +4,11 @@ //! //! ```text //! LC_DENOISE_MODEL= LC_DENOISE_RAW= \ -//! cargo test --release -p lightcraft-engine --features denoise --test denoise_real -- --ignored --nocapture +//! cargo test --release -p lightcraft-engine --features denoise --test it denoise_real:: -- --ignored --nocapture //! ``` //! //! `LC_DENOISE_RAW` is a folder of CC0 raw files (`cargo xtask corpus --download` → `corpus/raw`); every file in it is //! tried. Optional: `LC_DENOISE_ONLY` (a file name to try alone), `LC_DENOISE_RUN_ON=cpu|gpu|auto` (where the model runs; `cpu` to compare with the card). -#![cfg(feature = "denoise")] use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..2c52d87e9 --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,7 @@ +//! The integration tests, one module per file, linked into one test binary. + +#[cfg(feature = "denoise")] +mod denoise_cpu; +#[cfg(feature = "denoise")] +mod denoise_real; +mod preview_read; diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/it/preview_read.rs similarity index 95% rename from crates/engine/tests/preview_read.rs rename to crates/engine/tests/it/preview_read.rs index eedebc2c1..98eb64d27 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/it/preview_read.rs @@ -2,7 +2,7 @@ //! network share, which is where reading only the header instead of the whole raw matters: //! //! LC_PREVIEW_RAW="/run/user/1000/gvfs/smb-share:.../DSCF0001.RAF" \ -//! cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture +//! cargo test --release -p lightcraft-engine --test it preview_read:: -- --ignored --nocapture //! //! It reports what the preview cost and fails when the read is not the small part of the file the //! header path promises. Skipped without `LC_PREVIEW_RAW` (there is no committed raw fixture: media diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 0c3ab8f05..33e02fbe4 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -118,7 +118,9 @@ impl Drop for Buf { let mut b = Some(b); let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take())); match b { - Some(b) => std::mem::forget(b), + // Leaking is deliberate during thread teardown — dropping a wgpu buffer whose device + // thread-locals have already gone can abort the process. The device owns it. + Some(b) => std::mem::forget(b), // NOSONAR None => { RETIRED_BYTES.fetch_add(size, Ordering::Relaxed); } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/it/equivalence.rs similarity index 100% rename from crates/gpu/tests/equivalence.rs rename to crates/gpu/tests/it/equivalence.rs diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/it/fallback.rs similarity index 100% rename from crates/gpu/tests/fallback.rs rename to crates/gpu/tests/it/fallback.rs diff --git a/crates/gpu/tests/gpu_probe.rs b/crates/gpu/tests/it/gpu_probe.rs similarity index 97% rename from crates/gpu/tests/gpu_probe.rs rename to crates/gpu/tests/it/gpu_probe.rs index e006fbc80..4bed20d1b 100644 --- a/crates/gpu/tests/gpu_probe.rs +++ b/crates/gpu/tests/it/gpu_probe.rs @@ -1,7 +1,7 @@ //! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of //! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs; //! xtask runs it after the workspace tests, so it costs no extra build: -//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`. +//! `cargo test --workspace --test it -- --ignored --exact gpu_probe::gpu_probe --nocapture`. //! //! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`, //! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build @@ -11,7 +11,6 @@ //! a driver that crashes while it is enumerated (issue #136) costs only its own line. //! //! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy. -#![cfg(not(target_arch = "wasm32"))] use lightcraft_gpu::backend::{self, Backends}; diff --git a/crates/gpu/tests/it/main.rs b/crates/gpu/tests/it/main.rs new file mode 100644 index 000000000..d26d1efc9 --- /dev/null +++ b/crates/gpu/tests/it/main.rs @@ -0,0 +1,9 @@ +//! The integration tests, one module per file, linked into one test binary. +//! Binaries of their own: `tests/shutdown.rs` and `tests/shutdown_first.rs` close the GPU for the whole +//! process, and `tests/memory.rs` checks the process's device-memory counters, which renders on other test +//! threads would change. + +mod equivalence; +mod fallback; +#[cfg(not(target_arch = "wasm32"))] +mod gpu_probe; diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/it/e2e.rs similarity index 100% rename from crates/mcp/tests/e2e.rs rename to crates/mcp/tests/it/e2e.rs diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs new file mode 100644 index 000000000..7fce2464b --- /dev/null +++ b/crates/mcp/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod e2e; diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/it/hdr.rs similarity index 100% rename from crates/merge/tests/hdr.rs rename to crates/merge/tests/it/hdr.rs diff --git a/crates/merge/tests/it/main.rs b/crates/merge/tests/it/main.rs new file mode 100644 index 000000000..0859919cd --- /dev/null +++ b/crates/merge/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod hdr; +mod pano; +mod white_balance; diff --git a/crates/merge/tests/pano.rs b/crates/merge/tests/it/pano.rs similarity index 100% rename from crates/merge/tests/pano.rs rename to crates/merge/tests/it/pano.rs diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/it/white_balance.rs similarity index 100% rename from crates/merge/tests/white_balance.rs rename to crates/merge/tests/it/white_balance.rs diff --git a/crates/meta/README.md b/crates/meta/README.md index 83ee9e601..519426784 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -8,11 +8,12 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr |---|---| | `read_exif(&[u8]) -> Metadata` | TIFF-structured Exif block (whole TIFF/DNG/raw file, or JPEG APP1 payload with/without `Exif\0\0`). `try_read_exif` reports header errors. | | `from_tiff(&Tiff) -> Metadata` | Same, from an already parsed stream (used by `lightcraft-raw`). | -| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. | +| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF / PSD / PSB and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. PSD/PSB EXIF resource 1058 and XMP resource 1060 are read from the bounded image-resource section without decoding pixels. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. | | `parse_xmp(&str) -> Result` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). | | `write_xmp(&Metadata, Option<&str>) -> String` | Complete `` packet; the second argument is LightCraft's full develop settings JSON. | | `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date/time created (2:55/2:60). | | `extract(&[u8]) -> Metadata` | Everything for a whole file. Precedence: EXIF → IPTC fills gaps → XMP overrides user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. | +| `try_extract(&[u8]) -> Result` | Like `extract`, but reports malformed PSD/PSB headers, image-resource sections, or embedded EXIF/XMP structure (including duplicate EXIF/XMP resources) so callers can fail closed when location absence cannot be established. Other formats remain best-effort. | `Metadata` carries camera (make, model, serial, software), lens (make, model, serial, specification), capture (date-time with UTC offset and milliseconds, exposure time, f-number, ISO incl. extended ISO, focal length and @@ -27,10 +28,19 @@ prefixes by namespace URI, handles attribute and element forms, `rdf:Seq`/`Bag`/ `rdf:parseType="Resource"` and nested-description structs, predefined and numeric entities. Nesting depth and node count are limited. +An explicitly foreign namespace using a recognized prefix (for example `lc` or `xmp`) cannot supply +recognized properties. Its keys use `Q{hex-encoded namespace URI}local` in `properties` and `values`; +hex encoding keeps `/` reserved for struct paths. Other unknown prefixes keep their existing QName keys. +Merging preserves the original foreign XML rather than writing these opaque keys into the packet. +The parser and merge scanner each cap generated opaque names at 8 MiB per packet, returning `TooComplex` +if repeated use of a long namespace URI would exceed that budget. + ## Robustness No panics on malformed input: proptests feed random bytes to every container walker and random text to the -XMP parser; truncation tests cut a sample JPEG at every byte. +XMP parser; truncation tests cut a sample JPEG at every byte and exercise truncated PSD/PSB headers and +image-resource records with hostile declared lengths. +PSD/PSB XMP resources require valid UTF-8; trailing NUL padding is trimmed after validation. ## Sources diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 3ac60907f..3cfb7e287 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,7 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Photoshop PSD/PSB: EXIF (image resource 1058) and XMP (image resource 1060) from the bounded image-resource section. //! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block. //! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the //! XMP `uuid` box. @@ -29,6 +30,9 @@ pub struct Embedded { /// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result. pub fn embedded(bytes: &[u8]) -> Embedded { + if bytes.starts_with(b"8BPS") { + return crate::psd::embedded(bytes); + } if bytes.starts_with(b"FUJIFILMCCD-RAW") { // Fujifilm RAF: metadata lives in the embedded JPEG whose (offset, length) are big-endian u32s at byte 84 let be = |i: usize| bytes.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize); @@ -71,6 +75,12 @@ pub fn embedded(bytes: &[u8]) -> Embedded { } } +/// Locate metadata blocks while preserving PSD/PSB resource-structure errors for callers that +/// must establish whether embedded GPS is absent. Other containers keep their best-effort walk. +pub(crate) fn try_embedded(bytes: &[u8]) -> Result { + if bytes.starts_with(b"8BPS") { crate::psd::parse(bytes).ok_or(crate::ExtractError::MalformedPsd) } else { Ok(embedded(bytes)) } +} + const XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 59062dfda..baebbfe0f 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -7,7 +7,7 @@ //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. -//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, PSD/PSB, Canon CR3). //! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). #![forbid(unsafe_code)] @@ -20,6 +20,7 @@ mod focus_read; mod gpx; mod iptc; pub mod mrw; +mod psd; pub mod tags; mod xmp; mod xmp_merge; @@ -282,17 +283,57 @@ impl Metadata { /// Precedence: EXIF, then IPTC fills gaps, then XMP overrides user fields and fills the rest. pub fn extract(bytes: &[u8]) -> Metadata { let e = embedded(bytes); - let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); + let exif = e.exif.as_deref().map(read_exif).unwrap_or_default(); + let xmp = e.xmp.as_deref().and_then(|xmp| parse_xmp(xmp).ok()).map(|xmp| xmp.metadata); + extract_embedded(e, exif, xmp) +} + +/// Read metadata while reporting malformed PSD/PSB containers, embedded EXIF, or embedded XMP instead of treating +/// them as empty. Other container formats retain [`extract`]'s best-effort behavior. +pub fn try_extract(bytes: &[u8]) -> Result { + let e = container::try_embedded(bytes)?; + let is_psd = bytes.starts_with(b"8BPS"); + let exif = match e.exif.as_deref() { + Some(exif) if is_psd => try_read_exif(exif).map_err(|_| ExtractError::MalformedPsd)?, + Some(exif) => read_exif(exif), + None => Metadata::default(), + }; + let xmp = match e.xmp.as_deref() { + Some(xmp) if is_psd => Some(parse_xmp(xmp).map_err(|_| ExtractError::MalformedPsd)?.metadata), + Some(xmp) => parse_xmp(xmp).ok().map(|xmp| xmp.metadata), + None => None, + }; + Ok(extract_embedded(e, exif, xmp)) +} + +fn extract_embedded(e: Embedded, mut m: Metadata, xmp: Option) -> Metadata { if let Some(i) = e.iptc.as_deref() { m.fill_missing(&parse_iptc(i)); } - if let Some(x) = e.xmp.as_deref().and_then(|x| parse_xmp(x).ok()) { - m.overlay_user_fields(&x.metadata); - m.fill_missing(&x.metadata); + if let Some(xmp) = xmp { + m.overlay_user_fields(&xmp); + m.fill_missing(&xmp); } m } +/// A container-level error reported by [`try_extract`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExtractError { + /// The PSD/PSB header, image-resource section, or embedded EXIF/XMP structure is malformed. + MalformedPsd, +} + +impl std::fmt::Display for ExtractError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MalformedPsd => f.write_str("malformed PSD/PSB header, image-resource section, or embedded EXIF/XMP structure"), + } + } +} + +impl std::error::Error for ExtractError {} + /// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the /// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`. pub fn lens_spec_name(spec: [f64; 4]) -> Option { @@ -351,6 +392,17 @@ mod tests { assert_eq!(m.exposure_display(), None); } + #[test] + fn try_extract_keeps_non_psd_exif_best_effort() { + let payload = b"Exif\0\0not a TIFF stream"; + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpeg.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + jpeg.extend_from_slice(payload); + jpeg.extend_from_slice(&[0xff, 0xd9]); + + assert!(matches!(try_extract(&jpeg), Ok(metadata) if metadata == Metadata::default())); + } + #[test] fn merge_rules() { let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() }; diff --git a/crates/meta/src/psd.rs b/crates/meta/src/psd.rs new file mode 100644 index 000000000..bda16d960 --- /dev/null +++ b/crates/meta/src/psd.rs @@ -0,0 +1,285 @@ +//! EXIF and XMP image resources in Photoshop PSD and PSB files. + +use crate::container::Embedded; + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, length: usize) -> Option<&'a [u8]> { + let end = self.offset.checked_add(length)?; + let bytes = self.bytes.get(self.offset..end)?; + self.offset = end; + Some(bytes) + } + + fn u16(&mut self) -> Option { + let bytes = self.take(2)?; + Some(u16::from_be_bytes([*bytes.first()?, *bytes.get(1)?])) + } + + fn u32(&mut self) -> Option { + let bytes = self.take(4)?; + Some(u32::from_be_bytes([*bytes.first()?, *bytes.get(1)?, *bytes.get(2)?, *bytes.get(3)?])) + } +} + +pub(crate) fn embedded(bytes: &[u8]) -> Embedded { + parse(bytes).unwrap_or_default() +} + +pub(crate) fn parse(bytes: &[u8]) -> Option { + let mut reader = Reader { bytes, offset: 0 }; + if reader.take(4)? != b"8BPS" || !matches!(reader.u16()?, 1 | 2) { + return None; + } + // The fixed header after the version is reserved bytes, channels, dimensions, depth and colour mode. + reader.take(6 + 2 + 4 + 4 + 2 + 2)?; + let colour_mode_length = usize::try_from(reader.u32()?).ok()?; + reader.take(colour_mode_length)?; + let resource_length = usize::try_from(reader.u32()?).ok()?; + let resources = reader.take(resource_length)?; + parse_resources(resources) +} + +fn parse_resources(bytes: &[u8]) -> Option { + let mut reader = Reader { bytes, offset: 0 }; + let (mut exif, mut xmp) = (None, None); + while reader.offset < bytes.len() { + if reader.take(4)? != b"8BIM" { + return None; + } + let id = reader.u16()?; + let name_length = usize::from(*reader.take(1)?.first()?); + let pascal_length = 1usize.checked_add(name_length)?.checked_add(1)? & !1; + reader.take(pascal_length.checked_sub(1)?)?; + let data_length = usize::try_from(reader.u32()?).ok()?; + let data = reader.take(data_length)?; + if !data_length.is_multiple_of(2) { + reader.take(1)?; + } + match id { + 1058 if exif.is_some() => return None, + 1058 => exif = Some(data), + 1060 if xmp.is_some() => return None, + 1060 => xmp = Some(std::str::from_utf8(data).ok()?), + _ => {} + } + } + + Some(Embedded { + exif: exif.map(crate::strip_exif_header).map(<[u8]>::to_vec), + xmp: xmp.map(|text| text.trim_end_matches('\0').to_string()), + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::embedded; + use crate::container::Embedded; + use crate::{DateTime, Gps, Metadata}; + + fn resource(id: u16, name: &[u8], data: &[u8]) -> Vec { + let mut out = b"8BIM".to_vec(); + out.extend_from_slice(&id.to_be_bytes()); + out.push(u8::try_from(name.len()).unwrap()); + out.extend_from_slice(name); + if !(name.len() + 1).is_multiple_of(2) { + out.push(0); + } + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(data); + if !data.len().is_multiple_of(2) { + out.push(0); + } + out + } + + fn image(version: u16, colour_data: &[u8], resources: &[u8]) -> Vec { + let mut out = b"8BPS".to_vec(); + out.extend_from_slice(&version.to_be_bytes()); + out.extend_from_slice(&[0; 6]); + out.extend_from_slice(&3u16.to_be_bytes()); // channels + out.extend_from_slice(&1u32.to_be_bytes()); // height + out.extend_from_slice(&1u32.to_be_bytes()); // width + out.extend_from_slice(&8u16.to_be_bytes()); // depth + out.extend_from_slice(&3u16.to_be_bytes()); // RGB + out.extend_from_slice(&u32::try_from(colour_data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(colour_data); + out.extend_from_slice(&u32::try_from(resources.len()).unwrap().to_be_bytes()); + out.extend_from_slice(resources); + out + } + + fn dated_metadata() -> Metadata { + Metadata { + capture_time: DateTime::parse_exif("2023:05:14 14:40:39"), + gps: Some(Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + ..Default::default() + } + } + + fn dated_image(version: u16) -> Vec { + let exif = crate::write_exif(&dated_metadata()); + image(version, &[], &resource(1058, b"", &exif)) + } + + #[test] + fn psd_capture_time_is_extracted() { + let expected = dated_metadata().capture_time; + let actual = crate::extract(&dated_image(1)); + assert_eq!(actual.capture_time, expected); + assert_eq!(actual.gps, dated_metadata().gps); + } + + #[test] + fn psb_capture_time_is_extracted() { + let expected = dated_metadata().capture_time; + let actual = crate::extract(&dated_image(2)); + assert_eq!(actual.capture_time, expected); + assert_eq!(actual.gps, dated_metadata().gps); + } + + #[test] + fn psd_extracts_xmp_without_exif() { + let metadata = Metadata { + capture_time: DateTime::parse_iso("2024-02-03T04:05:06Z"), + gps: Some(Gps { latitude: -33.9, longitude: 151.2, altitude: Some(25.0) }), + title: Some("Embedded XMP".into()), + ..Default::default() + }; + let xmp = crate::write_xmp(&metadata, None); + let file = image(1, &[], &resource(1060, b"", xmp.as_bytes())); + let actual = crate::extract(&file); + assert_eq!(actual.capture_time, metadata.capture_time); + assert_eq!(actual.gps, metadata.gps); + assert_eq!(actual.title, metadata.title); + } + + #[test] + fn psd_normalizes_the_optional_exif_resource_prefix() { + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(crate::write_exif(&dated_metadata())); + let file = image(1, &[], &resource(1058, b"", &exif)); + assert_eq!(crate::extract(&file).capture_time, dated_metadata().capture_time); + } + + #[test] + fn try_extract_rejects_structurally_malformed_psd_exif() { + for version in [1, 2] { + let file = image(version, &[], &resource(1058, b"", b"not a TIFF stream")); + assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd))); + } + } + + #[test] + fn try_extract_accepts_unknown_exif_tags_in_psd() { + let mut ifd0 = lightcraft_tiff::IfdBuilder::new(); + ifd0.set(0xc001, lightcraft_tiff::Value::Ascii("vendor metadata".into())); + let exif = lightcraft_tiff::TiffWriter::default().write(&[ifd0]).unwrap(); + let file = image(1, &[], &resource(1058, b"", &exif)); + assert!(crate::try_extract(&file).is_ok()); + } + + #[test] + fn try_extract_rejects_malformed_psd_xmp_but_extract_remains_best_effort() { + let file = image(1, &[], &resource(1060, b"", b" Vec { } /// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block -/// (JPEG, PNG, WebP, HEIF…). Empty when there is none. +/// (JPEG, PNG, WebP, HEIF, PSD/PSB…). Empty when there is none. pub fn file_tag_rows(bytes: &[u8]) -> Vec { if Tiff::sniff(bytes).is_some() && let Ok(t) = Tiff::parse(bytes) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 1fb789164..2dbf59482 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -69,7 +69,8 @@ pub struct XmpData { /// The opaque `lc:settings` JSON string, if present. pub lc_settings: Option, /// Every property found, keyed `prefix:name` (struct fields as `prefix:name/prefix:field`); arrays keep - /// item order, language alternatives put `x-default` first. + /// item order, language alternatives put `x-default` first. Foreign namespaces that reuse a recognized + /// prefix use `Q{hex-encoded namespace URI}local` instead, so they cannot impersonate known properties. pub properties: BTreeMap>, /// Every top-level property with its full structure (arrays of structs such as local /// corrections, which [`XmpData::properties`] flattens). @@ -150,26 +151,46 @@ impl Node { const MAX_DEPTH: usize = 64; const MAX_NODES: usize = 200_000; +// Expanding a long namespace URI on many short names must not amplify a small packet without bound. +pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024; -/// `prefix:local` with the prefix canonicalised by namespace URI (see [`NAMESPACES`]). -pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>]) -> String { - canonical(qname, scopes) +/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque. +pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result { + canonical(qname, scopes, opaque_budget) } -fn canonical(qname: &str, scopes: &[Vec<(String, String)>]) -> String { +fn canonical(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result { let (prefix, local) = qname.split_once(':').unwrap_or(("", qname)); if prefix == "xml" || prefix == "xmlns" { - return qname.to_string(); + return Ok(qname.to_string()); } let uri = scopes.iter().rev().flat_map(|s| s.iter().rev()).find(|(p, _)| p == prefix).map(|(_, u)| u.as_str()); - match uri { + Ok(match uri { Some(u) => match NAMESPACES.iter().find(|(_, nu)| *nu == u) { Some((p, _)) => format!("{p}:{local}"), + None if NAMESPACES.iter().any(|(p, _)| *p == prefix) => { + // A foreign `lc:settings` must never match our `lc:*` ownership rule (nor may a + // foreign `xmp:Rating` become a rating). Braces cannot occur in an XML QName; + // hex keeps URI slashes out of the flattened property's `/` path separators. + let size = + u.len().checked_mul(2).and_then(|n| n.checked_add(3)).and_then(|n| n.checked_add(local.len())).ok_or(XmpError::TooComplex)?; + *opaque_budget = opaque_budget.checked_sub(size).ok_or(XmpError::TooComplex)?; + let mut key = String::with_capacity(size); + key.push_str("Q{"); + for byte in u.bytes() { + for nibble in [byte >> 4, byte & 15] { + key.push(char::from(if nibble < 10 { b'0' + nibble } else { b'a' + (nibble - 10) })); + } + } + key.push('}'); + key.push_str(local); + key + } None if prefix.is_empty() => local.to_string(), None => qname.to_string(), }, None => qname.to_string(), - } + }) } fn entity(name: &str) -> Option { @@ -202,7 +223,7 @@ fn xml_safe(s: &str) -> String { } /// Unescape attribute values (predefined + numeric references; unknown references kept verbatim). -fn unescape_attr(s: &str) -> String { +pub(crate) fn unescape_attr(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(i) = rest.find('&') { @@ -229,6 +250,7 @@ fn parse_tree(s: &str) -> Result { let mut stack: Vec = vec![Node { name: "#root".into(), ..Default::default() }]; let mut scopes: Vec> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]]; let mut count = 0usize; + let mut opaque_budget = MAX_OPAQUE_NAME_BYTES; loop { let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?; match ev { @@ -251,8 +273,9 @@ fn parse_tree(s: &str) -> Result { } } scopes.push(scope); - let name = canonical(&String::from_utf8_lossy(e.name().as_ref()), &scopes); - let attrs = raw_attrs.into_iter().map(|(k, v)| (canonical(&k, &scopes), v)).collect(); + let name = canonical(&String::from_utf8_lossy(e.name().as_ref()), &scopes, &mut opaque_budget)?; + let attrs = + raw_attrs.into_iter().map(|(k, v)| Ok((canonical(&k, &scopes, &mut opaque_budget)?, v))).collect::>()?; let node = Node { name, attrs, ..Default::default() }; if matches!(ev, Event::Empty(_)) { scopes.pop(); @@ -767,6 +790,43 @@ mod tests { assert_eq!(d.lc_settings, None); } + #[test] + fn foreign_namespace_cannot_impersonate_known_metadata() { + for body in [ + r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, + r#">2foreign settings"#, + ] { + let packet = format!( + r#""# + ); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); + assert_eq!(d.lc_settings, None, "foreign settings: {packet}"); + assert!(!d.properties.contains_key("xmp:Rating")); + assert!(!d.values.contains_key("lc:settings")); + assert!(d.properties.values().any(|v| v == &["foreign settings"])); + assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); + } + } + + #[test] + fn foreign_namespace_names_are_distinct_and_scoped() { + let packet = r#" + + 1 + 2 + 4 + "#; + let d = parse_xmp(packet).unwrap(); + assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); + assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); + assert_eq!(d.values.len(), 3); + assert_eq!(d.properties["xmp:Rating"], vec!["4"]); + assert!(d.properties.values().any(|v| v == &["1"])); + assert!(d.properties.values().any(|v| v == &["2"])); + } + #[test] fn attribute_form_foreign_prefixes_and_structs() { let x = r#" diff --git a/crates/meta/src/xmp_merge.rs b/crates/meta/src/xmp_merge.rs index 022f962ec..e1e4bca5e 100644 --- a/crates/meta/src/xmp_merge.rs +++ b/crates/meta/src/xmp_merge.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeSet, HashSet}; use quick_xml::events::Event; -use crate::xmp::{XmpError, canonical_name, parse_xmp}; +use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, canonical_name, parse_xmp, unescape_attr}; /// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical /// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace. @@ -101,6 +101,7 @@ fn scan(s: &str) -> Result { let mut stack: Vec = Vec::new(); let mut scopes: Vec> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]]; let mut count = 0usize; + let mut opaque_budget = MAX_OPAQUE_NAME_BYTES; loop { let before = pos(&reader); let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?; @@ -124,18 +125,21 @@ fn scan(s: &str) -> Result { } let v = String::from_utf8_lossy(&a.value).into_owned(); if k == "xmlns" { - scope.push((String::new(), v.clone())); + scope.push((String::new(), unescape_attr(&v))); } else if let Some(p) = k.strip_prefix("xmlns:") { - scope.push((p.to_string(), v.clone())); + scope.push((p.to_string(), unescape_attr(&v))); } raw_attrs.push((k, v)); } scopes.push(scope); let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); - let name = canonical_name(&raw_name, &scopes); + let name = canonical_name(&raw_name, &scopes, &mut opaque_budget)?; let kind = match stack.last() { Some(Kind::Rdf) if name == "rdf:Description" => { - let attrs = raw_attrs.into_iter().map(|(raw, value)| Attr { name: canonical_name(&raw, &scopes), raw, value }).collect(); + let attrs = raw_attrs + .into_iter() + .map(|(raw, value)| Ok(Attr { name: canonical_name(&raw, &scopes, &mut opaque_budget)?, raw, value })) + .collect::>()?; out.descs.push(Desc { tag_start: before, tag_end: after, raw_name, empty, attrs, props: Vec::new(), end: after }); Kind::Desc(out.descs.len() - 1) } @@ -399,6 +403,47 @@ mod tests { assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2024-05-06T07:08:09".to_string()])); } + #[test] + fn preserves_foreign_properties_using_known_prefixes() { + for property in [r#"lc:Secret="keep-me"/>"#, r#">keep-me"#] { + let desc = format!(r#"{desc}"#); + let out = merge_xmp(&old, &fresh(4, &[]), RULES).unwrap(); + assert!(out.contains(&desc), "lost foreign description: {out}"); + assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(4)); + assert_eq!(merge_xmp(&out, &fresh(4, &[]), RULES).unwrap(), out); + } + } + + #[test] + fn namespace_aliases_remain_distinct_during_merge() { + // The alias URI includes a character reference: scanning and parsing must resolve it identically. + let foreign = r#""#; + let old = format!( + r#"{foreign} + + "# + ); + let out = merge_xmp(&old, &fresh(5, &[]), RULES).unwrap(); + assert!(out.contains(foreign), "lost foreign rating: {out}"); + assert!(!out.contains("a:Rating="), "genuine alias must be replaced: {out}"); + assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(5)); + assert_eq!(merge_xmp(&out, &fresh(5, &[]), RULES).unwrap(), out); + } + + #[test] + fn opaque_namespace_expansion_is_bounded() { + // A roughly 40 KiB packet must not expand the same long URI into every property name indefinitely. + let packet = format!( + r#"{}"#, + "a".repeat(1024), + "".repeat(5000) + ); + assert_eq!(parse_xmp(&packet).unwrap_err(), XmpError::TooComplex); + assert!(matches!(scan(&packet), Err(XmpError::TooComplex))); + } + #[test] fn many_attributes_scan_in_linear_time() { // quick-xml's duplicate-name check compares each attribute with all earlier ones: 200 000 attributes on diff --git a/crates/meta/tests/it/main.rs b/crates/meta/tests/it/main.rs new file mode 100644 index 000000000..f56fd47f0 --- /dev/null +++ b/crates/meta/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod robust; diff --git a/crates/meta/tests/robust.rs b/crates/meta/tests/it/robust.rs similarity index 100% rename from crates/meta/tests/robust.rs rename to crates/meta/tests/it/robust.rs diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index ba0298226..705b4026e 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -227,22 +227,47 @@ fn median_row(src: &[f32], out: &mut [f32], r: usize) { if n == 0 { return; } - let at = |i: isize| src[i.clamp(0, n as isize - 1) as usize]; + let at = |i: isize| total_key(src[i.clamp(0, n as isize - 1) as usize]); let r_ = r as isize; - let mut win: Vec = (-r_..=r_).map(at).collect(); - win.sort_unstable_by(f32::total_cmp); + let mut win: Vec = (-r_..=r_).map(at).collect(); + win.sort_unstable(); for (x, o) in out.iter_mut().enumerate() { - *o = win[r]; + *o = f32::from_bits(total_key_inv(win[r])); let (old, new) = (at(x as isize - r_), at(x as isize + r_ + 1)); - if old.to_bits() != new.to_bits() { - let i = win.binary_search_by(|v| v.total_cmp(&old)).unwrap_or_else(|i| i.min(win.len() - 1)); - win.remove(i); - let j = win.binary_search_by(|v| v.total_cmp(&new)).unwrap_or_else(|j| j); - win.insert(j, new); + if old != new { + // Ranks by counting the smaller keys in one branch-free pass the compiler vectorizes. + // Binary searches here mispredicted on most steps and, with `remove` + `insert`, cost + // 2.4 s per 24 MP at r = 9 on one core. + let (mut i, mut j) = (0u32, 0u32); + for &v in &win { + i += u32::from(v < old); + j += u32::from(v < new); + } + let (i, j) = (i as usize, j as usize); + // `old` sits at `i`; shift the keys between the two ranks by one over it + if j > i { + win.copy_within(i + 1..j, i); + win[j - 1] = new; + } else { + win.copy_within(j..i, j + 1); + win[j] = new; + } } } } +/// `v`'s bits as an `i32` that orders like [`f32::total_cmp`] (the same transform it applies), so +/// equal keys are equal bits. +fn total_key(v: f32) -> i32 { + let b = v.to_bits() as i32; + b ^ (((b >> 31) as u32) >> 1) as i32 +} + +/// The bits [`total_key`] came from (the transform is its own inverse: it keeps the sign bit). +fn total_key_inv(k: i32) -> u32 { + (k ^ (((k >> 31) as u32) >> 1) as i32) as u32 +} + fn transpose(p: &Plane) -> Plane { let (w, h) = (p.width, p.height); let mut t = Plane::new(h, w); diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index d7105acfc..ac244cb0d 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -488,3 +488,39 @@ fn level_degrees_sign_matches_crop_angle() { let deg = crate::upright::level_degrees(&src, &SourceInfo::default(), &DevelopSettings::default()).expect("lines found"); assert!((deg + 6.0).abs() < 0.5, "{deg}"); } + +/// A "horizon" of `deg` degrees: sky above, below it bands of `band` px (0: one edge only). +fn tilted_scene(deg: f32, band: f32) -> Rgb32f { + let (w, h) = (900usize, 600usize); + let t = deg.to_radians().tan(); + Rgb32f::from_fn(w, h, |x, y| { + let yh = h as f32 / 2.0 + (x as f32 - w as f32 / 2.0) * t; + if (y as f32) < yh { + [0.6, 0.7, 0.9] + } else if band > 0.0 && (((y as f32 - yh) / band) as i32) % 2 == 1 { + [0.1, 0.2, 0.3] + } else { + [0.05, 0.1, 0.2] + } + }) +} + +/// Auto straighten acts on several agreeing lines only: one edge (the frame of a window, a branch) +/// turned straight photos in a measurement on real ones, so it is left alone. +#[test] +fn auto_straighten_needs_more_than_one_line() { + let level = |src: &Rgb32f| crate::upright::level_degrees(src, &SourceInfo::default(), &DevelopSettings::default()); + assert_eq!(level(&tilted_scene(4.0, 0.0)), None, "one edge"); + let deg = level(&tilted_scene(4.0, 30.0)).expect("a horizon and the bands below it"); + assert!((deg + 4.0).abs() < 0.5, "{deg}"); +} + +/// Auto straighten turns by at most `STRAIGHTEN_MAX_DEGREES`: larger "levels" came from texture +/// (planks, a bird's wing) on photos that were straight. +#[test] +fn auto_straighten_leaves_steep_lines_alone() { + let level = |src: &Rgb32f| crate::upright::level_degrees(src, &SourceInfo::default(), &DevelopSettings::default()); + assert_eq!(level(&tilted_scene(20.0, 30.0)), None, "20° is not a horizon to level"); + let deg = level(&tilted_scene(8.0, 30.0)).expect("8° is"); + assert!((deg + 8.0).abs() < 0.5, "{deg}"); +} diff --git a/crates/pipeline/src/tests_tone.rs b/crates/pipeline/src/tests_tone.rs index a6ec70eb5..87e1b659a 100644 --- a/crates/pipeline/src/tests_tone.rs +++ b/crates/pipeline/src/tests_tone.rs @@ -187,6 +187,27 @@ fn median_matches_a_plain_per_pixel_median() { } } +#[test] +fn median_orders_special_values_and_ties_like_total_cmp() { + // the running window keeps keys in `f32::total_cmp` order: NaNs of either sign, signed zeros, + // infinities and long runs of equal values come out bit for bit as a sorted per-pixel window + // does, also on planes narrower or shorter than the window + let special = [f32::NAN, -f32::NAN, 0.0, -0.0, f32::INFINITY, f32::NEG_INFINITY, f32::MIN_POSITIVE, -1.0, 1.0, 1.0, 1.0]; + let bits = |p: &Plane| p.data.iter().map(|v| v.to_bits()).collect::>(); + for (w, h) in [(1, 1), (2, 3), (5, 1), (19, 7), (61, 9)] { + let p = Plane::from_fn(w, h, |x, y| special[(x * 7 + y * 3 + (x * y) % 5) % special.len()]); + for r in [1, 2, 9, 24] { + let med = |v: &mut Vec| { + v.sort_by(f32::total_cmp); + v[r] + }; + let rows = Plane::from_fn(w, h, |x, y| med(&mut (0..=2 * r).map(|k| p.get((x + k).saturating_sub(r).min(w - 1), y)).collect())); + let want = Plane::from_fn(w, h, |x, y| med(&mut (0..=2 * r).map(|k| rows.get(x, (y + k).saturating_sub(r).min(h - 1))).collect())); + assert_eq!(bits(&local::median_hv(&p, r)), bits(&want), "{w}x{h} r {r}"); + } + } +} + #[test] fn v1_keeps_the_base_it_always_had() { // V1's base is the edge-aware filter of log luminance itself, as before process V2: no median diff --git a/crates/pipeline/src/upright.rs b/crates/pipeline/src/upright.rs index 93dffad0c..58a61adbe 100644 --- a/crates/pipeline/src/upright.rs +++ b/crates/pipeline/src/upright.rs @@ -375,6 +375,12 @@ fn focal_from_vps(v: [f64; 3], h: [f64; 3]) -> Option { /// Pure rotation (degrees, clockwise +) that levels the dominant lines. fn level_angle(vert: &[Segment], horiz: &[Segment]) -> Option { + level_angle_within(vert, horiz, 90.0, 1) +} + +/// [`level_angle`] that only answers for at most `max` degrees, backed by at least `min_lines` +/// agreeing segments. +fn level_angle_within(vert: &[Segment], horiz: &[Segment], max: f64, min_lines: usize) -> Option { let dev = |s: &Segment, vertical: bool| { let (dx, dy) = (s.b.x - s.a.x, s.b.y - s.a.y); // signed deviation in degrees of the segment from the axis @@ -406,8 +412,9 @@ fn level_angle(vert: &[Segment], horiz: &[Segment]) -> Option { })?; // consensus: most of the line length must agree with the median within 2°, otherwise the "lines" are scene // texture (tree flanks, ridges) rather than a horizon or plumb lines - let agree: f64 = v.iter().filter(|(d, _)| (d - median.0).abs() <= 2.0).map(|x| x.1).sum(); - (agree >= 0.5 * total).then_some(-median.0) + let agreeing: Vec<_> = v.iter().filter(|(d, _)| (d - median.0).abs() <= 2.0).collect(); + let agree: f64 = agreeing.iter().map(|x| x.1).sum(); + (agree >= 0.5 * total && agreeing.len() >= min_lines && median.0.abs() <= max).then_some(-median.0) } fn rot_z_h(deg: f64) -> Homography { @@ -569,11 +576,27 @@ pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> A a } +/// The most Auto straighten turns a photo (degrees). Measured on 68 photos straightened by hand +/// in Lightroom Classic and 300 left as shot: none was turned by more than 6°, while a larger +/// "level" came from texture (wooden planks, a branch, a bird's wing) and turned photos that were +/// straight. +pub const STRAIGHTEN_MAX_DEGREES: f64 = 10.0; + +/// The fewest agreeing lines Auto straighten acts on: one line (the frame's edge, a branch) said +/// nothing about the horizon in that measurement. +pub const STRAIGHTEN_MIN_LINES: usize = 3; + /// The rotation (degrees, clockwise +) that levels `src`'s dominant horizon/plumb lines, if they agree -/// (the consensus rule of Upright Level). Used by the crop panel's Auto straighten. +/// (the consensus rule of Upright Level), within [`STRAIGHTEN_MAX_DEGREES`] and backed by at least +/// [`STRAIGHTEN_MIN_LINES`] lines. Used by the crop panel's Auto straighten. pub fn level_degrees(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option { let a = analyze_source(src, info, s); - level_angle(&a.vertical, &a.horizontal) + straighten_angle(&a) +} + +/// [`level_degrees`] of an analysis. +pub fn straighten_angle(a: &Analysis) -> Option { + level_angle_within(&a.vertical, &a.horizontal, STRAIGHTEN_MAX_DEGREES, STRAIGHTEN_MIN_LINES) } /// The automatic Upright correction for `mode` on `src` (identity for Off and Guided). diff --git a/crates/pipeline/tests/it/main.rs b/crates/pipeline/tests/it/main.rs new file mode 100644 index 000000000..95fc179bf --- /dev/null +++ b/crates/pipeline/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod preview_save; diff --git a/crates/pipeline/tests/preview_save.rs b/crates/pipeline/tests/it/preview_save.rs similarity index 100% rename from crates/pipeline/tests/preview_save.rs rename to crates/pipeline/tests/it/preview_save.rs diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 13f63a4a3..5d69bf2d4 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -373,7 +373,7 @@ fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) { /// low-pass columns (bands 8 and 5, whose low-pass columns the next level transforms again) at an odd /// width: an even synthesis width gains one column, which synthesis does not use. Every other band, /// including band 2 of the last level, is coded at its synthesis width. Measured on every C-RAW plane -/// that `tests/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816, +/// that `tests/it/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816, /// 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, R100, 250D, RP, R, 90D, M6 Mark II), where bands 5 /// and 8 gain a column in left tiles exactly when the rule says so, and single-tile `0x100` and /// `0x200` files, which never extend a band. In every one of those planes, each band's entropy stream diff --git a/crates/raw/tests/body_corpus.rs b/crates/raw/tests/it/body_corpus.rs similarity index 100% rename from crates/raw/tests/body_corpus.rs rename to crates/raw/tests/it/body_corpus.rs diff --git a/crates/raw/tests/canon_pentax_corpus.rs b/crates/raw/tests/it/canon_pentax_corpus.rs similarity index 100% rename from crates/raw/tests/canon_pentax_corpus.rs rename to crates/raw/tests/it/canon_pentax_corpus.rs diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/it/corpus.rs similarity index 99% rename from crates/raw/tests/corpus.rs rename to crates/raw/tests/it/corpus.rs index c9a48fb69..94d4647d6 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/it/corpus.rs @@ -4,7 +4,8 @@ //! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, generic CFA TIFF, //! older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times -//! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). +//! (`cargo test -p lightcraft-raw --release --test it corpus:: -- --nocapture --skip _corpus::`; the +//! `--skip` leaves out the other `*_corpus` modules). use lightcraft_raw::{RawError, RawFormat, decode, embedded_preview, probe, probe_info}; use std::path::{Path, PathBuf}; @@ -44,10 +45,12 @@ fn corpus_raw_decodes() { let tp = t0.elapsed().as_secs_f64() * 1e3; // DNG and generic CFA TIFF previews can be absent or uncompressed RGB; other vendor raws embed a JPEG, // except the Panasonic `.RAW` files of 2005–2007, Canon's explicit HEVC preview tracks and Mamiya MEF. + // A Hasselblad 3FR / FFF without a JPEG hands its 8-bit RGB image on as a baseline TIFF (`rgb_preview`). if let Some(p) = &preview { let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]); let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL "); - assert!(jpeg || jxl, "{name}: preview is neither a JPEG nor a JPEG XL file"); + let tiff = fmt == RawFormat::CfaTiff && p.starts_with(b"II*\0"); + assert!(jpeg || jxl || tiff, "{name}: preview is neither a JPEG, a JPEG XL nor (Hasselblad) a TIFF file"); } else { let hevc_preview = fmt == RawFormat::Cr3 && lightcraft_meta::cr3::parse_cr3(&bytes) @@ -946,7 +949,7 @@ fn corpus_samsung_srw() { #[test] fn corpus_fujifilm_compressed_samples() { use sha2::{Digest, Sha256}; - let checksums = include_str!("../../../docs/raf-corpus.sha256"); + let checksums = include_str!("../../../../docs/raf-corpus.sha256"); let cases: &[(&str, usize, usize, u64, u64)] = &[ ("raf-fuji-gfx100-3773.raf", 11808, 8754, 483668733090, 6453395759968151080), ("raf-fuji-gfx100-3775.raf", 11808, 8754, 120434414850, 6144091676930008551), @@ -1200,7 +1203,7 @@ fn sony_pins_match_the_corpus_manifest() { lines.sort(); let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); - let manifest = include_str!("../../../xtask/src/main.rs"); + let manifest = include_str!("../../../../xtask/src/main.rs"); for (name, sha) in SONY_SAMPLES { let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); diff --git a/crates/raw/tests/coverage_sweep_corpus.rs b/crates/raw/tests/it/coverage_sweep_corpus.rs similarity index 97% rename from crates/raw/tests/coverage_sweep_corpus.rs rename to crates/raw/tests/it/coverage_sweep_corpus.rs index 78f384651..5aabe5365 100644 --- a/crates/raw/tests/coverage_sweep_corpus.rs +++ b/crates/raw/tests/it/coverage_sweep_corpus.rs @@ -10,7 +10,7 @@ use lightcraft_raw::{RawData, decode, probe_info}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; -const RECORDS: &str = include_str!("../../../docs/raw/coverage-sweep.tsv"); +const RECORDS: &str = include_str!("../../../../docs/raw/coverage-sweep.tsv"); #[test] fn coverage_sweep_sensor_decodes_are_pinned() { diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/it/cr3_corpus.rs similarity index 100% rename from crates/raw/tests/cr3_corpus.rs rename to crates/raw/tests/it/cr3_corpus.rs diff --git a/crates/raw/tests/cr3_variants.rs b/crates/raw/tests/it/cr3_variants.rs similarity index 100% rename from crates/raw/tests/cr3_variants.rs rename to crates/raw/tests/it/cr3_variants.rs diff --git a/crates/raw/tests/crx_properties.rs b/crates/raw/tests/it/crx_properties.rs similarity index 100% rename from crates/raw/tests/crx_properties.rs rename to crates/raw/tests/it/crx_properties.rs diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/it/dng.rs similarity index 100% rename from crates/raw/tests/dng.rs rename to crates/raw/tests/it/dng.rs diff --git a/crates/raw/tests/lossy_dng.rs b/crates/raw/tests/it/lossy_dng.rs similarity index 100% rename from crates/raw/tests/lossy_dng.rs rename to crates/raw/tests/it/lossy_dng.rs diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs new file mode 100644 index 000000000..8834ed0f0 --- /dev/null +++ b/crates/raw/tests/it/main.rs @@ -0,0 +1,20 @@ +//! The integration tests, one module per file, linked into one test binary. +//! `tests/sr2_heap.rs` stays a binary of its own: it installs dhat as the global allocator. + +mod body_corpus; +mod canon_pentax_corpus; +mod corpus; +mod coverage_sweep_corpus; +mod cr3_corpus; +mod cr3_variants; +mod crx_properties; +mod dng; +mod lossy_dng; +mod nrw_be16; +mod olympus_samsung_corpus; +mod rendered_dng; +mod robust; +mod sony_leica_corpus; +mod sony_lens_inventory; +mod sparse_corpus; +mod vendor_robust; diff --git a/crates/raw/tests/nrw_be16.rs b/crates/raw/tests/it/nrw_be16.rs similarity index 100% rename from crates/raw/tests/nrw_be16.rs rename to crates/raw/tests/it/nrw_be16.rs diff --git a/crates/raw/tests/olympus_samsung_corpus.rs b/crates/raw/tests/it/olympus_samsung_corpus.rs similarity index 100% rename from crates/raw/tests/olympus_samsung_corpus.rs rename to crates/raw/tests/it/olympus_samsung_corpus.rs diff --git a/crates/raw/tests/rendered_dng.rs b/crates/raw/tests/it/rendered_dng.rs similarity index 100% rename from crates/raw/tests/rendered_dng.rs rename to crates/raw/tests/it/rendered_dng.rs diff --git a/crates/raw/tests/robust.proptest-regressions b/crates/raw/tests/it/robust.proptest-regressions similarity index 100% rename from crates/raw/tests/robust.proptest-regressions rename to crates/raw/tests/it/robust.proptest-regressions diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/it/robust.rs similarity index 100% rename from crates/raw/tests/robust.rs rename to crates/raw/tests/it/robust.rs diff --git a/crates/raw/tests/sony_leica_corpus.rs b/crates/raw/tests/it/sony_leica_corpus.rs similarity index 100% rename from crates/raw/tests/sony_leica_corpus.rs rename to crates/raw/tests/it/sony_leica_corpus.rs diff --git a/crates/raw/tests/sony_lens_inventory.rs b/crates/raw/tests/it/sony_lens_inventory.rs similarity index 100% rename from crates/raw/tests/sony_lens_inventory.rs rename to crates/raw/tests/it/sony_lens_inventory.rs diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/it/sparse_corpus.rs similarity index 99% rename from crates/raw/tests/sparse_corpus.rs rename to crates/raw/tests/it/sparse_corpus.rs index a602dd148..7f50d935d 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/it/sparse_corpus.rs @@ -5,7 +5,7 @@ //! For every file the plan reaches `Ready` on, probing the partly loaded buffer must give exactly //! what probing the whole file gives — with the skipped bytes zero and again with them 0xFF, so a //! reader that looked at skipped data would be caught. CR3 and DNG files must be described from a -//! small part of the file (`cargo test -p lightcraft-raw --release --test sparse_corpus -- --nocapture` +//! small part of the file (`cargo test -p lightcraft-raw --release --test it sparse_corpus:: -- --nocapture` //! prints what each format reads). use lightcraft_raw::sparse::{HeaderPlan, Ranges, header_plan, preview_plan}; diff --git a/crates/raw/tests/vendor_robust.rs b/crates/raw/tests/it/vendor_robust.rs similarity index 100% rename from crates/raw/tests/vendor_robust.rs rename to crates/raw/tests/it/vendor_robust.rs diff --git a/crates/segment/tests/it/main.rs b/crates/segment/tests/it/main.rs new file mode 100644 index 000000000..4dcb68591 --- /dev/null +++ b/crates/segment/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod reference; +mod remote; diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/it/reference.rs similarity index 100% rename from crates/segment/tests/reference.rs rename to crates/segment/tests/it/reference.rs diff --git a/crates/segment/tests/remote.rs b/crates/segment/tests/it/remote.rs similarity index 100% rename from crates/segment/tests/remote.rs rename to crates/segment/tests/it/remote.rs diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 7a2322245..cfdf7e65a 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -16,7 +16,7 @@ //! //! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file //! (`cargo xtask ci` sets it), appends one JSON line there: -//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`. +//! `{"need":"gpu","reason":"no GPU adapter","test":"equivalence::camera_tone","binary":"it","location":"crates/gpu/tests/it/equivalence.rs:22"}`. //! `cargo xtask ci` lists the skips by need and reason in its summary and in //! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records //! the part it could not run the same way. @@ -142,13 +142,13 @@ mod tests { #[test] fn an_entry_is_one_json_line_with_its_text_escaped() { - let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28); + let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "it", "crates\\raw\\tests\\it\\corpus.rs", 28); assert!(line.ends_with('\n')); assert_eq!(line.matches('\n').count(), 1, "{line}"); assert_eq!( line, "{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\ - \"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n" + \"binary\":\"it\",\"location\":\"crates/raw/tests/it/corpus.rs:28\"}\n" ); } diff --git a/crates/tiff/tests/it/main.rs b/crates/tiff/tests/it/main.rs new file mode 100644 index 000000000..e415ed537 --- /dev/null +++ b/crates/tiff/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod roundtrip; diff --git a/crates/tiff/tests/roundtrip.rs b/crates/tiff/tests/it/roundtrip.rs similarity index 100% rename from crates/tiff/tests/roundtrip.rs rename to crates/tiff/tests/it/roundtrip.rs diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 0d0f9a3a4..9d8300e8e 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -7,9 +7,9 @@ //! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means //! adding its file (and its `language_table!` entry); this script picks the file up by name. //! -//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog -//! does not compile, so a message missing from one language would otherwise go unnoticed until it -//! shows English. +//! A message a language lacks (or translates as an empty string) shows the English source: that +//! language has no arm for it, and a message no catalog has formats the English source through the +//! macro's last arm. `i18n::tests::translation_gaps_report` lists the gaps per language. //! //! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`, //! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from @@ -61,25 +61,14 @@ fn generate() -> Result> { let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; println!("cargo:rerun-if-changed=locales/{name}"); let variant = variant_for(code)?; - let messages: BTreeMap = + let mut messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?; + messages.retain(|_, translated| !translated.is_empty()); catalogs.push((code.to_string(), variant, messages)); } catalogs.sort_by(|a, b| a.0.cmp(&b.0)); - // Every catalog translates the same messages. let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect(); - let mut problems = Vec::new(); - for (code, _, messages) in &catalogs { - for key in &all { - if !messages.contains_key(*key) { - problems.push(format!("locales/{code}-formats.json is missing {key:?}")); - } - } - } - if !problems.is_empty() { - return Err(problems.join("\n").into()); - } let mut source = String::from(concat!( "/// Translate a format string, checked by `format!` in every language.\n", @@ -98,6 +87,9 @@ fn generate() -> Result> { source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); } source.push_str(concat!( + " ($english:literal $(, $($args:tt)*)?) => {\n", + " format!($english $(, $($args)*)?)\n", + " };\n", "}\n", "\n", "// `macro_rules!` is textual: the module re-export is what lets call sites say\n", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 67a305ddf..cfed522cf 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -445,6 +445,7 @@ "Download the SAM 3 Model?": "SAM-3-Modell herunterladen?", "Downloads": "Downloads", "Drag along the horizon; double-click for Auto": "Entlang des Horizonts ziehen; Doppelklick für Automatik", + "No horizon or vertical lines to level by": "Kein Horizont und keine senkrechten Linien zum Ausrichten gefunden", "Drag over an eye on the photo; the pupil inside is found automatically.": "Ziehe über ein Auge im Foto; die Pupille darin wird automatisch gefunden.", "Draw Guides": "Hilfslinien zeichnen", "Duplicate": "Duplizieren", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index a638c1482..759fe53d0 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2344,7 +2344,6 @@ "Loupe Info 1": "Info lente 1", "Loupe Info 2": "Info lente 2", "Use Defaults": "Usa predefiniti", - "Dimensions": "Dimensioni", "Camera & Lens": "Fotocamera e obiettivo", "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2." } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index acd513e98..c33ba0998 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2495,7 +2495,7 @@ mod tests { assert!(!row.cancellable, "{row:?}"); assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked"); let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default(); - assert_eq!(current, "Wird zu Apple Fotos hinzugefügt…"); + assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…")); // the row says so on the next frame (issue #345) h.step(); let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); @@ -4570,9 +4570,6 @@ mod tests { assert_eq!(crate::i18n::language(), locale); for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { let expected = locale.tr(key); - if locale != Locale::En { - assert_ne!(expected, key, "this locale must translate the tested message"); - } assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 158a00664..3614637a0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -51,10 +51,13 @@ macro_rules! language_table { static CATALOGS: OnceLock>> = OnceLock::new(); static EMPTY: OnceLock> = OnceLock::new(); // A malformed catalog logs and comes back empty: a broken translation degrades to - // English text, it never takes the app down. + // English text, it never takes the app down. An empty translation is a missing one. let parse = |catalog: Option<&'static str>| match catalog { - Some(json) => match serde_json::from_str(json) { - Ok(messages) => messages, + Some(json) => match serde_json::from_str::>(json) { + Ok(mut messages) => { + messages.retain(|_, text| !text.is_empty()); + messages + } Err(error) => { log::error!("Invalid message catalog for {}: {error}", self.code()); BTreeMap::new() @@ -522,112 +525,55 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_cat mod tests { use super::*; + /// Each language through its menu command and the control channel's locale tags, saved by its + /// code: built-in labels follow it, user values and ids don't, the window paints its text and + /// the bundled fonts have its letters. #[test] - fn czech_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::Cs.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("cs lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("cs lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn czech_switches_persists_and_formats_without_changing_user_values() { + fn languages_switch_persist_and_keep_user_values() { use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.czech", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Cs); - assert_eq!(Locale::Cs.name(), "Čeština"); - assert_eq!(Locale::Cs.script(), "Latn"); - assert_eq!(crate::menubar::checked(&app, "app.language.czech"), Some(true)); - assert_eq!(tr("File"), "Soubor"); - assert_eq!(tr("Develop"), "Vyvolat"); - assert_eq!(date_group_label("2026-09-20", false), "Neděle, 20. 9. 2026"); - assert_eq!(date_group_label("2026-09-20", true), "Neděle, 20."); - assert_eq!(date_group_label("2026-09", false), "9. 2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Neděle, 20. 9. 2026 16:04:05"); - for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Importováno fotografií: {n}")); - assert_eq!( - tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { "photo waiting" } else { "photos waiting" })), - format!("Čekající fotografie: {n}") - ); - } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Do „{name}“ přidáno fotografií: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Cs); - let ctx = egui::Context::default(); - for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Cs)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Cs); - } - let text = painted_text(&ctx, &mut app, Locale::Cs); - assert!(text.contains("Soubor") && text.contains("Expozice"), "{text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“".chars() { - assert!(fonts.has_glyph(&font, ch), "missing Czech glyph {ch}"); - } + let languages: [(Locale, &str, &[&str], &str); 8] = [ + (Locale::Cs, "czech", &["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"], "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“"), + (Locale::Id, "indonesian", &["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"], ""), + (Locale::Sv, "swedish", &["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"], "åäöÅÄÖé”’"), + (Locale::Uk, "ukrainian", &["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"], ""), + (Locale::Ka, "georgian", &["ka", "ka-GE", "ka_GE.UTF-8"], ""), + (Locale::It, "italian", &["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"], ""), + (Locale::Fi, "finnish", &["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"], ""), + (Locale::Vi, "vietnamese", &["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"], ""), + ]; + for (locale, command, tags, letters) in languages { + let command = format!("app.language.{command}"); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run(&command, serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, locale); + assert_eq!(language(), locale); + assert_eq!(crate::menubar::checked(&app, &command), Some(true)); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), locale.tr("Warm Glow")); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(&format!(r#""language":"{}""#, locale.code())), "{saved}"); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, locale); + let ctx = egui::Context::default(); + for tag in tags { + assert_eq!(Locale::parse_tag(tag), Some(locale), "{tag}"); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), locale); } - }); - set_language(Locale::En); - } - - #[test] - fn indonesian_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.indonesian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Id); - assert_eq!(Locale::Id.name(), "Bahasa Indonesia"); - assert_eq!(Locale::Id.script(), "Latn"); - assert_eq!(crate::menubar::checked(&app, "app.language.indonesian"), Some(true)); - assert_eq!(tr("Exposure"), "Eksposur"); - assert_eq!(tr("My Photos"), "Foto saya"); - assert_eq!(date_group_label("2026-09-20", false), "Minggu, 20/09/2026"); - assert_eq!(date_group_label("2026-09-20", true), "Minggu, 20"); - assert_eq!(date_group_label("2026-09", false), "09/2026"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Minggu, 20/09/2026 16:04:05"); - // Indonesian keeps a noun unchanged after a count ("1 foto", "22 foto"), so the English - // plural suffix is consumed and never shows. - for n in [0, 1, 2, 5, 11, 22] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("{n} foto diimpor")); - } - let name = String::from("Color 1234.56 {title} 日本語"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("22 foto ditambahkan ke “{name}”")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"id""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Id); - let ctx = egui::Context::default(); - for tag in ["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Id)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Id); + let text = painted_text(&ctx, &mut app, locale); + // (not the menu titles: a language whose titles don't fit shows one Menu button) + assert!(text.contains(locale.tr("Exposure")), "{locale:?}: {text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in letters.chars() { + assert!(fonts.has_glyph(&font, ch), "{locale:?}: missing glyph {ch}"); + } + } + }); } set_language(Locale::En); } @@ -642,68 +588,64 @@ mod tests { } #[test] - fn swedish_switches_persists_and_formats_without_changing_user_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.swedish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Sv); - assert_eq!(Locale::Sv.name(), "Svenska"); - assert_eq!(Locale::Sv.script(), "Latn"); - assert_eq!(crate::menubar::checked(&app, "app.language.swedish"), Some(true)); - assert_eq!(tr("File"), "Fil"); - assert_eq!(tr("Develop"), "Framkalla"); - assert_eq!(tr("Exposure"), "Exponering"); - assert_eq!(date_group_label("2026", false), "2026"); - assert!(date_group_label("2026-09-20", false).starts_with("Söndag 20 "), "{}", date_group_label("2026-09-20", false)); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains("\"sv\""), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Sv); - let ctx = egui::Context::default(); - for tag in ["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Sv), "{tag}"); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Sv); - } - let text = painted_text(&ctx, &mut app, Locale::Sv); - assert!(text.contains("Fil") && text.contains("Exponering"), "{text}"); - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "åäöÅÄÖé”’".chars() { - assert!(fonts.has_glyph(&font, ch), "missing Swedish glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn every_language_has_a_catalog_covering_the_source() { + fn every_language_has_a_name_a_script_and_its_own_code() { assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); for language in Locale::ALL { assert!(!language.name().is_empty(), "{language:?} has a name"); assert!(!language.script().is_empty(), "{language:?} has a script"); assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); - if *language == Locale::En { - continue; - } - let messages = language.catalog(); - assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code()); - for (key, value) in messages { - assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}"); - } } } - /// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover - /// different sets of messages (a message a language lacks shows in English): the gaps are - /// reported, not failed, so a translation can grow at its own pace. + /// Every catalog file is a JSON object of strings, each key once: a malformed file would load as + /// no catalog, and a repeated key silently drops one of its translations. #[test] - fn catalogs_agree_on_placeholders_and_report_gaps() { + fn catalogs_load() { + let files = catalog_files(); + assert_eq!(files.len(), 2 * (Locale::ALL.len() - 1), "a plain and a formats catalog per translation"); + for path in files { + let repeated = repeated_keys(&path); + assert_eq!(repeated, Ok(Vec::new()), "{}", path.display()); + } + } + + /// `locales/*.json`, sorted. + fn catalog_files() -> Vec { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + let mut files: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect(); + files.sort(); + files + } + + /// The keys a catalog file names more than once, or why it isn't an object of strings. + fn repeated_keys(path: &std::path::Path) -> Result, String> { + struct Repeated; + impl<'de> serde::de::Visitor<'de> for Repeated { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an object of strings") + } + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let (mut seen, mut repeated) = (std::collections::BTreeSet::new(), Vec::new()); + while let Some((key, _)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + repeated.push(key); + } + } + Ok(repeated) + } + } + let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; + serde::Deserializer::deserialize_map(&mut serde_json::Deserializer::from_str(&text), Repeated).map_err(|error| error.to_string()) + } + + /// A translation carries the placeholders of the message it translates. + #[test] + fn catalogs_agree_on_placeholders() { let fields = |text: &str| { let mut out: Vec = Vec::new(); let mut rest = text; @@ -715,17 +657,10 @@ mod tests { out.sort(); out }; - let translated: Vec = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect(); - let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect(); - for language in &translated { - let catalog = language.catalog(); - for (key, value) in catalog { + for language in Locale::ALL { + for (key, value) in language.catalog() { assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); } - let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !missing.is_empty() { - eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len()); - } } } @@ -793,18 +728,16 @@ mod tests { #[test] fn switching_language_translates_and_restores() { - set_language(Locale::ZhHans); - assert_eq!(tr("Exposure"), "曝光"); - assert_eq!(tr("Settings"), "设置"); - // Untranslated and data-like text passes through untouched. - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - assert_eq!(tr("A string no catalog has"), "A string no catalog has"); - set_language(Locale::Ja); - assert_eq!(tr("Exposure"), "露出"); - set_language(Locale::PtBr); - assert_eq!(tr("Exposure"), "Exposição"); - assert_eq!(tr("Settings"), "Configurações"); + for language in Locale::ALL { + set_language(*language); + for source in ["Exposure", "Settings"] { + assert_eq!(tr(source), language.catalog().get(source).map_or(source, String::as_str), "{language:?}"); + } + // Untranslated and data-like text passes through untouched. + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(tr("A string no catalog has"), "A string no catalog has"); + } set_language(Locale::En); assert_eq!(tr("Exposure"), "Exposure"); } @@ -817,16 +750,10 @@ mod tests { set_language(Locale::En); assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); - set_language(Locale::ZhHans); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片"); - set_language(Locale::Ja); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); - set_language(Locale::Fr); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 photos importée(s)"); - assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 photo importée(s)"); - // Format specs survive translation: precision, sign, and values a language reorders. + // A message no catalog has formats its English source. + assert_eq!(tr_format!("{} of {} (no catalog has this)", 1, 2), "1 of 2 (no catalog has this)"); + // Format specs survive translation: precision, sign, and values a language reorders; user + // text (an album name with braces and CJK) and counts are never dropped. for language in Locale::ALL { set_language(*language); let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); @@ -838,6 +765,17 @@ mod tests { let reading = tr_format!("Reading photos… {} of {}", 3, 10); let (three, ten) = (reading.find('3'), reading.find("10")); assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); + let name = "Color 1234.56 {title} 日本語"; + let added = tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22); + assert!(added.contains(name) && added.contains("22"), "{language:?}: {added}"); + for n in [0, 1, 2, 5, 22] { + let imported = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); + assert!(imported.contains(&n.to_string()), "{language:?}: {imported}"); + } + // The trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + let denoising = tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4); + assert!(denoising.contains("IMG.CR3") && denoising.contains('5'), "{language:?}: {denoising}"); } set_language(Locale::En); assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); @@ -973,48 +911,31 @@ mod tests { assert_eq!(restored.language, Locale::ZhHans); } - #[test] - fn catalogs_contain_core_workflows() { - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { - let value = language.tr(key); - assert!(!value.is_empty() && value != key, "{language:?}: {key}"); - } - } - } - - /// Smart-album choice values read as words in every language (English "Public Domain", German - /// "Gemeinfrei"), never as rule ids like `publicDomain`; an id the field doesn't know shows as - /// written. Unlike other labels these must be translated: an untranslated one would be English - /// in the middle of a translated rule. + /// Smart-album choice values read as words in every language (English "Public Domain"), never as + /// rule ids like `publicDomain`; an id the field doesn't know shows as written. A yes/no value + /// reads Yes / No, or the language's contextual entry for it where it has one. #[test] fn choice_values_read_as_words_in_every_language() { - use lightcraft_catalog::rules::{FIELDS, Kind}; + use lightcraft_catalog::rules::{FIELDS, Kind, bool_label}; assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); - assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "publicDomain"), "Gemeinfrei"); - assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "someday"), "someday"); - assert_eq!(choice_text_in(Locale::ZhHans, "treatment", "color"), "彩色", "in colour, not the colour noun"); - assert_eq!(choice_text_in(Locale::Ru, "treatment", "color"), "Цветное"); - assert_eq!(choice_text_in(Locale::Es, "label", "none"), "Sin etiqueta"); - // yes/no fields read Yes / No, never true / false assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); - assert_eq!(bool_text_in(Locale::De, false), "Nein"); - // Japanese answers はい / いいえ don't read as a rule's value: its own wording, あり / なし, - // through a context, while the shared Yes / No keep their meaning everywhere else - assert_eq!((bool_text_in(Locale::Ja, true), bool_text_in(Locale::Ja, false)), ("あり", "なし")); - assert_eq!((Locale::Ja.tr("Yes"), Locale::Ja.tr("No")), ("はい", "いいえ")); - assert_eq!(tr_ctx_in(Locale::Ja, "nope", "Yes"), "はい", "no contextual entry: the plain message"); assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); - // and the yes/no fields are named by plain nouns, so "編集 … なし" doesn't read "edits: yes … none" - for (field, label, kind) in FIELDS { - if *kind != Kind::Bool { - continue; + for language in Locale::ALL { + assert_eq!(choice_text_in(*language, "copyrightStatus", "someday"), "someday"); + assert_eq!(tr_ctx_in(*language, "nope", "Yes"), language.tr("Yes"), "no contextual entry: the plain message"); + for b in [true, false] { + let contextual = language.catalog().get(&format!("{BOOL_CONTEXT}|{}", bool_label(b))); + assert_eq!(bool_text_in(*language, b), contextual.map_or(language.tr(bool_label(b)), String::as_str)); + } + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, _) in *choices { + assert_ne!(choice_text_in(*language, field, id), *id, "{}: {field} {id}", language.code()); + } } - let ja = Locale::Ja.catalog().get(*label).unwrap_or_else(|| panic!("ja lacks the {field} label {label:?}")); - assert!(!ja.contains("あり") && !ja.contains("なし"), "{field}: {ja}"); } - // a contextual entry names a real value (a typo would silently fall back to はい / いいえ) - let values = [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)]; + // a contextual entry names a real value (a typo would silently fall back to the plain Yes / No) + let values = [bool_label(true), bool_label(false)]; for language in Locale::ALL { for key in language.catalog().keys() { if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { @@ -1022,28 +943,10 @@ mod tests { } } } - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - for label in [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)] { - assert!(language.catalog().contains_key(label), "{} lacks {label:?}", language.code()); - } - } - for language in Locale::ALL { - for (field, _, kind) in FIELDS { - let Kind::Choice(choices) = kind else { continue }; - for (id, label) in *choices { - let text = choice_text_in(*language, field, id); - assert_ne!(text, *id, "{}: {field} {id}", language.code()); - if *language != Locale::En { - assert!(language.catalog().contains_key(*label), "{} lacks {label:?}", language.code()); - } - } - } - } } - /// A rule's problem reads as the field's name and the issue, in the editor's language ("Titel: - /// braucht einen Suchbegriff …"); the sidebar's list puts the rule's position first. Every issue - /// is translated in every language: an English one would stand out in a translated editor. + /// A rule's problem reads as the field's name and the issue, in the editor's language, joined by + /// a full-width colon in Japanese and Chinese; the sidebar's list puts the rule's position first. #[test] fn problems_read_in_every_language() { use lightcraft_catalog::rules::Issue; @@ -1053,151 +956,19 @@ mod tests { }; let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); - assert_eq!(problem_text_in(Locale::De, &p[0]), "Titel: braucht einen Suchbegriff (oder „ist leer“)"); assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); - // Japanese and Chinese join with a full-width colon, as their catalogs do - assert_eq!(problem_text_in(Locale::Ja, &p[0]), "タイトル:検索する語句が必要です(または「が空」を使用)"); + for language in Locale::ALL { + let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; + let text = format!("{}{colon}{}", language.tr("Title"), language.tr(p[0].issue.text())); + assert_eq!(problem_text_in(*language, &p[0]), text); + assert_eq!(problem_line_in(*language, &p[0]), format!("#2 {text}")); + } // a problem with the album filter itself has no rule position to show let filter_loop = lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); let g = problems(serde_json::json!([{"group": {"rules": []}}])); assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); - // the issues, the operators their hints name and the album picker's words, in every language - for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - // the field menu: every field and every group, so it never mixes languages - for label in lightcraft_catalog::rules::FIELDS.iter().map(|f| f.1).chain(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|g| g.0)) { - assert!(language.catalog().contains_key(label), "{} lacks the rule label {label:?}", language.code()); - } - for word in [ - "Choose an album…", - "No albums yet", - "Search albums", - "No albums match", - "This is the album you're editing", - "It tests this album, so testing it back would loop", - "Fix the marked rules to save this album.", - ] { - assert!(language.catalog().contains_key(word), "{} lacks {word:?}", language.code()); - } - for issue in Issue::ALL { - assert!(language.catalog().contains_key(issue.text()), "{} lacks {:?}", language.code(), issue.text()); - } - for (_, _, kind) in lightcraft_catalog::rules::FIELDS { - for (_, op) in lightcraft_catalog::rules::ops_for(*kind) { - assert!(language.catalog().contains_key(*op), "{} lacks the operator {op:?}", language.code()); - } - } - } - } - - /// The date picker's own words (its tooltip, the weekday headers) are in every language, as the - /// month and day labels it borrows from the date headings are. - #[test] - fn date_picker_words_are_translated() { - for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { - assert!(language.catalog().contains_key(*word), "{} lacks {word:?}", language.code()); - } - } - } - - /// Ukrainian's coverage of every catalog message and display label. Gaps are reported, not failed - /// (like `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR that adds a label doesn't - /// have to ship its Ukrainian text, which shows in English until the translation catches up. - #[test] - fn ukrainian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::Uk.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("uk lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("uk lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn ukrainian_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.ukrainian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Uk); - assert_eq!(crate::menubar::checked(&app, "app.language.ukrainian"), Some(true)); - assert_eq!(tr("Light"), "Світло"); - assert_eq!(tr("Landscape"), "Пейзаж"); - assert_eq!(tr("Home"), "Домашня тека"); - assert_eq!(date_group_label("2026-09-20", false), "Неділя, 20.09.2026"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Неділя, 20.09.2026 16:04:05"); - // Count-neutral labels work for every Ukrainian integer category, with no English suffix. - for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Імпортовано фото: {n}")); - assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Файлів: {n}, тек: {n}")); - let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); - assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), format!("Кешовані зображення: {n}")); - } - // This trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - assert_eq!( - tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), - "Усунення шуму IMG.CR3: ділянки — 1 із 4 · очікують: 5" - ); - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("До «{name}» додано фото: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Тепле сяйво"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"uk""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Uk); - let ctx = egui::Context::default(); - for code in ["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Uk)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Uk); - } - set_language(Locale::En); - } - - #[test] - fn georgian_switches_formats_counts_and_dates() { - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.georgian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Ka); - assert_eq!(crate::menubar::checked(&app, "app.language.georgian"), Some(true)); - assert_eq!(tr("Light"), "სინათლე"); - assert_eq!(date_group_label("2026-09-20", false), "კვირა, 20.09.2026"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - for n in [0, 1, 2, 5, 21] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("იმპორტირებულია ფოტო: {n}")); - } - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Trip", n = 3), "„Trip“-ს დაემატა ფოტო: 3"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"ka""#)); - for code in ["ka", "ka-GE", "ka_GE.UTF-8"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Ka)); - } - // no Mtavruli: Georgian UI text is written in lowercase Mkhedruli - for text in Locale::Ka.catalog().values() { - assert!(!text.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "{text}"); - } - set_language(Locale::En); } #[test] @@ -1206,13 +977,12 @@ mod tests { let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains("Мої фото") && text.contains("Усі фото"), "{text}"); - for (command, title) in [("app.about", "Про LightCraft"), ("app.shortcuts", "Клавіатурні скорочення"), ("app.settings", "Налаштування")] - { + assert!(text.contains(Locale::Uk.tr("My Photos")) && text.contains(Locale::Uk.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::Uk.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1228,94 +998,18 @@ mod tests { set_language(Locale::En); } - /// Italian's coverage of every catalog message and display label, reported like Ukrainian's. - #[test] - fn italian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::It.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("it lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("it lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn italian_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.italian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::It); - assert_eq!(crate::menubar::checked(&app, "app.language.italian"), Some(true)); - assert_eq!(tr("Light"), "Luce"); - assert_eq!(tr("Landscape"), "Paesaggio"); - assert_eq!(tr("Home"), "Cartella personale"); - assert_eq!(date_group_label("2026-09-20", false), "Domenica 20/09/2026"); - assert_eq!(date_group_label("2026-09", false), "09/2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Domenica 20/09/2026 16:04:05"); - // Italian agrees adjectives with the count ("1 foto importata", "2 foto importate"), so the - // count follows a label instead, which reads right for every number; the English plural - // suffix is consumed and never shows. - for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Foto importate: {n}")); - assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("{n} file, cartelle: {n}")); - let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); - let expected = if n == 1 { "1 immagine in cache".to_string() } else { format!("{n} immagini in cache") }; - assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); - } - // This trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - assert_eq!( - tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), - "Riduzione disturbo di IMG.CR3: 1 di 4 riquadri · in attesa: 5" - ); - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Foto aggiunte a “{name}”: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Bagliore caldo"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"it""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::It); - let ctx = egui::Context::default(); - for code in ["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::It)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::It); - } - set_language(Locale::En); - } - #[test] fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { let ctx = fonts_ctx(&[]); let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains("Le mie foto") && text.contains("Tutte le foto"), "{text}"); - for (command, title) in - [("app.about", "Informazioni su LightCraft"), ("app.shortcuts", "Scorciatoie da tastiera"), ("app.settings", "Impostazioni")] - { + assert!(text.contains(Locale::It.tr("My Photos")) && text.contains(Locale::It.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::It); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::It.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1331,95 +1025,18 @@ mod tests { set_language(Locale::En); } - /// Finnish's coverage of every catalog message and display label, reported like Italian's. - #[test] - fn finnish_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::Fi.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("fi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("fi lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn finnish_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.finnish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Fi); - assert_eq!(crate::menubar::checked(&app, "app.language.finnish"), Some(true)); - assert_eq!(tr("Light"), "Valo"); - assert_eq!(tr("Landscape"), "Maisema"); - assert_eq!(tr("Home"), "Kotikansio"); - // Finnish dates have no leading zeros and a lower-case weekday; times are 24-hour with colons. - assert_eq!(date_group_label("2026-09-20", false), "sunnuntai 20.9.2026"); - assert_eq!(date_group_label("2026-09-20", true), "sunnuntai 20."); - assert_eq!(date_group_label("2026-09", false), "9/2026"); - assert_eq!(date_group_label("2026-09", true), "9"); - assert_eq!(date_group_label("2026", false), "2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "sunnuntai 20.9.2026 16:04:05"); - // A noun after a numeral is in the partitive singular (1 kuva, 2 kuvaa), which the English - // plural suffix cannot choose, so counts follow a label; the suffix is consumed and never shows. - for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Tuotuja kuvia: {n}")); - assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Tiedostoja: {n}, kansioita: {n}")); - let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); - let expected = if n == 1 { "1 välimuistissa oleva kuva".to_string() } else { format!("{n} välimuistissa olevaa kuvaa") }; - assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); - } - // This trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - assert_eq!( - tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), - "Kohinanpoisto IMG.CR3: 1 / 4 ruutua · odottaa: 5" - ); - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Lisättyjä kuvia kohteeseen ”{name}”: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Lämmin hehku"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"fi""#)); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Fi); - let ctx = egui::Context::default(); - for code in ["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Fi)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Fi); - } - set_language(Locale::En); - } - #[test] fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() { let ctx = fonts_ctx(&[]); let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains("Omat kuvat") && text.contains("Kaikki kuvat"), "{text}"); - for (command, title) in [("app.about", "Tietoja LightCraftista"), ("app.shortcuts", "Pikanäppäimet"), ("app.settings", "Asetukset")] { + assert!(text.contains(Locale::Fi.tr("My Photos")) && text.contains(Locale::Fi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::Fi); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::Fi.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1435,66 +1052,14 @@ mod tests { set_language(Locale::En); } - /// Every command, control, rule and rename label, and every line of What's New, is a message a - /// catalog can translate. What a language lacks is reported, not failed (like - /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every - /// language, and the translations catch up at their own pace. #[test] - fn display_label_gaps_are_reported() { - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let ui_labels = labels.len(); - for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) { - if line.is_empty() || line.starts_with("# ") { - continue; - } - let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line); - labels.push(label); - } - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - let catalog = language.catalog(); - let missing: Vec<&&str> = labels[..ui_labels].iter().filter(|label| !catalog.contains_key(**label)).collect(); - let notes = labels[ui_labels..].iter().filter(|label| !catalog.contains_key(**label)).count(); - if !missing.is_empty() || notes > 0 { - eprintln!( - "{} lacks {} display label(s) (shown in English): {missing:?}; and {notes} What's New line(s)", - language.code(), - missing.len() - ); - } - } - // the German catalog, which set out to cover them all, still covers every other catalog - let german = Locale::De.catalog(); - let gaps: Vec<&String> = - Locale::ALL.iter().flat_map(|language| language.catalog().keys()).filter(|key| !german.contains_key(key.as_str())).collect(); - if !gaps.is_empty() { - eprintln!("de lacks {} message(s) another catalog has: {gaps:?}", gaps.len()); - } - } - - #[test] - fn german_switches_via_menu_and_control_persists_and_formats_dates() { + fn german_switches_via_menu_and_control_and_persists() { use crate::control::{ControlRequest, Outcome}; let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.run("app.language.german", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Locale::De); - assert_eq!(tr("File"), "Datei"); + assert_eq!(language(), Locale::De); assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); - assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026"); - assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20."); - assert_eq!(date_group_label("2026-09", false), "09/2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05"); - assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert"); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt"); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains(r#""language":"de""#)); assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::De); @@ -1506,7 +1071,7 @@ mod tests { assert_eq!(language(), Locale::De); } assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz"); + assert_eq!(builtin_label("Warm Glow", true), Locale::De.tr("Warm Glow")); assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); let rules = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, @@ -1516,7 +1081,9 @@ mod tests { catalog .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) .unwrap(); - assert_eq!(rules_label(&rules, &catalog), "Stichwörter enthält Color"); + // "Color" is a message too, but a rule's free-text value stays as written + let keywords = lightcraft_catalog::rules::field_label("keywords").unwrap_or("keywords"); + assert_eq!(rules_label(&rules, &catalog), format!("{} {} Color", Locale::De.tr(keywords), Locale::De.tr("contains"))); let album = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], @@ -1530,21 +1097,28 @@ mod tests { value: serde_json::json!("publicDomain"), }], }; - assert!(rules_label(&rules, &catalog).ends_with(" Gemeinfrei"), "{}", rules_label(&rules, &catalog)); + let public_domain = format!(" {}", choice_text_in(Locale::De, "copyrightStatus", "publicDomain")); + assert!(rules_label(&rules, &catalog).ends_with(&public_domain), "{}", rules_label(&rules, &catalog)); for value in [serde_json::json!(false), serde_json::json!("false")] { let rules = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], }; - assert!(rules_label(&rules, &catalog).ends_with(" Nein"), "{}", rules_label(&rules, &catalog)); + let no = format!(" {}", bool_text_in(Locale::De, false)); + assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); } let filter = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], ..Default::default() }; - assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau"); + let blue = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Blue); + let red = color_label(&app.session.catalog, lightcraft_catalog::ColorLabel::Red); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains(&red) && label.contains(&blue), "{label}"); + // a label set's own name is the user's app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); - assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau"); + let label = filter_label(&filter, &app.session.catalog); + assert!(label.contains("Color") && !label.contains(&red) && label.contains(&blue), "{label}"); set_language(Locale::En); } @@ -1554,15 +1128,15 @@ mod tests { let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}"); + assert!(text.contains(Locale::De.tr("My Photos")) && text.contains(Locale::De.tr("All Photos")), "{text}"); app.ui.view = crate::state::ViewMode::People; let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains("Benannte Personen"), "{text}"); - for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] { + assert!(text.contains(Locale::De.tr("Named People")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::De.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -1634,10 +1208,6 @@ mod tests { assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); } - set_language(Locale::ZhHans); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色"); - set_language(Locale::Ja); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); set_language(Locale::En); } @@ -1689,12 +1259,6 @@ mod tests { assert_eq!(app.font_language, *language, "the fonts follow the language"); assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); } - let text = painted_text(&ctx, &mut app, Locale::Ja); - assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); - let text = painted_text(&ctx, &mut app, Locale::ZhHans); - assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); - let text = painted_text(&ctx, &mut app, Locale::PtBr); - assert!(text.contains("Minhas fotos") && text.contains("Todas as fotos"), "{text}"); set_language(Locale::En); } @@ -1765,7 +1329,7 @@ mod tests { } /// Traditional Chinese through the menu command and the control channel, persisted, with - /// localised headings and dates; ids and user text are untouched. + /// localised headings; ids and user text are untouched. #[test] fn traditional_chinese_switches_through_menu_and_control_and_persists() { use crate::control::{ControlRequest, Outcome}; @@ -1773,15 +1337,9 @@ mod tests { app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Locale::ZhHant); // Applied at once, not on the next frame. - assert_eq!(tr("File"), "檔案"); - assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片"); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); - assert_eq!(date_group_label("2026-09-20", true), "20日 星期日"); - assert_eq!(date_group_label("2026-09", false), "2026年9月"); - assert_eq!(date_group_label("2026-09", true), "9月"); - assert_eq!(date_group_label("2026", false), "2026年"); - assert_eq!(date_group_label("", false), "未知日期"); - assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05"); + assert_eq!(language(), Locale::ZhHant); + assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), Locale::ZhHant.tr("All Photos")); + assert_eq!(date_group_label("", false), Locale::ZhHant.tr("Unknown Date")); assert_eq!(display_time("a-user-value"), "a-user-value"); assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); @@ -1789,9 +1347,7 @@ mod tests { for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); } - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩"); - assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), Locale::ZhHant.tr("Color")); let saved = serde_json::to_string(&app.ui).unwrap(); assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant); @@ -1815,7 +1371,8 @@ mod tests { assert_eq!(chinese_ids, ids(&app)); } - /// Dates follow every language's own patterns; English keeps the catalog's wording. + /// Dates follow every language's own patterns (English keeps the catalog's wording); a key that + /// is not a date never panics and never shows a raw pattern. #[test] fn date_headings_in_every_language() { set_language(Locale::En); @@ -1823,14 +1380,16 @@ mod tests { assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); assert_eq!(date_group_label("2026-09", true), "September"); assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); - set_language(Locale::Ja); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)"); - assert_eq!(date_group_label("2026-09", true), "9月"); - set_language(Locale::ZhHans); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); for language in Locale::ALL { set_language(*language); - // A key that is not a date never panics and never shows a raw pattern. + if *language != Locale::En { + let day = date_group_label("2026-09-20", false); + assert!(day.contains("20") && day.contains("2026") && day.contains(tr("Sunday")), "{language:?}: {day}"); + assert!(date_group_label("2026-09-20", true).contains("20"), "{language:?}"); + assert!(date_group_label("2026-09", false).contains("2026"), "{language:?}"); + assert!(date_group_label("2026", false).contains("2026"), "{language:?}"); + assert_eq!(display_time("2026-09-20T16:04:05"), format!("{day} 16:04:05"), "{language:?}"); + } for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); @@ -1849,172 +1408,18 @@ mod tests { app.ui.presets = true; let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); - for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] { + let zh = Locale::ZhHant; + for (panel, title) in [(crate::state::RightPanel::Activity, "History"), (crate::state::RightPanel::Versions, "Versions")] { app.ui.right = panel; - let text = painted_text(&ctx, &mut app, Locale::ZhHant); - assert!(text.contains(title), "{text}"); - assert!(!text.contains("History") && !text.contains("Versions"), "{text}"); - assert!(text.contains("暖光"), "stock preset: {text}"); + let text = painted_text(&ctx, &mut app, zh); + assert!(text.contains(zh.tr(title)), "{text}"); + assert!(text.contains(zh.tr("Warm Glow")), "stock preset: {text}"); assert!(text.contains("Warm Glow"), "user preset: {text}"); - assert!(text.contains("色彩"), "stock group: {text}"); - } - assert_eq!(history_label("Exposure", &app.session.presets), "曝光"); - assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片"); - assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow"); - set_language(Locale::En); - } - - /// English catalog values Vietnamese keeps on purpose. Grouped by reason: - /// standards/acronyms (`ISO`, `HDR`, …), product names (`LightCraft`, `PhotoCraft`), - /// file-type tokens (`Raw`/`raw`, `video`), look names left as established labels - /// (`Bleach Bypass`, `Vintage`), and machine ids that must stay byte-identical - /// (`publicDomain`, `denoiseModel.accept`). `Album` stays as the common loanword. - /// Every other required label must differ from its English source. - const VI_INTENTIONAL_ENGLISH: &[&str] = &[ - "OK", - "ISO", - "RGB", - "HDR", - "JPEG", - "PNG", - "TIFF", - "WebP", - "AVIF", - "HEIF", - "HEIC", - "DNG", - "ICC", - "EXIF", - "XMP", - "GPU", - "MCP", - "SAM", - "RAW", - "Raw", - "raw", - "B&W", - "LUT", - "LightCraft", - "ArtCraft", - "GitHub", - "Discord", - "English", - "日本語", - "繁體中文(台灣)", - "Display P3", - "Bleach Bypass", - "Cross Process", - "HDR…", - "video", - "Video", - "true", - "false", - "Album", - "Vintage", - "publicDomain", - "denoiseModel.accept", - "Panorama", - "Panorama…", - "PhotoCraft", - "SDR", - "Matrix/TRC", - "LUT-based", - ]; - - /// Vietnamese's coverage of every catalog message and display label, reported like Italian's; the - /// messages it has are never left in English. - #[test] - fn vietnamese_covers_catalogs_commands_controls_and_rules() { - let catalog = Locale::Vi.catalog(); - assert!(!catalog.is_empty()); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("vi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { - labels.push(*word); - } - for day in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] { - labels.push(day); - } - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("vi lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - - for (key, value) in catalog { - if key.contains('|') { - continue; - } - if VI_INTENTIONAL_ENGLISH.contains(&key.as_str()) { - continue; - } - assert!(!value.is_empty(), "vi empty for {key:?}"); - // Identical values outside the allowlist are accidental English fallbacks. - if value == key && key.chars().any(|ch| ch.is_ascii_alphabetic()) && key.len() > 1 { - // Single-letter ops / symbols may match; longer shared English must be listed. - assert!( - key.len() <= 2 || key.chars().all(|ch| !ch.is_ascii_alphabetic()), - "vi keeps English for {key:?}; add it to VI_INTENTIONAL_ENGLISH if intentional" - ); - } - } - } - - #[test] - fn vietnamese_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.vietnamese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Vi); - assert_eq!(crate::menubar::checked(&app, "app.language.vietnamese"), Some(true)); - assert_eq!(tr("Light"), "Ánh sáng"); - assert_eq!(tr("Exposure"), "Phơi sáng"); - assert_eq!(Locale::Vi.name(), "Tiếng Việt"); - assert_eq!(tr("Settings"), "Cài đặt"); - assert_eq!(tr("Appearance"), "Giao diện"); - assert_eq!(tr("My Photos"), "Ảnh của tôi"); - assert_eq!(tr("Candidate"), "Ảnh ứng viên"); - assert_eq!(tr("Subject"), "Chủ thể"); - assert_eq!(date_group_label("2026-09-20", false), "Chủ Nhật, 20/9/2026"); - assert_eq!(date_group_label("2026-09", false), "Tháng 9 năm 2026"); - assert_eq!(date_group_label("2026-09", true), "Tháng 9"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Chủ Nhật, 20/9/2026 16:04:05"); - for n in [0, 1, 2, 5, 22] { - let text = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }); - assert!(text.contains(&n.to_string()) && text.contains("ảnh"), "{n}: {text}"); - assert!(!text.contains("photos") && !text.contains("photo"), "{n}: {text}"); - } - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Đã thêm 22 ảnh vào «{name}»")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_ne!(builtin_label("Warm Glow", true), "Warm Glow"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"vi""#), "{saved}"); - assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::Vi); - let ctx = egui::Context::default(); - for code in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Vi)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Vi); + assert!(text.contains(zh.tr("Color")), "stock group: {text}"); } + assert_eq!(history_label("Exposure", &app.session.presets), zh.tr("Exposure")); + assert!(history_label("Preset: Warm Film", &app.session.presets).ends_with(zh.tr("Warm Film"))); + assert!(history_label("Preset: Warm Glow", &app.session.presets).ends_with("Warm Glow"), "the user's preset shares the name"); set_language(Locale::En); } @@ -2024,12 +1429,12 @@ mod tests { let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); app.ui.left_panel = true; let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains("Ảnh của tôi") && text.contains("Tất cả ảnh"), "{text}"); - for (command, title) in [("app.about", "Giới thiệu LightCraft"), ("app.shortcuts", "Phím tắt"), ("app.settings", "Cài đặt")] { + assert!(text.contains(Locale::Vi.tr("My Photos")) && text.contains(Locale::Vi.tr("All Photos")), "{text}"); + for (command, title) in [("app.about", "About LightCraft"), ("app.shortcuts", "Keyboard Shortcuts"), ("app.settings", "Settings")] { app.ui.dialog = None; app.run(command, serde_json::json!({})).unwrap(); let text = painted_text(&ctx, &mut app, Locale::Vi); - assert!(text.contains(title), "{command}: {text}"); + assert!(text.contains(Locale::Vi.tr(title)), "{command}: {text}"); } ctx.fonts_mut(|fonts| { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { @@ -2047,6 +1452,178 @@ mod tests { }); set_language(Locale::En); } + /// The messages each language lacks, which it shows in English. Never fails: `cargo xtask ci` + /// runs it with `--nocapture`, so the list lands in the gate log. Plain messages are every key + /// some catalog has plus the labels the UI passes to `tr` from tables (commands, controls, rule + /// fields…); formats are every key some `*-formats.json` has plus every `tr_format!` message in + /// the app's source. An empty translation counts as missing. + #[test] + fn translation_gaps_report() { + let notes = whats_new_lines(); + let messages: std::collections::BTreeSet = plain_messages().into_iter().filter(|m| !notes.contains(&m.as_str())).collect(); + let formats = format_messages(); + let quoted = |list: &[&str]| list.iter().map(|m| format!("{m:?}")).collect::>().join(", "); + let mut out = format!( + "Translation gaps, shown in English ({} messages, {} formats, {} What's New lines):\n", + messages.len(), + formats.len(), + notes.len() + ); + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + let catalog = language.catalog(); + let own_formats = formats_catalog(language.code()); + let missing: Vec<&str> = messages.iter().map(String::as_str).filter(|m| !catalog.contains_key(*m)).collect(); + let missing_formats: Vec<&str> = formats.iter().map(String::as_str).filter(|m| !own_formats.contains_key(*m)).collect(); + let missing_notes = notes.iter().filter(|line| !catalog.contains_key(**line)).count(); + out.push_str(&format!( + "{}: {} messages, {} formats, {} What's New lines\n", + language.code(), + missing.len(), + missing_formats.len(), + missing_notes + )); + if !missing.is_empty() { + out.push_str(&format!(" messages: {}\n", quoted(&missing))); + } + if !missing_formats.is_empty() { + out.push_str(&format!(" formats: {}\n", quoted(&missing_formats))); + } + } + println!("{out}"); + } + + /// Every plain message: the keys of every catalog (but contextual entries, which a language adds + /// only where it needs one) and the labels the UI translates from tables. + fn plain_messages() -> std::collections::BTreeSet { + use lightcraft_catalog::rules; + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in rules::FIELDS { + labels.push(label); + labels.extend(rules::ops_for(*kind).iter().map(|(_, label)| *label)); + if let rules::Kind::Choice(choices) = kind { + labels.extend(choices.iter().map(|(_, label)| *label)); + } + } + labels.extend(rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(rules::Issue::ALL.iter().map(|issue| issue.text())); + labels.extend([rules::bool_label(true), rules::bool_label(false)]); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + labels.extend(crate::date_picker::WEEKDAY_SHORT); + labels.extend(["Pick a date", "Year", "Month", "Day", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]); + // the album pickers + labels.extend([ + "Choose an album…", + "No albums yet", + "Search albums", + "No albums match", + "This is the album you're editing", + "It tests this album, so testing it back would loop", + "Fix the marked rules to save this album.", + "None", + "New album", + "New album…", + "Imported Photos", + ]); + labels.extend(crate::panels::keyword_list::VARIABLE_LABELS); + let contextual = format!("{BOOL_CONTEXT}|"); + Locale::ALL + .iter() + .flat_map(|language| language.catalog().keys()) + .filter(|key| !key.starts_with(&contextual)) + .cloned() + .chain(labels.into_iter().map(str::to_string)) + .collect() + } + + /// Help ▸ What's New, line by line as the dialog looks each one up. + fn whats_new_lines() -> Vec<&'static str> { + crate::panels::dialogs::WHATS_NEW + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("# ")) + .map(|line| line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line)) + .collect() + } + + /// `locales/-formats.json` (the catalog `build.rs` compiles into `tr_format!`), without + /// empty translations. + fn formats_catalog(code: &str) -> BTreeMap { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("locales/{code}-formats.json")); + let mut messages: BTreeMap = + std::fs::read_to_string(path).ok().and_then(|text| serde_json::from_str(&text).ok()).unwrap_or_default(); + messages.retain(|_, text| !text.is_empty()); + messages + } + + /// Every format message: the keys of every formats catalog and the message of every + /// `tr_format!` call in `src/` outside its test modules. + fn format_messages() -> std::collections::BTreeSet { + let mut all: std::collections::BTreeSet = Locale::ALL + .iter() + .filter(|language| **language != Locale::En) + .flat_map(|language| formats_catalog(language.code()).into_keys()) + .collect(); + let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + while let Some(dir) = dirs.pop() { + for path in std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()) { + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let source = std::fs::read_to_string(&path).unwrap(); + let app = source + .split( + "#[cfg(test)] +mod ", + ) + .next() + .unwrap_or_default(); + all.extend(tr_format_messages(app)); + } + } + } + all + } + + /// The first argument of each `tr_format!` call in `source`, when it is a string literal. + fn tr_format_messages(source: &str) -> Vec { + // built from parts, so this file's own source holds no call to find + let call = concat!("tr_format", "!("); + let mut messages = Vec::new(); + for (at, _) in source.match_indices(call) { + let Some(body) = source[at + call.len()..].trim_start().strip_prefix('"') else { continue }; + let mut message = String::new(); + let mut chars = body.chars(); + while let Some(ch) = chars.next() { + match ch { + '"' => break, + '\\' => match chars.next() { + Some('n') => message.push('\n'), + Some('t') => message.push('\t'), + Some('u') => { + let hex: String = chars.by_ref().skip(1).take_while(|ch| *ch != '}').collect(); + message.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); + } + Some(other) => message.push(other), + None => break, + }, + _ => message.push(ch), + } + } + messages.push(message); + } + messages + } + + #[test] + fn tr_format_messages_are_read_from_source() { + let call = concat!("tr_format", "!("); + let source = format!("{call}\"Imported {{}} photo{{}}\", n, s); crate::i18n::{call}\n \"Say \\\"hi\\\" \\u{{2192}}\", x); {call}name)"); + assert_eq!(tr_format_messages(&source), ["Imported {} photo{}", "Say \"hi\" →"]); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82ee5d3f8..f1244666c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,8 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialog_primary; #[cfg(test)] +mod tests_dialog_tab; +#[cfg(test)] mod tests_dialogs; #[cfg(test)] mod tests_edit_layout; @@ -645,6 +647,10 @@ impl LightcraftApp { .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) }; } + if r.is_ok() && id == "library.toggleFilters" { + // the search field shows the text filter: empty while the filters are off, back with them + self.ui.search = self.session.filter.text.clone(); + } if r.is_ok() && id == "library.clearFilter" { // the search field shows the text filter: cleared from anywhere (the loupe's pill, the // menu), it must not keep a search that no longer applies (issue #1648) @@ -822,6 +828,18 @@ impl LightcraftApp { self.toast_for(ctx, text, TOAST_SECONDS); } + /// Auto straighten (the crop panel's Auto, a double-click with the Straighten tool), saying so + /// when it finds nothing to level by instead of leaving the photo as it is without a word. + pub fn auto_straighten(&mut self, ctx: &egui::Context) { + match self.run("crop.autoStraighten", serde_json::json!({})) { + Ok(r) if r.get("changed").and_then(Value::as_bool) == Some(false) => { + self.toast(ctx, crate::i18n::tr("No horizon or vertical lines to level by")); + } + Ok(_) => {} + Err(e) => self.toast(ctx, e), + } + } + /// A toast that stays `secs` seconds (messages that say where to look or what to do next). pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into, secs: f64) { let t = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 2bda28cf4..059a700e1 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -684,25 +684,17 @@ mod tests { } #[test] - fn skipped_images_are_one_line_in_the_ui_language() { + fn skipped_images_are_one_line() { let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [ {"image": 7, "reason": "missingFile"}, {"image": 8, "reason": "missingRoot"}, {"image": 9, "reason": "masterSkipped"}, ]}}); - let details = |locale| { - crate::i18n::set_language(locale); - let dialog = result_dialog(&value, "done"); - crate::i18n::set_language(crate::i18n::Locale::En); - let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") }; - details - }; - let en = details(crate::i18n::Locale::En); + crate::i18n::set_language(crate::i18n::Locale::En); + let crate::state::Dialog::LightroomResult { details: en, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") }; assert_eq!(en.len(), 4, "{en:?}"); assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete."); assert_eq!(&en[2..], ["w1", "w2"]); - let es = details(crate::i18n::Locale::Es); - assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos."); } #[test] diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 8e0cb85ea..e31f1d52b 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -173,6 +173,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Sort", "@Stacks", "view.filterBar", + "library.toggleFilters", "library.clearFilter", "library.refresh", "library.holdView", @@ -354,6 +355,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), + "library.toggleFilters" => Some(!app.session.filters_off()), "view.soloSections" => Some(u.single_panel), "view.navigator" => Some(u.navigator), "view.layoutImage" => Some(u.layout_image.show && !u.layout_image.path.trim().is_empty()), @@ -1401,6 +1403,23 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // Regression test for #49: the in-window menu headings (File, Edit, ...) sat only egui's default + // 8 px apart, too close to read or aim at. + #[test] + fn in_window_menu_headings_are_spaced_apart() { + let mut a = app(); + a.native_menu = false; + let mut h = crate::headless::Headless::new(a, [1400.0, 900.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} is on screen")); + let titles: Vec<_> = ["File", "Edit", "View", "Photo", "Window", "Help"].iter().map(|t| rect(&format!("menu:{t}"))).collect(); + for pair in titles.windows(2) { + let gap = pair[1].left() - pair[0].right(); + assert!(gap >= 24.0 - 0.5, "headings {:?} and {:?} are only {gap} px apart", pair[0], pair[1]); + } + } + /// A backup the host refuses (changes still unsaved, issue #1508) says why when started from /// the menu, as a refused export does; the refusal used to reach only the control channel. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 04d43e0d3..747cfbccb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -340,7 +340,7 @@ fn import_into_album(app: &mut LightcraftApp, p: &Value, r: Result Option<[u8; 3]> { if let Some(s) = v.as_str() { let h = s.strip_prefix('#').unwrap_or(s); - if h.len() != 6 { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index 30f03971b..3a8f0ce8e 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -55,7 +55,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt }; } if chip.clear.get("only").is_some() { - return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = filter.only.len())); + return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = if filter.only_none { 0 } else { filter.only.len() })); } if let Some((prefix, value)) = chip.label.split_once(": ") { let value = if matches!(prefix, "Flag" | "Type") { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index b95d7b056..082508d08 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2245,9 +2245,15 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } for m in &d.masks { let sel = Some(m.id) == active; + // Ctrl+Shift+O (`mask_overlay_all`) shows every visible mask's region; outline each one + // too, not just the selected mask. Handles and the rotation grip stay on the selected + // mask so nothing else becomes draggable. The component check keeps this in step with the + // painted region (`view_overlay` at detail.rs:1354 only covers masks with components). + let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty()); + let stroke_a: u8 = if sel { 230 } else { 170 }; for (ci, c) in m.components.iter().enumerate() { match &c.shape { - MaskShape::Radial { center, rx, ry, angle, .. } if sel => { + MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => { let pts: Vec = (0..64) .map(|i| { let a = i as f64 / 64.0 * std::f64::consts::TAU; @@ -2257,39 +2263,44 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) }) .collect(); - p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230)))); + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); + + if sel { + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, q)); + } - let l = frame_long_norm(map); - for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { - let a_local = a_deg.to_radians(); let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); - let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui.ctx(), format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, q)); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 5, rot_q)); } - - let (s, co) = angle.to_radians().sin_cos(); - let rot_r = rx + 0.08; - let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); - let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); - p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); - p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); - register(ui.ctx(), format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 5, rot_q)); } - MaskShape::Linear { start, end } if sel => { + MaskShape::Linear { start, end } if show_outline => { let (a, b) = (map.screen(*start), map.screen(*end)); let dir = (b - a).normalized(); let perp = vec2(-dir.y, dir.x) * 3000.0; - for (q, alpha) in [(a, 230), (b, 110)] { + let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; + for (q, alpha) in [(a, a1), (b, a2)] { p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); } - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui.ctx(), format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, *q)); + if sel { + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, *q)); + } } } _ => {} @@ -3047,7 +3058,7 @@ mod preview_geometry_tests { fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, held: bool) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if !held && resp.double_clicked() { - let _ = app.run("crop.autoStraighten", json!({})); + app.auto_straighten(ui.ctx()); app.ui.tool.clear(); app.gesture = None; return; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 894ad2bd5..c24f6da7c 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -2244,13 +2244,15 @@ fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) - PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } } -/// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens -/// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc -/// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the -/// focus Return gives up, and Return did nothing. +/// A dialog's one text field: it takes the focus when the dialog opens, and opens with its text +/// selected, so typing replaces it. It takes the focus back only while nothing else has it: taking +/// it back every frame would keep Tab from ever reaching the dialog's buttons and check boxes. +/// True when Return confirms the dialog; Esc cancels the dialog (`show`). fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool { let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui); - if !r.editing { + // (and takes it back when nothing has it: after a click on the dialog's background or a check box, + // or when Return gave it up on a refusal; a control Tab reached keeps it) + if just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none())) { r.response.request_focus(); } r.ending == Some(crate::text_field::Ending::Return) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index a1184892c..fbe52cf12 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1533,18 +1533,6 @@ mod tests { } } - #[test] - fn library_context_menu_labels_have_french_translations() { - let saved = crate::i18n::language(); - crate::i18n::set_language(crate::i18n::Locale::Fr); - for (label, expected) in - [("Pick", "Sélectionnée"), ("Reject", "Rejeter"), ("Group into Stack", "Regrouper en pile"), ("HDR Panorama…", "Panorama HDR…")] - { - assert_eq!(crate::i18n::tr(label), expected); - } - crate::i18n::set_language(saved); - } - #[test] fn square_grid_groups_and_auto_levels() { let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 1ec97d1c9..4f2cba91c 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -520,6 +520,35 @@ fn in_tree(tree: &[KeywordNode], path: &str) -> bool { tree.iter().any(|n| same(&n.path, path) || (lightcraft_catalog::keywords::is_under(path, &n.path) && in_tree(&n.children, path))) } +/// The labels the Keyword List and its dialogs pass to `tr` through variables, out of reach of a +/// literal search: the translation gaps report (`i18n`) checks them. +#[cfg(test)] +pub(crate) const VARIABLE_LABELS: &[&str] = &[ + "Include on Export", + "Export Containing Keywords", + "Export Synonyms", + "Person", + "Merge Keywords", + "No keywords yet", + "No keywords match", + "Add to Selected Photos", + "Remove from Selected Photos", + "Keywords", + "& Containing", + "Will Export", + "No keywords are exported", + "Add to All Selected Photos", + "Set name", + "Edit Set…", + "Edit Keyword Set", + "A keyword set needs a name", + "Recent Keywords", + "Keywords (⌥1–⌥9)", + "Save as a new set", + "Import Keywords…", + "Export Keywords…", +]; + #[cfg(test)] mod tests { use lightcraft_catalog::{Op, Photo, Source}; @@ -589,45 +618,4 @@ mod tests { let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]); assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some); } - - /// The labels the Keyword List and its dialogs pass to `tr` through variables (out of reach of - /// a literal search) are translated in every language. - #[test] - fn labels_passed_through_variables_are_translated() { - use crate::i18n::Locale; - let labels = [ - "Include on Export", - "Export Containing Keywords", - "Export Synonyms", - "Person", - "Merge Keywords", - "No keywords yet", - "No keywords match", - "Add to Selected Photos", - "Remove from Selected Photos", - "Keywords", - "& Containing", - "Will Export", - "No keywords are exported", - "Add to All Selected Photos", - "Set name", - "Edit Set…", - "Edit Keyword Set", - "A keyword set needs a name", - "Recent Keywords", - "Keywords (⌥1–⌥9)", - "Save as a new set", - "Import Keywords…", - "Export Keywords…", - ]; - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs", "sv", "fi", "id", "vi"] { - let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); - let catalog: std::collections::HashMap = serde_json::from_str(&text).unwrap(); - for label in labels { - assert!(catalog.contains_key(label), "{code}: “{label}”"); - } - } - assert_eq!(Locale::ALL.len(), 18, "a language added: list its file above"); - } } diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index b0371c422..4fb31be80 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -897,25 +897,6 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { mod tests { use super::*; - #[test] - fn masking_context_actions_use_catalog_translations() { - let saved = crate::i18n::language(); - crate::i18n::set_language(crate::i18n::Locale::Fr); - for (english, translated) in [ - ("Duplicate Mask", "Dupliquer le masque"), - ("Invert Mask", "Inverser le masque"), - ("Hide Mask", "Masquer le masque"), - ("Move Up", "Monter"), - ("Brush", "Pinceau"), - ("Luminance Range", "Plage de luminance"), - ] { - assert_eq!(menu_label(english), translated, "{english}"); - } - // An arbitrary user name has no catalog entry and is left unchanged. - assert_eq!(menu_label("My custom mask"), "My custom mask"); - crate::i18n::set_language(saved); - } - /// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label /// room by dropping a column rather than drawing it past its tile. #[test] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 76ecc746a..79cd28712 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -317,7 +317,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.tool = if on { String::new() } else { "straighten".into() }; } if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() { - let _ = app.run("crop.autoStraighten", json!({})); + app.auto_straighten(ui.ctx()); } }); }); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e7b58a9b5..91e6555b9 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -1317,6 +1317,10 @@ mod relative_rating_tests; #[path = "shortcuts/tests_flag_cycle.rs"] mod flag_cycle_tests; +#[cfg(test)] +#[path = "shortcuts/tests_enable_filters.rs"] +mod enable_filters_tests; + #[cfg(test)] #[path = "shortcuts/tests_filter_bar_key.rs"] mod filter_bar_key_tests; diff --git a/crates/ui-egui/src/shortcuts/tests_enable_filters.rs b/crates/ui-egui/src/shortcuts/tests_enable_filters.rs new file mode 100644 index 000000000..0f42c3eff --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests_enable_filters.rs @@ -0,0 +1,69 @@ +//! Cmd+L (Library > Enable Filters) switches the filter off and on from the keyboard and the menu. +use egui::{Key, Modifiers}; +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::state::ViewMode; + +fn app() -> lightcraft_engine::Result { + let mut s = lightcraft_engine::Session::new(); + for id in 1..=3 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-enable-filters-keys/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-23", + ); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.select", &json!({"ids": [1]}))?; + s.execute("photo.rate", &json!({"ids": [1], "rating": 4}))?; + s.execute("library.filter", &json!({"rating": 4, "text": "1.jpg"}))?; + let mut app = LightcraftApp::new(s, crate::Services { png: None, ..Default::default() }); + app.ui.search = "1.jpg".into(); + Ok(app) +} + +fn press(app: &mut LightcraftApp, ctx: &egui::Context) { + let events = vec![egui::Event::Key { key: Key::L, physical_key: Some(Key::L), pressed: true, repeat: false, modifiers: Modifiers::COMMAND }]; + let mut out = ctx.run_ui( + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, + |ui| super::handle(app, ui.ctx()), + ); + out.textures_delta.clear(); +} + +#[test] +fn the_key_is_cmd_l() { + assert_eq!(super::shortcut_of(&Default::default(), "library.toggleFilters"), Some("Cmd+L")); +} + +#[test] +fn cmd_l_switches_the_filter_off_and_on_in_the_grids_and_the_loupe() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { + let mut app = app().unwrap(); + app.ui.view = view; + let ctx = egui::Context::default(); + press(&mut app, &ctx); + assert_eq!(app.session.visible().len(), 3, "{view:?}: off shows every photo"); + assert_eq!(app.ui.search, "", "{view:?}: the search field follows"); + assert_eq!(crate::menubar::checked(&app, "library.toggleFilters"), Some(false)); + press(&mut app, &ctx); + assert_eq!(app.session.visible().len(), 1, "{view:?}: on brings the filter back"); + assert_eq!(app.ui.search, "1.jpg"); + assert_eq!(crate::menubar::checked(&app, "library.toggleFilters"), Some(true)); + } +} + +#[test] +fn the_menu_item_does_the_same() { + let mut app = app().unwrap(); + crate::menubar::run_item(&mut app, "library.toggleFilters", serde_json::Value::Null).unwrap(); + assert_eq!(app.session.visible().len(), 3); + assert_eq!(app.ui.search, ""); + assert_eq!(crate::menubar::shortcut_text("Cmd+L", true), "⌘L"); +} diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index bce9d2eb1..e66c2b7ef 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -242,6 +242,14 @@ fn dead_export_worker_leaves_no_row() { /// A folder of `n` stand-in JPEGs and an app whose file probe takes 100 ms each (a slow drive). fn slow_import(tag: &str, n: usize) -> (Headless, std::path::PathBuf) { + gated_import(tag, n, None) +} + +/// Like `slow_import`, and while `gate` is false the probe holds every file after the first batch +/// (`batch_size()` files, which join the catalog together), so a test can act mid-import without +/// racing the 100 ms-per-file clock. The hold gives up after 60 s, so a failed test never leaves +/// the import thread spinning. +fn gated_import(tag: &str, n: usize, gate: Option>) -> (Headless, std::path::PathBuf) { let dir = std::env::temp_dir().join(format!("lc-activity-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -249,8 +257,17 @@ fn slow_import(tag: &str, n: usize) -> (Headless, std::path::PathBuf) { std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); } let mut s = lightcraft_engine::Session::new(); - s.media.file_probe = Some(std::sync::Arc::new(|p: &str| { + s.media.file_probe = Some(std::sync::Arc::new(move |p: &str| { std::thread::sleep(Duration::from_millis(100)); + let index = p.rsplit_once("IMG_").and_then(|(_, n)| n.strip_suffix(".jpg")).and_then(|n| n.parse::().ok()); + if let (Some(gate), Some(index)) = (&gate, index) + && index >= lightcraft_engine::import::batch_size() + { + let t0 = std::time::Instant::now(); + while !gate.load(std::sync::atomic::Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) { + std::thread::sleep(Duration::from_millis(1)); + } + } Ok(lightcraft_engine::media::ProbeInfo { width: 60, height: 40, @@ -286,14 +303,17 @@ fn import_cancelled_by_command_reports_cancelled() { /// selects its first photo only if nobody picked something else meanwhile (issue #1446). #[test] fn a_photo_chosen_during_an_import_stays_selected() { - let (mut h, dir) = slow_import("chosen", 20); + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (mut h, dir) = gated_import("chosen", 20, Some(gate.clone())); crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2))); // the newest photo so far, never the import's first let chosen = h.app.session.catalog.photos().map(|p| p.id).max().expect("photos arrived"); let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [chosen.0]}}), T); assert_eq!(r["ok"], true, "{r}"); + // held at the gate until now, so the import cannot have ended however slowly the frames ran assert!(h.app.import.is_some(), "still importing"); + gate.store(true, std::sync::atomic::Ordering::Release); assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none())); assert_eq!(h.app.session.catalog.len(), 20, "all imported"); assert_eq!(h.app.session.selection.active, Some(chosen)); diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs index 32cc906fe..97305cba6 100644 --- a/crates/ui-egui/src/tests_album_picker.rs +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -343,20 +343,12 @@ mod import_review { } } - /// Every shipped locale paints translated destination choices and initializes a translated new name. + /// Every shipped locale paints its own destination choices and names a new album in its language. #[test] fn import_album_picker_localizes_choices_in_every_shipped_language() { use crate::i18n::Locale; let Review { mut h, .. } = review(); for locale in Locale::ALL { - for source in - ["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"] - { - assert!(!locale.tr(source).is_empty()); - if *locale != Locale::En { - assert_ne!(locale.tr(source), source, "{locale:?}: {source}"); - } - } let r = h.request("ui.set", json!({"language": locale.code()}), T); assert_eq!(r["ok"], true, "{r}"); assert!(h.settle(SETTLE)); diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index 848b1b035..71368fba2 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -454,3 +454,31 @@ fn a_drag_from_just_inside_a_handle_resizes_not_moves() { assert!(r.x0 > 0.0, "the left edge moved in: {r:?}"); assert!((r.x1 - 1.0).abs() < 1e-6, "the right edge stays: {r:?}"); } + +/// Auto on a photo without a horizon or plumb lines says so, instead of leaving the photo as it is +/// without a word. +#[test] +fn auto_straighten_says_when_it_finds_nothing() { + let dir = std::env::temp_dir().join(format!("lc-ui-auto-straighten-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 64, height: 48, data: vec![[90, 120, 160, 255]; 64 * 48] }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let path = dir.join("flat.png"); + std::fs::write(&path, bytes).unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + for (method, params) in [("ui.set", json!({"view": "detail"})), ("engine.execute", json!({"command": "panel.crop"}))] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "button:straightenAuto"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0 == "No horizon or vertical lines to level by"), "{:?}", h.app.ui.toast); + assert_eq!(angle(&h), 0.0); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/tests_dialog_tab.rs b/crates/ui-egui/src/tests_dialog_tab.rs new file mode 100644 index 000000000..5ffb99580 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_tab.rs @@ -0,0 +1,153 @@ +//! A dialog's name field takes the keyboard focus once, when the dialog opens: Tab then carries it +//! on to the dialog's other controls (a check box, Cancel, OK) and Shift+Tab back, instead of the +//! field taking it back every frame. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo_with_selection() -> Headless { + let mut session = lightcraft_engine::Session::with_demo(); + session.selection.ids = session.catalog.photos().take(2).map(|p| p.id).collect(); + let mut h = Headless::new(LightcraftApp::new(session, Services::default()), [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +/// The registered widget (`field:…`, `check:…`, `button:…`) that has the keyboard focus. +fn focused(h: &Headless) -> Option { + let id = h.view.ctx.memory(|m| m.focused())?; + let rect = h.view.ctx.read_response(id)?.rect; + h.app.widgets.iter().find(|(_, r)| *r == rect).map(|(w, _)| w.clone()) +} + +fn key(h: &mut Headless, params: serde_json::Value) { + let r = h.request("ui.key", params, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +/// Open `dialog`, then Tab through it; the focus order, starting with the field it opens on. +fn tab_order(dialog: Dialog) -> Vec { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(dialog); + for _ in 0..4 { + h.step(); + } + let mut order = vec![focused(&h).unwrap_or_default()]; + for _ in 0..4 { + key(&mut h, json!({"key": "Tab"})); + order.push(focused(&h).unwrap_or_default()); + } + order +} + +#[test] +fn new_album_opens_on_its_name_and_tab_reaches_the_check_box_cancel_and_ok() { + let order = tab_order(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + assert_eq!(order[..4], ["field:albumName", "check:albumIncludeSelected", "button:dialogCancel", "button:dialogOk"], "{order:?}"); +} + +#[test] +fn new_smart_album_opens_on_its_name_and_tab_reaches_cancel_and_ok() { + let order = tab_order(Dialog::NewSmartAlbum { name: "Rejected".into(), parent: None }); + let (cancel, ok) = (order.iter().position(|w| w == "button:dialogCancel"), order.iter().position(|w| w == "button:dialogOk")); + assert_eq!(order[0], "field:smartAlbumName", "{order:?}"); + assert!(cancel.is_some() && ok.is_some() && cancel < ok, "Cancel then OK are reached by Tab: {order:?}"); +} + +#[test] +fn save_metadata_preset_opens_on_its_name_and_tab_reaches_cancel_and_ok() { + let prompt = Dialog::TextPrompt { + title: "Save Metadata Preset".into(), + hint: "Preset name".into(), + value: String::new(), + command: "metadata.savePreset".into(), + params: json!({}), + key: "name".into(), + }; + let order = tab_order(prompt); + assert_eq!(order[..3], ["field:prompt", "button:dialogCancel", "button:dialogOk"], "{order:?}"); +} + +#[test] +fn shift_tab_goes_back_from_ok_to_the_name() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + for _ in 0..4 { + h.step(); + } + for _ in 0..3 { + key(&mut h, json!({"key": "Tab"})); + } + assert_eq!(focused(&h).as_deref(), Some("button:dialogOk")); + for _ in 0..3 { + key(&mut h, json!({"key": "Tab", "shift": true})); + } + assert_eq!(focused(&h).as_deref(), Some("field:albumName")); +} + +#[test] +fn the_name_still_takes_typing_and_enter_after_opening() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: false }); + for _ in 0..4 { + h.step(); + } + let r = h.request("ui.text", json!({"text": "Holiday"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + key(&mut h, json!({"key": "Enter"})); + h.settle(T); + assert!(h.app.ui.dialog.is_none(), "Enter confirmed the dialog"); + assert!(h.app.session.catalog.albums().any(|a| a.name == "Holiday"), "the typed name made the album"); +} + +#[test] +fn escape_still_cancels_after_tabbing_away_from_the_name() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + for _ in 0..4 { + h.step(); + } + key(&mut h, json!({"key": "Tab"})); + key(&mut h, json!({"key": "Escape"})); + assert!(h.app.ui.dialog.is_none(), "Esc closed the dialog"); +} + +#[test] +fn a_click_on_a_check_box_leaves_return_confirming_the_name() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + for _ in 0..4 { + h.step(); + } + let r = h.request("ui.clickWidget", json!({"id": "check:albumIncludeSelected"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + key(&mut h, json!({"key": "Enter"})); + h.settle(T); + assert!(h.app.ui.dialog.is_none(), "Enter confirmed the dialog after the click"); + assert!(h.app.session.catalog.albums().any(|a| a.name == "Trip")); +} + +#[test] +fn a_refused_return_leaves_the_name_focused_for_the_correction() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: false }); + for _ in 0..4 { + h.step(); + } + key(&mut h, json!({"key": "Enter"})); + h.step(); + assert!(h.app.ui.dialog.is_some(), "an empty name is refused and the dialog stays"); + assert_eq!(focused(&h).as_deref(), Some("field:albumName"), "the field has the focus again"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index a1e5846e4..2ce24355f 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -295,6 +295,21 @@ fn radial_body_drag_keeps_resize_and_rotation_handles() { } } +#[test] +fn mask_overlay_color_refuses_anything_but_six_hex_digits() { + let mut h = detail("panel.masking"); + exec(&mut h, "view.maskOverlayColor", json!({"color": "#2870f0"})); + for bad in ["#+f+f+f", "+12345", "#-0-0-0", "##ffffff"] { + let r = h.request("engine.execute", json!({"command": "view.maskOverlayColor", "params": {"color": bad}}), T); + assert_eq!(r["ok"], false, "{bad:?} is not a colour: {r}"); + assert_eq!(h.app.ui.mask_overlay_color, [0x28, 0x70, 0xf0], "{bad:?} leaves the overlay colour alone"); + } + exec(&mut h, "view.maskOverlayColor", json!({"color": "e63228"})); + assert_eq!(h.app.ui.mask_overlay_color, [0xe6, 0x32, 0x28]); + exec(&mut h, "view.maskOverlayColor", json!({"color": [10, 20, 30]})); + assert_eq!(h.app.ui.mask_overlay_color, [10, 20, 30]); +} + #[test] fn mask_overlay_keys_and_pins() { use lightcraft_pipeline::{MaskView, Overlay}; @@ -348,6 +363,63 @@ fn mask_overlay_keys_and_pins() { h.settle(SETTLE); } +/// #2210: Ctrl+Shift+O (the all-masks overlay) draws the white outline of every visible +/// Radial/Linear mask, not just the selected one — but only the selected mask registers +/// `maskHandle:` elements, so nothing else becomes draggable. +#[test] +fn all_masks_overlay_outlines_every_visible_mask_but_only_the_selected_one_is_draggable() { + let mut h = detail("panel.masking"); + // Two visible radial masks; mask 2 ends up selected (mask.add selects the new one). + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.5], "rx": 0.12, "ry": 0.12})); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.7, 0.5], "rx": 0.12, "ry": 0.12})); + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(h.app.ui.mask_overlay_all); + assert_eq!(h.app.session.active_mask, Some(2)); + // Pins add their own elements; keep them out of the handle count. + exec(&mut h, "view.maskPins", json!({"show": false})); + h.settle(SETTLE); + + let handles_of = |h: &Headless, id: usize| h.app.widgets.iter().filter(|(w, _)| w.starts_with(&format!("maskHandle:{id}:"))).count(); + // The selected mask keeps its 4 edge handles and its rotation grip. + assert_eq!(handles_of(&h, 2), 5, "the selected mask keeps its handles"); + // The other visible mask draws its outline but registers no handle: it is not draggable. + assert_eq!(handles_of(&h, 1), 0, "an unselected mask must not become draggable"); + // …while a mask with no components has nothing to outline and nothing to grab either. + exec(&mut h, "mask.add", json!({"kind": "luminanceRange"})); + exec(&mut h, "mask.addComponent", json!({"kind": "radial", "op": "add", "center": [0.5, 0.8], "rx": 0.08, "ry": 0.08})); + assert_eq!(h.app.session.active_mask, Some(3)); + assert_eq!(handles_of(&h, 1), 0); + h.settle(SETTLE); +} + +/// #2210: the outlines of *unselected* masks are painted, not registered as widgets, so the way to +/// observe them is the rendered pixels. With the all-masks overlay on, the unselected mask's +/// ellipse edge must carry white stroke pixels the photo alone does not have. +#[test] +fn all_masks_overlay_paints_the_outline_of_an_unselected_mask() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.5], "rx": 0.12, "ry": 0.12})); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.7, 0.5], "rx": 0.12, "ry": 0.12})); + assert_eq!(h.app.session.active_mask, Some(2)); + exec(&mut h, "view.maskPins", json!({"show": false})); + + // Sample the whole canvas: the outline is white (alpha 170) over the photo, so the overlay adds + // near-white pixels along the unselected mask's ellipse edge. + let white_pixels = |h: &mut Headless, on: bool| -> usize { + let overlay = h.app.ui.mask_overlay_all; + if overlay != on { + exec(h, "view.maskOverlayAll", json!({})); + } + assert_eq!(h.app.ui.mask_overlay_all, on); + h.settle(SETTLE); + let px = h.view.paint(&Default::default()); + px.pixels.iter().filter(|p| p.r() > 200 && p.g() > 200 && p.b() > 200).count() + }; + let off = white_pixels(&mut h, false); + let on = white_pixels(&mut h, true); + assert!(on > off, "the unselected mask's outline must add white pixels: off {off}, on {on}"); +} + /// #518: holding the brush still, however long, paints the same feathered spot as a quick press. #[test] fn holding_the_brush_still_does_not_harden_the_spot() { diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/it/accessibility.rs similarity index 100% rename from crates/ui-egui/tests/accessibility.rs rename to crates/ui-egui/tests/it/accessibility.rs diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs similarity index 100% rename from crates/ui-egui/tests/docking_workflows.rs rename to crates/ui-egui/tests/it/docking_workflows.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..4b9fa7eb7 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The integration tests, one module per file, linked into one test binary. +//! `tests/shutdown.rs` stays a binary of its own: it closes the GPU for the whole process. + +mod accessibility; +mod docking_workflows; diff --git a/docs/ai-masks.md b/docs/ai-masks.md index b01fd9e42..84e082163 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -238,7 +238,7 @@ wait and return the result (when built with the `sam` feature; the default CLI h the model on the CPU from the start. - Hostile documents: a selection keeps at most 4 detail patches (when read and when rendered), each at most 1024² logits; more than 64 clicks are never sent to the model. -- Accuracy: `crates/segment/tests/reference.rs` compares every stage against the Python +- Accuracy: `crates/segment/tests/it/reference.rs` compares every stage against the Python reference (`tools/sam3_reference.py`); on Metal the backbone matches to 7e-5 relative error and masks to ~5e-5. f16 was tried and rejected (overflow, ~7 % faster). - The engine feature `sam` (on in the desktop app, off for web and CLI) gates the model; without diff --git a/docs/architecture.md b/docs/architecture.md index 7d1b7a3ae..aec9a51a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`cargo xtask ci` reports the machine and skipped tests; `testkit`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (`cargo xtask ci`: one integration-test binary per crate, doctests apart, one WASM check) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only (`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted @@ -63,6 +63,16 @@ dispatch by id, and headless snapshots render the UI without a window. `assets` (every asset attributed, no Adobe formats) and the WASM build. Other workflows build and test FreeBSD and Windows ARM64, lint packaging, and build releases. +The tests run under `cargo test --workspace --lib --bins --tests`, or `cargo nextest run` with the +same targets when `LIGHTCRAFT_NEXTEST=1` is set and cargo-nextest is installed; doctests run after +them with `cargo test --workspace --doc`. `cargo test` is the default because the demo library's +generated scenes are cached per process, and nextest's process per test regenerates them. +Examples are left out of the test build (clippy `--all-targets` still +checks them). Each crate links its integration tests into one binary, `tests/it/main.rs` with one +module per file (`cargo test -p --test it ::`); a test that changes state for its +whole process (a global allocator, closing the GPU) keeps a binary of its own in `tests/`. The WASM +build is one `cargo check --target wasm32-unknown-unknown --keep-going` over the wasm-safe crates. + `ci` also probes the machine: OS, CPUs, RAM, the GPU adapters of each backend (from the ignored `gpu_probe` test of `lightcraft-gpu`, run after the workspace tests so it reuses their build; xtask has no wgpu), the corpus and `CRAFT_FONTS_DIR`. Tests @@ -77,5 +87,6 @@ the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Quality gates: one integration-test binary per crate, doctests apart, nextest on request, one WASM check | | 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` | | 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) | diff --git a/docs/cr3.md b/docs/cr3.md index b133dfe6a..b53647c7d 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -39,7 +39,7 @@ decoded. ## Variant status The supported and rejected CRX coding variants. Every rejection is an explicit `RawError::Unsupported` whose -text is part of the contract: `crates/raw/tests/cr3_variants.rs` rebuilds each variant as a synthetic CR3 +text is part of the contract: `crates/raw/tests/it/cr3_variants.rs` rebuilds each variant as a synthetic CR3 fixture and asserts the exact string, and that header probing (`probe_info`, the import path) and full decoding fail with the same error, because the engine shows that reason when it falls back to the embedded preview. The same tests decode a control fixture at both edges of each accepted range, so a rejection can @@ -87,7 +87,7 @@ CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data. Other unverified coding variants return explicit unsupported errors (the table above lists them all). Header probing validates the same coding variants and tile/plane layout as full decoding. -`crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a +`crates/raw/tests/it/cr3_corpus.rs` checks every full-sensor sample against a black-box reference, before cropping or colour processing. Public CC0 files are downloaded by `cargo xtask corpus --download` and kept outside version control. The exact inputs and reference hashes are: @@ -194,7 +194,7 @@ All 110 CR3 files on raw.pixls.us (2026-10-12, every one CC0, 31 folders) decode falls back to the preview, so none of them uses a CRX variant the decoder refuses (vertical C-RAW tiles, partial-subband or rounded-plane coding, multi-tile 0x200, adaptive low-frequency gains) or a CR3 sRAW / mRAW. HEIF stills are separate `.HIF` files, not CR3. One file per body is pinned in -`canon_cr3_bodies_decode_like_the_camera_jpeg` (`crates/raw/tests/cr3_corpus.rs`; URLs and SHA-256 in +`canon_cr3_bodies_decode_like_the_camera_jpeg` (`crates/raw/tests/it/cr3_corpus.rs`; URLs and SHA-256 in `RAW_SAMPLES`, `xtask/src/main.rs`): EOS-1D X Mark III, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, R, R3, R5, R6, R6 Mark II, R6 Mark III, R7, R10, RP, R50, R50 V and PowerShot G5 X Mark II, G7 X Mark III, SX70 HS and V1, C-RAW where the body has a CC0 C-RAW sample. No external decoder was run: the sensor diff --git a/docs/denoise.md b/docs/denoise.md index 7c3f5138f..64354e805 100644 --- a/docs/denoise.md +++ b/docs/denoise.md @@ -117,8 +117,8 @@ Fresh checks can be run using an already installed model and CC0 raw samples: ```text LC_DENOISE_MODEL= cargo test --release -p lightcraft-gpu --lib real_model_on_the_gpu_matches_cpu -- --ignored --nocapture -LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_cpu -- --ignored --nocapture -LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_real -- --ignored --nocapture +LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features rawnind-model --test it denoise_cpu:: -- --ignored --nocapture +LC_DENOISE_MODEL= LC_DENOISE_RAW= cargo test --release -p lightcraft-engine --features rawnind-model --test it denoise_real:: -- --ignored --nocapture ``` ## Another computer's graphics card diff --git a/docs/fuji-lens-corrections.md b/docs/fuji-lens-corrections.md index 365c98e98..2546ef2cf 100644 --- a/docs/fuji-lens-corrections.md +++ b/docs/fuji-lens-corrections.md @@ -161,5 +161,5 @@ before demosaicing would be needed. vignette gain on the X-T4's real arrays, and the X-T4 geometry against its measured free fit. - `crates/raw/src/vendor/raf.rs`: a synthetic RAF through header and full decode, only for validated models, and a DNG round trip. -- `crates/raw/tests/corpus.rs` `fuji_embedded_lens_tables_follow_the_validated_models`: the corpus' `raf-fuji-*` files, +- `crates/raw/tests/it/corpus.rs` `fuji_embedded_lens_tables_follow_the_validated_models`: the corpus' `raf-fuji-*` files, or any folder of RAFs in `LIGHTCRAFT_FUJI` (the pixls set: 129 decoded, 88 warps, 98 vignette gains). diff --git a/docs/gaps.md b/docs/gaps.md index be411a205..329dcf531 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 28: ⌘L Enable Filters switches the filter off and on; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -43,7 +43,8 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: ⌘L (Enable Filters), Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | +| 30 | **Multi-select metadata editing in the UI**: `photo.setMeta` already iterates all selected photos, so the engine applies changes to every target, but the Info panel and All Metadata dialog always show one photo's values with no "mixed" indication when selected photos differ — users cannot tell whether a field is uniform or mixed across the selection before editing | standard in Lightroom Classic's multi-select Info panel | anyone editing metadata on a batch (copyright, keywords, custom fields) | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -51,7 +52,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Gaps by kind - **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19, 29 -- **UI / UX:** 4, 23, 24, 28 +- **UI / UX:** 4, 23, 24, 28, 30 - **File format:** 2, 6, 7, 10, 13, 20, 25 (raw ones in [raw-parity.md](raw-parity.md)) - **Hardware:** 14, 17, 22, 27 - **Localization:** 21 @@ -60,6 +61,10 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-22: saving XMP preserves foreign namespaces even when they use `lc:` or `xmp:` prefixes; importing them no longer interprets their properties as native settings or ratings. Parser, merge and save-command regressions cover the correction; no ranked gap or readiness estimate changes. + +- 2026-10-22: PSD/PSB imports and metadata readers now expose embedded EXIF resource 1058 and XMP resource 1060 for capture dates, GPS and All Metadata (#2519); imported PSD edit copies retain their capture date, and the `removeLocation` Original-export guard sees embedded PSD/PSB GPS. No ranked gap or headline feature count closes. + - 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes. - 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like @@ -79,6 +84,9 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-22 | patch | XMP namespace-prefix collisions no longer overwrite foreign metadata; ranked gaps and estimates unchanged | +| 2026-10-22 | patch | Issue #2519: PSD/PSB EXIF resource 1058 and XMP resource 1060 now use the common metadata extractor, and `.psb` files are accepted by import; no ranked gap or readiness count changes | +| 2026-10-22 | minor | Gap 30 added: multi-select metadata editing — Info panel and All Metadata dialog show one photo's values with no mixed-value indication | | 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) | | 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | | 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h | @@ -86,6 +94,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | | 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | | 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged | +| 2026-10-23 | minor | Gap 28: ⌘L / Ctrl+L (Library ▸ Enable Filters) puts the filter aside and brings it back as it was; the entry drops ⌘L; hours unchanged | | 2026-10-15 | minor | Gap 28: `\` shows / hides the Filter Bar in the grids; the entry drops `[` `]` (#509), flag cycling (#1490) and F1 (Help), already on main; ⌘L is the filter-bar key left; hours unchanged | | 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index b580c243a..c55e12965 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -229,7 +229,7 @@ passes on as `RenderResult::gpu_fallback`; an export carries it as `Exported::gp each file of an `app.export` batch result has `gpuFallback` when its render fell back (issue #675). **Reproducing a smaller GPU:** `LIGHTCRAFT_GPU_LIMITS=webgpu` (or `downlevel`) creates the device with 128 MiB storage bindings / 256 MiB buffers, `LIGHTCRAFT_GPU_LIMITS=` with n MiB / 2n MiB. -Tests inject failures with `lightcraft_gpu::inject_fault` (`crates/gpu/tests/fallback.rs`, +Tests inject failures with `lightcraft_gpu::inject_fault` (`crates/gpu/tests/it/fallback.rs`, `export_falls_back_to_the_cpu_when_gpu_work_is_lost`). The fault is thread-local, and so are its consequences: a simulated fatal failure stops GPU use on the injecting thread only (`available()` false, `unavailable_reason()` set there), until `reset_failures()`, which undoes nothing else — a @@ -237,7 +237,7 @@ real failure still stops the whole process. Such a test asserts on its own rende `last_fallback()`, so it can run beside other tests rendering on the GPU (issue #675). ## Correctness: CPU oracle and equivalence tests -`crates/gpu/tests/equivalence.rs` renders the same settings on both and compares 8-bit sRGB: +`crates/gpu/tests/it/equivalence.rs` renders the same settings on both and compares 8-bit sRGB: bounds **mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB** per channel. Measured (Apple M4 Pro, Metal): **max 1 LSB, mean ≤ 0.0003 LSB** for every case — 26 settings cases (all tone/colour/effects tools, 3 vignette styles, grain, curves, grading, B&W, WB, NR, crop/straighten/flip, lens + perspective, @@ -254,7 +254,7 @@ cut back after the readback. The tests skip (pass with a note) when no adapter e non-Apple GPU verified: all 16 tests pass, 135 compared cases. 124 differ by max 1 LSB, 10 are bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002% of pixels. Worst mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds -(`cargo test -p lightcraft-gpu --release --test equivalence -- --nocapture`). +(`cargo test -p lightcraft-gpu --release --test it equivalence:: -- --nocapture`). Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on Metal and summation order in the box filters — all far below one 8-bit step. diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 211b00b76..81bc2cede 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -17,6 +17,7 @@ Select photos in Photo Grid or Square Grid, then use: | `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | | `\` (backslash) | Show / hide the Filter Bar | +| ⌘L / Ctrl+L | Enable Filters: switch the filter off and on | Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail. diff --git a/docs/localization-cs.md b/docs/localization-cs.md index 52fad4542..c3ed1ddcf 100644 --- a/docs/localization-cs.md +++ b/docs/localization-cs.md @@ -46,8 +46,9 @@ ani anglické zdrojové řetězce. Rozložení panelů a seznamu jazyků je beze ## Ověření a revize Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál, -uložení jazyka, pokrytí katalogů, placeholdery, neutrální formulace počtů, datum, čas -a české glyfy. Překladové šablony kontroluje také Rust při sestavení. +uložení jazyka, placeholdery, datum, čas a české glyfy. Překladové šablony kontroluje také +Rust při sestavení. Chybějící překlady se zobrazují anglicky a vypisuje je +`i18n::tests::translation_gaps_report`; žádný test kvůli nim neselže. Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack, „bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení. diff --git a/docs/localization-de.md b/docs/localization-de.md index 0ceb3b45b..aa058af9d 100644 --- a/docs/localization-de.md +++ b/docs/localization-de.md @@ -29,8 +29,8 @@ cargo test -p lightcraft-ui-egui i18n::tests LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000 ``` -Die Tests prüfen Katalogabdeckung, Platzhalter, Sprachwechsel per Menü und Steuerkanal, -Speicherung, Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute -und ß. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft +Die Tests prüfen Platzhalter, Sprachwechsel per Menü und Steuerkanal, Speicherung, +Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute und ß. Fehlende +Übersetzungen erscheinen auf Englisch; `i18n::tests::translation_gaps_report` listet sie auf. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`; gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md). diff --git a/docs/localization-fi.md b/docs/localization-fi.md index d80a2e1cc..fcb89d784 100644 --- a/docs/localization-fi.md +++ b/docs/localization-fi.md @@ -52,8 +52,9 @@ englanniksi. Käännös ei muuta kirjastotietoja eikä englanninkielisiä lähde ## Testaus `i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta, -kielen tallennuksen, luetteloiden kattavuuden, paikkamerkit, määrämuodot, päivämäärän, ajan ja -suomen kirjaimet. Rust tarkistaa muotoilumallit käännösvaiheessa. +kielen tallennuksen, paikkamerkit, päivämäärän, ajan ja suomen kirjaimet. Rust tarkistaa +muotoilumallit käännösvaiheessa. Puuttuvat käännökset näkyvät englanniksi, ja +`i18n::tests::translation_gaps_report` luettelee ne; mikään testi ei epäonnistu niiden takia. Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading), ”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing), diff --git a/docs/localization-ka.md b/docs/localization-ka.md index 4d517c6ca..e26f6e4a4 100644 --- a/docs/localization-ka.md +++ b/docs/localization-ka.md @@ -19,7 +19,7 @@ not translated. Descriptions for SAM 3 go to the model unchanged. ## Georgian script Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the -interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts +interface never uses Mtavruli. Commands are verbal nouns. Counts are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with `{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`. @@ -73,7 +73,7 @@ cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000 ``` -The tests check catalog coverage, placeholders, switching language by menu and control channel, -saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case -keeps Georgian lowercase. More notes are in +The tests check placeholders, switching language by menu and control channel, saving, the `ka` +locale tags, dates and that sentence case keeps Georgian lowercase. Messages Georgian lacks show in +English; `i18n::tests::translation_gaps_report` lists them. More notes are in [localization.md](localization.md). diff --git a/docs/localization-vi.md b/docs/localization-vi.md index 2bd6c74e0..02862bc4c 100644 --- a/docs/localization-vi.md +++ b/docs/localization-vi.md @@ -66,8 +66,9 @@ LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi.png --size 1600x1000 LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800 ``` -Các test tiếng Việt bắt buộc phủ catalog union, nhãn lệnh/control/Settings/Smart Album, -đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng, và glyph dấu. +Các test tiếng Việt kiểm tra đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng và glyph +dấu. Chuỗi còn thiếu hiện bằng tiếng Anh và được `i18n::tests::translation_gaps_report` liệt kê; +không test nào thất bại vì thiếu bản dịch. ## Nguồn dịch (clean-room) diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index e87431582..bf41dfed5 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -34,5 +34,6 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日 `LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 -翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由 +两种目录的占位符是否一致、字形是否齐全,由 `cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。 +缺少的译文显示为英文,由 `i18n::tests::translation_gaps_report` 列出,不会让测试失败。 diff --git a/docs/localization.md b/docs/localization.md index 94abfe236..5b4cbc15d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -12,11 +12,10 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h 1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to `locales/.json` and `locales/-formats.json`, then translate the values. Keys are the - English source strings and must stay byte-for-byte identical. The plain catalog may grow at its - own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests - -- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The - formats catalog must carry every message (the build fails otherwise; a message copied in English, - value equal to its key, is an untranslated placeholder until someone translates it), including the date + English source strings and must stay byte-for-byte identical. Both catalogs may grow at their + own pace: a message a catalog lacks, or translates as an empty string, shows in English, and + nothing fails. The translation gaps report lists what each language lacks (see + [Testing and looking at it](#testing-and-looking-at-it)). The formats catalog also holds the date patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and capture times use in every language but English; weekday names go in the plain catalog. 2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`: @@ -81,18 +80,23 @@ tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album `"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`). - `format!` rejects an unused argument, so a value a language does not need — the English plural suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing). -- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a - `tr_format!` call whose message is in no catalog does not compile: add it to every - `*-formats.json` file. +- A language whose formats catalog lacks a message shows the English source there, and a + `tr_format!` call whose message is in no catalog formats the English source (the macro's last + arm). A new message builds with no catalog entry. ## Testing and looking at it ```sh -cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands +cargo test -p lightcraft-ui-egui i18n::tests # catalogs load, placeholders, switching CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage +cargo test -p lightcraft-ui-egui --lib -- --exact i18n::tests::translation_gaps_report --nocapture ``` -The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then). +The tests fail on a broken catalog (malformed JSON, a repeated key, placeholders that differ from +the source), never on a missing translation. The last command is the translation gaps report: per +language, the count and list of plain and format messages it lacks and the number of untranslated +What's New lines. It always passes, and `cargo xtask ci` prints it after its test step. The glyph +tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then). To look at a language, render it headless: ```sh @@ -114,5 +118,5 @@ when a region has no dedicated entry. Help ▸ What's New shows `docs/whats-new.md`, and each of its lines (a heading, or one wrapped line of a bullet, without the `- ` or `### `) is looked up like any message, so a catalog can translate the notes line by line (German does). A line that is re-wrapped or edited no longer matches its translation and -shows in English until the catalog is updated; untranslated lines are reported by -`display_label_gaps_are_reported`, never failed. +shows in English until the catalog is updated; the translation gaps report counts the untranslated +lines. diff --git a/docs/mcp.md b/docs/mcp.md index af4c53ca9..31e069de9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -316,10 +316,10 @@ lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (d ## Tests -- `crates/mcp/tests/e2e.rs` — M0.9 acceptance: over the stdio framing, set exposure and render; +- `crates/mcp/tests/it/e2e.rs` — M0.9 acceptance: over the stdio framing, set exposure and render; checks the decoded PNG gets brighter/darker. Runs headless and through the TCP transport (`Remote`) against a stand-in control server; also import → render → JPEG export of a real file. -- `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`; +- `apps/lightcraft-cli/tests/it/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`; `commands`. ## Add to Apple Photos (macOS) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9bcd04286..356a9741b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: At launch opens the last library or asks with a list of recent ones, one recent entry can be removed, #1290; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: At launch opens the last library or asks with a list of recent ones, one recent entry can be removed, #1290; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -33,7 +33,7 @@ cited here still exists. Formerly docs/parity.md. | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | -| H. Crop & rotate (CROP) | 11 | 0 | 0 | 1 | 8/8 (100%) | 3/3 (100%) | +| H. Crop & rotate (CROP) | 10 | 1 | 0 | 1 | 8/8 (100%) | 2/3 (67%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | | K. Masking (MASK) | 15 | 5 | 5 | 0 | 7/8 (88%) | 7/7 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 25 | 21 | 8 | — | 24/26 (92%) | -| **Total** | 422 | 63 | 57 | 35 | 196/203 (97%) | 148/161 (92%) | +| **Total** | 421 | 64 | 57 | 35 | 196/203 (97%) | 147/161 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.7%** of 542 in-scope rows — P0 98.3% of 203 · P1 95.0% of 161 · P2 56.5% of 177. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.6%** of 542 in-scope rows — P0 98.3% of 203 · P1 94.7% of 161 · P2 56.5% of 177. ## Top gaps and shortcut notes @@ -80,8 +80,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/it/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); folder discovery and file pickers include Sony SR2 and SRF, with the same extension list used by CLI/MCP: DSC-R1 SR2 decodes from sensor data; DSC-F828 SRF stays preview-only; transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/it/corpus.rs`, `crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/it/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `crates/raw/src/dng.rs` (`decode_reduced`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -168,8 +168,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | -| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | +| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return); on a straightened photo a moved box slides along the tilted edges to where the pointer asks, so a box that fills the photo's height still reaches its sides (issue #2299) | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | -| LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | +| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required); turns by at most 10° (`STRAIGHTEN_MAX_DEGREES`) and only on at least 3 agreeing lines (`STRAIGHTEN_MIN_LINES`), and says so when it finds nothing (`auto_straighten_says_when_it_finds_nothing`). Measured on 68 photos straightened by hand in Lightroom Classic and 300 left as shot: it levels 14 of the 68 within 0.5° (1 more than 1° off) and turns 19 of the 300 by more than 1° (before the limits: 79, some by 20° or more). 🟡 because it still leaves most tilted photos alone: a soft sea horizon among wave crests, and verticals that converge in perspective, aren't used yet | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | | LR-GUIDED-SELECT | Guide selection in Guided Upright mode | P0 | ✅ | `cmd:tool.guidedUpright`, `cmd:geometry.guides`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/state.rs` (`selected_guide`), `crates/ui-egui/src/tests_guided_guides.rs` | a press near a guide's line selects it (`selected_guide = Some((photo id, index))`; another press on it deselects), a press on an empty spot starts a new guide at that point, and an endpoint drags to reshape the guide (`Gesture::GuideEndpoint`, one undo step; clicking an endpoint only selects); tested in `tests_guided_guides` 8/8 | | LR-GUIDED-DELETE | Delete removes the selected guide alone | P0 | ✅ | `cmd:photo.delete`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_guided_guides.rs` | with the Guided Upright tool open (`RightPanel::Crop && tool == "guidedUpright"`), Delete / Backspace removes only the selected guide and clears the selection; with nothing selected it removes nothing (the photo itself is never deleted while the tool is open); tested in `tests_guided_guides` | @@ -403,11 +403,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | -| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/lossy_dng.rs`); no embedded JPEG preview size | +| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/it/lossy_dng.rs`); no embedded JPEG preview size | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | -| LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | +| LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/it/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). Imported PSD/PSB photos retain embedded capture dates; PSD edit copies retain EXIF/XMP fields in the catalog and All Metadata. Original with Remove Location Info is refused when embedded PSD/PSB GPS is detected. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -479,7 +479,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | -| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | +| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/it/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/it/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Dialogs with one name field (New Album, New Smart Album, Save Metadata Preset, the renames) take the focus once when they open, so Tab and Shift+Tab reach their check box, Cancel and OK, and Return and Esc still confirm and cancel (`crates/ui-egui/src/tests_dialog_tab.rs`). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | | LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese, Thai and Georgian faces in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | @@ -779,7 +779,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | | KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks keys | -| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus; `\` shows / hides the Filter Bar in the grids (Show Original in Detail). Missing: Shift+P outside grids (Presets), ⌘L Enable Filters | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag`, `cmd:library.toggleFilters` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus; `\` shows / hides the Filter Bar in the grids (Show Original in Detail); ⌘L (Ctrl+L) Enable Filters puts the filter aside and brings it back as it was. Missing: Shift+P outside grids (Presets) | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | @@ -799,6 +799,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-23 | minor | LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: Settings ▸ General ▸ At launch (Open the last library / Ask me) with a launch window listing the recent libraries, Alt/Option at launch asks once, File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}` (issue #1290); photos dropped while no library is open wait for the one opened (presets are refused then); status and counts unchanged (✅) | +| 2026-10-22 | patch | LR-META-XMP: namespace URI determines ownership; foreign namespaces using recognized prefixes survive saves and cannot supply native settings or ratings; status and counts unchanged | +| 2026-10-22 | patch | LR-EXP-PSD, LR-META-EXIF-FULL: PSD/PSB imports and image resources 1058/1060 now supply EXIF and XMP to metadata readers and All Metadata; Original with Remove Location Info refuses detected PSD/PSB GPS; statuses and feature counts unchanged | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | | 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | @@ -928,3 +930,6 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged | | 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) | | 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) | +| 2026-10-22 | minor | MENU-BAR: on macOS the native menu gives a key back to the window while a text field has focus or a shortcut is being recorded (muda's `set_accelerator(None)` does nothing there, so the menu item is now swapped for one without a key, and the key is put back on that item afterwards); status unchanged (✅) | +| 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | +| 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2923ea880..bd17a3d32 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -46,11 +46,11 @@ unsupported to preview-only in the counts; sensor-decoding readiness is unchange version. The year a model was added is approximated from that Camera Raw major version. - **What we decode**: the decoders and their refusals in `crates/raw/src` (`lib.rs` routing, `vendor/*.rs` coding variants, `crx.rs` CRX limits, `orf.rs` / `srw.rs` compressed refusals), the import list - `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc + `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/it/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 105 CC0 files; bodies whose sample is in - `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; + `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/it/cr3_corpus.rs` (3 bodies exact against an external decoder; the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files; 22 more bodies pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue @@ -64,7 +64,7 @@ unsupported to preview-only in the counts; sensor-decoding readiness is unchange decoded on main `25ef65c8`; [raw/coverage-sweep.tsv](raw/coverage-sweep.tsv) records the URLs, SHA-256, dimensions, FNV-1a sensor hashes and render/reference luminance correlations (0.6801 to 0.9998). The hashes pin LightCraft's own output. The regression test is - `crates/raw/tests/coverage_sweep_corpus.rs`; downloads are in `RAW_SAMPLES`. Ten references are + `crates/raw/tests/it/coverage_sweep_corpus.rs`; downloads are in `RAW_SAMPLES`. Ten references are embedded TIFF images; the rest are JPEGs. FFF TIFF previews are not display-ready and check image structure only. Eleven candidate models were excluded for identity, provenance or missing/mismatched reference images. D6 / Z 6 / Z 7 verification covers compressed NEF only; diff --git a/docs/raw-preview-reads.md b/docs/raw-preview-reads.md index a16b9a333..5d2467da7 100644 --- a/docs/raw-preview-reads.md +++ b/docs/raw-preview-reads.md @@ -144,15 +144,15 @@ use the largest JPEG, so their geometry and colour do not depend on the size lev sRGB stays on the fast path. A synthetic DNG with a full-size and a half-size JPEG gets, on the plan path, the half-size one for a box it covers and the full-size one otherwise, with the same pixels as the whole-file path (`preview_loading_plans_the_smallest_covering_jpeg`). -- `crates/raw/tests/sparse_corpus.rs`: `preview_plan` is asked for **every** corpus file, not only +- `crates/raw/tests/it/sparse_corpus.rs`: `preview_plan` is asked for **every** corpus file, not only the ones whose header plan is `Ready`, and the preview it yields must equal the whole file's with the skipped bytes `0` and `0xFF`. The test prints what each format reads. -- `crates/engine/tests/preview_read.rs` (opt-in, `#[ignore]`): the same measurement on a **real** +- `crates/engine/tests/it/preview_read.rs` (opt-in, `#[ignore]`): the same measurement on a **real** file, typically on a share: ```sh LC_PREVIEW_RAW="/mnt/nas/…/DSCF0001.RAF" \ - cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture + cargo test --release -p lightcraft-engine --test it preview_read:: -- --ignored --nocapture ``` No raw fixture is committed (media never is, see `AGENTS.md`). diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index a9a5f521f..3db93ce81 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -29,7 +29,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) # Olympus ORF bodies that write uncompressed / packed data (crates/raw/src/vendor/orf.rs). Everything else # writes compressed ORF, which is preview-only (the E-M5 II / PEN-F high-res mode alone is decoded). -ORF_DECODED = re.compile(r"^(E-1|E-10|E-20|EVOLT E-300|EVOLT E-330|EVOLT E-400|EVOLT E-500|E-300|E-330|E-500|Camedia C-\S+.*|Stylus XZ-2 iHS)$") +ORF_DECODED = re.compile(r"^(E-1|E-10|E-20|EVOLT E-300|EVOLT E-330|EVOLT E-400|EVOLT E-500|E-300|E-330|E-500|Camedia C-\S.*|Stylus XZ-2 iHS)$") # Samsung SRW bodies with compressed raw data (crates/raw/src/vendor/srw.rs refuses 32770 / 32772 / 32773). SRW_COMPRESSED = re.compile(r"(^|\W)(NX1|NX30|NX300|NX300M|NX500|NX2000|NX3000|NX3300|NX mini|EK-GN\d+|EK-KN\d+)(\W|$)") # Fujifilm bodies before the X series whose RAF has no raw IFD (crates/raw/src/vendor/raf.rs). @@ -123,7 +123,7 @@ def main(): classes = ["verified", "unverified", "preview", "unsupported"] by_maker = {} for r in rows: - by_maker.setdefault(r["maker"], {c: 0 for c in classes})[r["class"]] += 1 + by_maker.setdefault(r["maker"], dict.fromkeys(classes, 0))[r["class"]] += 1 print("| Maker | Adobe models | verified | unverified | preview | unsupported |") print("|---|---:|---:|---:|---:|---:|") for m, c in sorted(by_maker.items(), key=lambda kv: -sum(kv[1].values())): diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc5808471..c062ea95e 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -73,7 +73,7 @@ Apple iPhone 12 Mini Rear Wide Camera DNG 2020 unverified DNG decoder (uncompres Apple iPhone 12 Ultrawide camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 12 Mini Ultrawide camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 12 Pro Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 12 Pro Rear Wide Camera DNG 2020 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 12 Pro Rear Wide Camera DNG 2020 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Apple iPhone 12 Pro Rear Telephoto Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 12 Pro Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 12 Pro Max Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -97,16 +97,16 @@ Apple iPad Pro 12.9-inch (2018 model) DNG 2018 unverified DNG decoder (uncompres Apple iPad Pro 13-inch M4 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad mini (6th Generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 6s DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 6s Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone 6s Plus DNG 2015 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 7 Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone 7 Plus DNG 2015 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 8 Plus DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (2020 version) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone X DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone XR DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone XS DNG 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone XS DNG 2018 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone XS Max DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad Air (5th generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (3rd generation) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -116,117 +116,117 @@ Apple iPad mini (A17 Pro) Rear Camera DNG 2024 unverified DNG decoder (uncompres Canon EOS-1D TIF 2003 unsupported TIF is not imported Canon EOS-1D C CR2 2012 unverified CR2 lossless JPEG decoder Canon EOS-1D Mark II CR2 2003 unverified CR2 lossless JPEG decoder -Canon EOS-1D Mark II N CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-1D Mark III CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-1D Mark IV CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark II N CR2 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark III CR2 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark IV CR2 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS-1Ds TIF 2003 unsupported TIF is not imported Canon EOS-1Ds Mark II CR2 2005 unverified CR2 lossless JPEG decoder Canon EOS-1Ds Mark III CR2 2007 unverified CR2 lossless JPEG decoder Canon EOS-1D X CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS-1D X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-1D X Mark III CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS 5D CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 5D Mark II CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS-5D Mark III CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS-5D Mark IV (** see note) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 5DS CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 5DS R CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 6D CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 6D Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 7D CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 7D Mark II CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D X Mark II CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D X Mark III CR3 2019 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 5D CR2 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5D Mark II CR2 2008 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS-5D Mark III CR2 2012 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS-5D Mark IV (** see note) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5DS CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5DS R CR2 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 6D CR2 2012 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 6D Mark II CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 7D CR2 2008 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 7D Mark II CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 10D CRW 2003 preview CRW is opened from its embedded JPEG only -Canon EOS 20D CR2 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 20D CR2 2003 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 20Da CR2 2005 unverified CR2 lossless JPEG decoder -Canon EOS 30D CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 40D CR2 2007 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 50D CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 60D CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 30D CR2 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 40D CR2 2007 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 50D CR2 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 60D CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 60Da CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 70D CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 77D (EOS 9000D) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 80D CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 90D CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 70D CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 77D (EOS 9000D) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 80D CR2 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 90D CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS D30 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS D60 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW 2003 preview CRW is opened from its embedded JPEG only -Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) CR2 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) CR2 2010 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS C50 CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M2 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M200 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS M3 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M5 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M50 Mark II CR3 2020 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS M6 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M6 Mark II CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS M10 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M50 (EOS Kiss M) CR3 2017 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS M100 CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS R (** see note) CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M2 CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M200 CR3 2019 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M3 CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M5 CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M50 Mark II CR3 2020 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M6 CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M6 Mark II CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M10 CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M50 (EOS Kiss M) CR3 2017 verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS M100 CR2 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS R (** see note) CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R1 CR3 2023 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R3 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R5 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R3 CR3 2021 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R5 CR3 2019 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R5 C CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R5 Mark II CR3 2023 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files -Canon EOS R6 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R5 Mark II CR3 2023 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files +Canon EOS R6 CR3 2019 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R6 V CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R6 Mark II CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; C-RAW with adaptive QP above 168 still opens as the preview) -Canon EOS R6 Mark III CR3 2025 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R7 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; lossless only, C-RAW unverified) +Canon EOS R6 Mark II CR3 2022 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; C-RAW with adaptive QP above 168 still opens as the preview) +Canon EOS R6 Mark III CR3 2025 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R7 CR3 2021 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; lossless only, C-RAW unverified) Canon EOS R8 Mark II CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R10 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R10 CR3 2021 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS Ra CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS RP CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R8 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS R50 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R50 V CR3 2024 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R100 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS RP CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R8 CR3 2022 verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS R50 CR3 2022 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 V CR3 2024 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R100 CR3 2022 verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon PowerShot 600 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot A5 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot A50 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G1 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G1 X CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G1 X Mark II CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G1 X Mark III CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark II CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark III CR2 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G2 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G3 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G3 X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G3 X CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G5 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G5 X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G5 X Mark II CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G5 X CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G5 X Mark II CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon PowerShot G6 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G7 X CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G7 X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G7 X Mark III CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon PowerShot G9 CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G9X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G9 X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark II CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark III CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G9 CR2 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9X CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 X Mark II CR2 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G10 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot G11 CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G12 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G15 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G16 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G11 CR2 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G12 CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G15 CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G16 CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot Pro 1 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot Pro70 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot Pro90 IS CRW 2003 preview CRW is opened from its embedded JPEG only @@ -236,17 +236,17 @@ Canon PowerShot S45 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S50 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S60 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S90 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot S95 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S95 CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot S70 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot S100 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S100V CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S110 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S120 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100 CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100V CR2 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S110 CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S120 CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot SX1 IS CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot SX50 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot SX60 HS CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot SX70 HS CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon PowerShot V1 CR3 2024 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot SX50 CR2 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX60 HS CR2 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX70 HS CR3 2018 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot V1 CR3 2024 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Casio EXILIM EX-10 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-100 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-100F DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -274,9 +274,9 @@ Casio EXILIM EX-ZR4100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, loss Casio EXILIM EX-ZR5000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-ZR5100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Contax N Digital RAW 2003 preview RAW is opened from its embedded JPEG only -DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI AIR 2S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mini 2 DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mini 2 DNG 2020 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Air 3 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mini 3 Pro DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -287,48 +287,48 @@ DJI DJI Mavic 3 Cine (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncom DJI DJI Mavic 3 Pro Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Osmo Pocket 3 D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Osmo Action 5 Pro DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Epson R-D1 ERF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Epson R-D1s ERF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Epson R-D1x ERF 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 50R RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 50S RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 50S II RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 100 RAF 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100 II RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Epson R-D1 ERF 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1s ERF 2005 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1x ERF 2008 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 50R RAF 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 50S RAF 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 50S II RAF 2020 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100 RAF 2018 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100 II RAF 2022 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100 II IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm GFX 100 IR RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm GFX 100S RAF 2020 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100S II RAF 2023 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 100RF (Final Support) RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100S RAF 2020 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100S II RAF 2023 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100RF (Final Support) RAF 2024 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Fujifilm FinePix E900 RAF 2005 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F505 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix F550 EXR RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F550 EXR RAF 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix F600 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F605 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F700 RAF 2003 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix F770 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F770 EXR RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix F775 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F800 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F900 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix HS10 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm FinePix HS20 EXR RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm FinePix HS22 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS30 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix HS33 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS30 EXR RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS33 EXR RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix HS35 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS50 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS50 EXR RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix IS-1 RAF 2007 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix IS Pro RAF 2008 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix S1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix S1 RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix S100fs RAF 2007 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S2 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S20 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD @@ -342,64 +342,64 @@ Fujifilm FinePix S6000fd (FinePix S6500fd) RAF 2005 preview pre-X FinePix RAF wi Fujifilm FinePix S7000 Z RAF 2003 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S9000 (FinePix S9500) RAF 2005 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S9100 (FinePix S9600) RAF 2005 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix SL1000 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X10 RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X100 RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X-M1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X-S1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100F RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100S RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100T RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100V** RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100VI RAF 2023 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X20 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X30 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X70 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A3 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A5 RAF 2017 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A7 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A10 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix SL1000 RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X10 RAF 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X100 RAF 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-M1 RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-S1 RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100F RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100S RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100T RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100V** RAF 2019 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100VI RAF 2023 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X20 RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X30 RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X70 RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A1 RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A2 RAF 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A3 RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A5 RAF 2017 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A7 RAF 2018 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A10 RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-A20 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-E1 RAF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-E2 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E2S RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E3 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E4 RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-H1 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E1 RAF 2012 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-E2 RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2S RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E3 RAF 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E4 RAF 2020 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E5 RAF 2024 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-H1 RAF 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-H2 IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm X-H2S RAF 2021 verified docs/raf-compression.md (supplied files, exact sensor arrays) -Fujifilm X-M5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-Pro1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-Pro2 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-Pro3** RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-S10 RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-S20 RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-M5 RAF 2024 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-Pro1 RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro2 RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro3** RAF 2019 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S10 RAF 2020 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S20 RAF 2022 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T1 RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T1 IR RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T3 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T4** RAF 2019 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T5 RAF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T10 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T20 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T30 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T30 II RAF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T30 III RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T50 RAF 2023 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T100 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T200 RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XF1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XF10 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XQ1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XQ2 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-H2 RAF 2021 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T2 RAF 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T3 RAF 2018 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T4** RAF 2019 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T5 RAF 2022 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T10 RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T20 RAF 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T30 RAF 2018 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 II RAF 2021 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 III RAF 2022 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T50 RAF 2023 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T100 RAF 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T200 RAF 2019 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF1 RAF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF10 RAF 2017 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ1 RAF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ2 RAF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-H2 RAF 2021 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Google Pixel DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel XL DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 2 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 2 XL DNG 2017 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Google Pixel 2 XL DNG 2017 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Google Pixel 3 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 3 Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 3 Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -407,7 +407,7 @@ Google Pixel 3 XL DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, De Google Pixel 3 XL Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 3 XL Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4 XL DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4 XL DNG 2019 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5 Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -415,7 +415,7 @@ Google Pixel 6 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, Google Pixel 6 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 6 Pro Rear Main Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 6 Pro Rear Main Camera DNG 2021 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 6 Pro Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -426,7 +426,7 @@ Google Pixel 7 Pro Telephoto Camera DNG 2022 unverified DNG decoder (uncompresse Google Pixel 7 Pro Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Pro Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4a Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4a Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Main Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Ultra Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -442,7 +442,7 @@ Google Pixel 8 Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, Google Pixel 8 Pro Front Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Telephoto Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Ultrawide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 8 Pro Wide Camera* DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 8 Pro Wide Camera* DNG 2023 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 8a Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -502,7 +502,7 @@ Hasselblad CF-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-39 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFV 3FR 2008 preview 3FR is opened from its embedded JPEG only -Hasselblad CFV-50c 3FR 2008 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad CFV-50c 3FR 2008 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H2D DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Hasselblad H2D-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H2D-39 3FR 2008 preview 3FR is opened from its embedded JPEG only @@ -515,18 +515,18 @@ Hasselblad H3DII-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H3DII-50 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-40 3FR 2010 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-60 3FR 2010 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-40 3FR 2012 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-40 3FR 2012 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-50 3FR 2012 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-50c 3FR 2013 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-50c 3FR 2013 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Hasselblad Lunar 3FR 2012 verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D 3FR 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D 3FR 2015 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D II 50C 3FR 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration -Hasselblad X2D 100C 3FR 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D II 50C 3FR 2018 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C 3FR 2021 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Huawei Honor 20 Pro Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -557,12 +557,12 @@ Konica Minolta Alpha Sweet Digital (Japan) MRW 2005 unverified MRW decoder (12-b Konica Minolta DiMAGE 5 MRW 2003 unverified MRW decoder (12-bit packed, 16-bit words) Konica Minolta DiMAGE 7 MRW 2003 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) Konica Minolta DiMAGE 7i MRW 2003 unverified MRW decoder (12-bit packed, 16-bit words) -Konica Minolta DiMAGE 7Hi MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Konica Minolta DiMAGE A1 MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE 7Hi MRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A1 MRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Konica Minolta DiMAGE A2 MRW 2003 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) -Konica Minolta DiMAGE A200 MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A200 MRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Konica Minolta Maxxum 5D Dynax 5D MRW 2005 unverified MRW decoder (12-bit packed, 16-bit words) -Konica Minolta Maxxum 7D Dynax 7D MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta Maxxum 7D Dynax 7D MRW 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Leaf AFi II 6 MOS 2008 preview MOS is opened from its embedded JPEG only Leaf AFi II 7 MOS 2008 preview MOS is opened from its embedded JPEG only Leaf Aptus 17 MOS 2007 preview MOS is opened from its embedded JPEG only @@ -586,8 +586,8 @@ Leaf Credo 50 IIQ 2013 preview IIQ is opened from its embedded JPEG only Leaf Credo 60 IIQ 2012 preview IIQ is opened from its embedded JPEG only Leaf Credo 80 IIQ 2012 preview IIQ is opened from its embedded JPEG only Leica C (Typ 112) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Leica CL DNG 2017 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica C-Lux DNG 2017 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica CL DNG 2017 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica C-Lux DNG 2017 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica D-Lux 2 RAW 2005 unverified Panasonic decoder, every raw format Leica D-Lux 3 RAW 2005 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body Leica D-Lux 4 RWL 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format @@ -601,26 +601,26 @@ Leica Digilux 2 RAW 2003 verified PR #215: raw.pixls.us bulk check of every RW2 Leica Digilux 3 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica Digital-Modul-R DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Leitz Phone 3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M8 DNG 2003 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) -Leica M9 DNG 2008 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) -Leica M10 DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M8 DNG 2003 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) +Leica M9 DNG 2008 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) +Leica M10 DNG 2015 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica M10-D DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M10-P DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M10-R DNG 2019 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M10-R DNG 2019 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica M11-D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-D (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-E DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-EV1 DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-P DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M Monochrom DNG 2012 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) -Leica M Monochrom (Typ 246) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M Monochrom DNG 2012 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) +Leica M Monochrom (Typ 246) DNG 2015 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) Leica M10 Monochrom DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11 MONOCHROM DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11-P DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica Q (Typ 116) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica Q2 DNG 2018 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica Q (Typ 116) DNG 2015 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica Q2 DNG 2018 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica Q2 Monochrom DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q3 DNG 2022 verified recorded check 2026-10-11: 38 private Q3 DNGs (landscape and portrait) decoded from sensor data and developed at full size without error, mean CIE76 dE to the camera JPEG 6.3-13.9 at 400 px (not committed; docs/raw-parity.md) Leica Q3 43 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -628,8 +628,8 @@ Leica Q3 Monochrom DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, D Leica S2 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S (Typ 007) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S3 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica SL (Typ 601) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica SL2 DNG 2019 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL (Typ 601) DNG 2015 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL2 DNG 2019 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica SL2-S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica SL3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica SL3-P DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -643,7 +643,7 @@ Leica V-LUX 3 RAW 2010 unverified Panasonic decoder, every raw format Leica V-LUX 4 RWL 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 5 RWL 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX (Typ 114) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Leica X2 RWL 2012 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica X2 RWL 2012 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica X (Typ 113) DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica X-U (Typ 113) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) LG G7 ThinQ DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -673,108 +673,108 @@ Mamiya Mamiya M18 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M22 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M31 TIF 2008 unsupported TIF is not imported Mamiya Mamiya DM40 MOS 2010 preview MOS is opened from its embedded JPEG only -Mamiya ZD MEF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Mamiya ZD MEF 2005 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Motorola Moto G Power (2021) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Macro Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Nikon 1 J1 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J2 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J3 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J4 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J5 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 S1 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J1 NEF 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J2 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J3 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J4 NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J5 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 S1 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon 1 S2 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 V1 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 V2 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 V3 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V1 NEF 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V2 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V3 NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX 5000 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX 5400 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX 5700 NEF 2003 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Nikon COOLPIX 8400 NEF 2005 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Nikon COOLPIX 8700 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX 8800 NEF 2005 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) -Nikon COOLPIX A NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX A1000 NRW 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX B700 NRW 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon COOLPIX A NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A1000 NRW 2018 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX B700 NRW 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Nikon COOLPIX P330 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P340 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P1000 NRW 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX P1100 NRW 2024 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1000 NRW 2018 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1100 NRW 2024 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX P6000 NRW 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7000 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7100 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7700 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7800 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P950 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P950 NEF 2019 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D1 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D1H NEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D1H NEF 2003 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D1X NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2H NEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D2Hs NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D2X NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D2Xs NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3s NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3X NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D4 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D4S NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D40 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D40x NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D50 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D60 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D6 NEF 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) -Nikon D70 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D70S NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D80 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D90 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2H NEF 2003 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Hs NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2X NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Xs NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3 NEF 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3s NEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3X NEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4S NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40 NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40x NEF 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D50 NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D60 NEF 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D6 NEF 2019 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon D70 NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70S NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D80 NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D90 NEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D100 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D200 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D300 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D300s NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D500 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D600 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D610 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D700 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D750 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D780 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D800 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D800E NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D810 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D200 NEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300 NEF 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300s NEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D500 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D600 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D610 NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D700 NEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D750 NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D780 NEF 2019 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800E NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D810 NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D810A NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D850 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3000 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3100 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3200 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3300 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3400 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3500 NEF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5000 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5100 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D5200 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5300 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5500 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5600 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D7000 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D7100 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D7200 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D7500 NEF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon Df NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 30 NEF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 50 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 50 II NEF 2024 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 5 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D850 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3000 NEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3100 NEF 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3200 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3300 NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3400 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3500 NEF 2018 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5000 NEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5100 NEF 2010 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon D5200 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5300 NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5500 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5600 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7000 NEF 2010 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon D7100 NEF 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7200 NEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7500 NEF 2015 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon Df NEF 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 30 NEF 2021 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 NEF 2019 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 II NEF 2024 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 5 NEF 2019 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 5 2 NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2C NEF 2025 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 6 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) -Nikon Z 6 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 6 NEF 2018 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 6 II NEF 2020 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 6 III NEF 2023 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 7 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) -Nikon Z 7 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 8 NEF 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only -Nikon Z 9 (*See note) NEF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z fc NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z f NEF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon Z 7 NEF 2018 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 7 II NEF 2020 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 8 NEF 2022 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only +Nikon Z 9 (*See note) NEF 2021 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z fc NEF 2020 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z f NEF 2022 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Nikon ZR NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nokia Lumia 1020 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Nokia HMD Global Nokia 9 PureView Front Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -788,13 +788,13 @@ OM Digital Solutions OM-5 Mark II ORF 2024 preview compressed ORF is not decoded OM Digital Solutions OM-3 Astro ORF 2025 preview compressed ORF is not decoded Olympus AIR A01 ORF 2015 preview compressed ORF is not decoded Olympus Camedia C-5050 Zoom ORF 2003 unverified uncompressed / packed ORF -Olympus Camedia C-5060 Zoom ORF 2003 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus Camedia C-7070 Wide Zoom ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Camedia C-5060 Zoom ORF 2003 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Camedia C-7070 Wide Zoom ORF 2005 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Camedia C-8080 Wide Zoom ORF 2003 unverified uncompressed / packed ORF -Olympus E-1 ORF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus E-1 ORF 2003 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Olympus E-3 ORF 2007 preview compressed ORF is not decoded Olympus E-5 ORF 2010 preview compressed ORF is not decoded -Olympus E-10 ORF 2003 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash) +Olympus E-10 ORF 2003 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash) Olympus E-20 ORF 2003 unverified uncompressed / packed ORF Olympus E-30 ORF 2008 preview compressed ORF is not decoded Olympus E-420 ORF 2008 preview compressed ORF is not decoded @@ -802,18 +802,18 @@ Olympus E-450 ORF 2008 preview compressed ORF is not decoded Olympus E-520 ORF 2008 preview compressed ORF is not decoded Olympus E-600 ORF 2010 preview compressed ORF is not decoded Olympus E-620 ORF 2008 preview compressed ORF is not decoded -Olympus EVOLT E-300 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus EVOLT E-330 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus EVOLT E-400 ORF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus EVOLT E-300 ORF 2005 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-330 ORF 2005 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-400 ORF 2005 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-410 ORF 2007 preview compressed ORF is not decoded -Olympus EVOLT E-500 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-500 ORF 2005 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus EVOLT E-510 ORF 2007 preview compressed ORF is not decoded Olympus OM-D E-M1 ORF 2013 preview compressed ORF is not decoded Olympus OM-D E-M1X ORF 2018 preview compressed ORF is not decoded Olympus OM-D E-M1 Mark II ORF 2015 preview compressed ORF is not decoded Olympus OM-D E-M1 Mark III ORF 2019 preview compressed ORF is not decoded Olympus OM-D E-M5 ORF 2012 preview compressed ORF is not decoded -Olympus OM-D E-M5 Mark II ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus OM-D E-M5 Mark II ORF 2015 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus OM-D E-M5 Mark III ORF 2019 preview compressed ORF is not decoded Olympus OM-D E-M10 ORF 2013 preview compressed ORF is not decoded Olympus OM-D E-M10 Mark II ORF 2015 preview compressed ORF is not decoded @@ -837,25 +837,25 @@ Olympus PEN E-PL8 ORF 2015 preview compressed ORF is not decoded Olympus PEN E-PL9 ORF 2017 preview compressed ORF is not decoded Olympus PEN E-PM1 ORF 2010 preview compressed ORF is not decoded Olympus PEN E-PM2 ORF 2012 preview compressed ORF is not decoded -Olympus PEN-F ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus PEN-F ORF 2015 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus SP-310 ORF 2005 preview compressed ORF is not decoded Olympus SP-320 ORF 2005 preview compressed ORF is not decoded Olympus SP-350 ORF 2005 preview compressed ORF is not decoded Olympus SP-500 UZ ORF 2005 preview compressed ORF is not decoded -Olympus SP-510 UZ ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus SP-550 UZ ORF 2007 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-510 UZ ORF 2005 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-550 UZ ORF 2007 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus SP-560 UZ ORF 2007 preview compressed ORF is not decoded -Olympus SP-565 UZ ORF 2008 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus SP-570 UZ ORF 2007 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-565 UZ ORF 2008 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-570 UZ ORF 2007 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Stylus 1 ORF 2013 preview compressed ORF is not decoded Olympus Stylus 1s ORF 2013 preview compressed ORF is not decoded -Olympus Stylus SH-2 ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus SH-2 ORF 2015 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Stylus SH-3 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-4 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-5 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-6 ORF 2018 preview compressed ORF is not decoded -Olympus Stylus XZ-2 iHS ORF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Olympus Stylus XZ-10 ORF 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus XZ-2 iHS ORF 2012 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Olympus Stylus XZ-10 ORF 2012 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus XZ-1 ORF 2010 preview compressed ORF is not decoded OnePlus 7T Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) OnePlus 7T Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1007,46 +1007,46 @@ Panasonic LUMIX DC-S1M2ES (Final Support) RW2 2024 verified PR #215: 178 raw.pix Panasonic LUMIX DC-S1M2 RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 2025 unverified Panasonic decoder, every raw format Panasonic LUMIX DC-L10 RW2 2025 unverified Panasonic decoder, every raw format -Parrot Anafi DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Parrot Anafi DNG 2017 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Parrot ANAFI Ai DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax 645D DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax 645Z DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax *ist D PEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax *ist DL PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645D DNG 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645Z DNG 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist D PEF 2003 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DL PEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax *ist DL2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax *ist DS PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DS PEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax *ist DS2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-01 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-1 DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-01 DNG 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-1 DNG 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax K-1 II (* see note) DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-3 DNG 2013 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-3 II († see note) DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-5 DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-5 II DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-5 IIs DNG 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-7 PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-3 Mark III DNG 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-3 Mark III Monochrome DNG 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) -Pentax K-30 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-50 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-70 PEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-500 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax KP DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-r PEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-S1 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-S2 DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-x PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K10D DNG 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K20D DNG 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K100D PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K100D Super PEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K110D PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K200D DNG 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K2000 K-m PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax KF PEF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax MX-1 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax Q DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax Q7 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 DNG 2013 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Pentax K-3 II († see note) DNG 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 DNG 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 II DNG 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 IIs DNG 2012 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Pentax K-7 PEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III DNG 2020 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III Monochrome DNG 2022 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash) +Pentax K-30 DNG 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-50 DNG 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-70 PEF 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-500 DNG 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KP DNG 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-r PEF 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S1 DNG 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S2 DNG 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-x PEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K10D DNG 2005 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Pentax K20D DNG 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D PEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D Super PEF 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K110D PEF 2005 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K200D DNG 2007 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K2000 K-m PEF 2008 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KF PEF 2022 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax MX-1 DNG 2012 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q DNG 2010 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q7 DNG 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax Q10 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax QS-1 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pixii PIXII Camera (A1571) DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1078,10 +1078,10 @@ Phase One P 40+ IIQ 2010 preview IIQ is opened from its embedded JPEG only Phase One P 45 TIF 2007 unsupported TIF is not imported Phase One P 45+ TIF 2007 unsupported TIF is not imported Phase One P 65+ IIQ 2010 preview IIQ is opened from its embedded JPEG only -Ricoh GR DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Ricoh GR II DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Ricoh GR III DNG 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Ricoh GR IIIx DNG 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR DNG 2013 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR II DNG 2015 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR III DNG 2018 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Ricoh GR IIIx DNG 2021 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Ricoh GR III HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR IIIx HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR IV DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1091,7 +1091,7 @@ Ricoh GR Digital II DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Ricoh GR Digital III DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR Digital IV DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GX100 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Ricoh GX200 DNG 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) +Ricoh GX200 DNG 2003 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash) Ricoh GXR with GR LENS A12 28mm F2.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GXR with GR LENS A12 50mm F2.5 MACRO DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GXR with GR LENS A16 24-85 F3.5-5.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1105,15 +1105,15 @@ Samsung EK-KN120 SRW 2012 preview compressed SRW is not decoded Samsung EX2F SRW 2012 unverified uncompressed / packed SRW Samsung NX300 SRW 2012 preview compressed SRW is not decoded Samsung NX500 SRW 2015 preview compressed SRW is not decoded -Samsung NX1000 SRW 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Samsung NX1100 SRW 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1000 SRW 2012 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1100 SRW 2012 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung NX2000 SRW 2012 preview compressed SRW is not decoded Samsung NX3000 SRW 2013 preview compressed SRW is not decoded Samsung NX3300 SRW 2015 preview compressed SRW is not decoded Samsung NX U SRW 2012 unverified uncompressed / packed SRW Samsung Pro 815 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung TL350 (WB2000) SRW 2010 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Samsung TL500 (EX1) SRW 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Samsung TL350 (WB2000) SRW 2010 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung TL500 (EX1) SRW 2010 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Samsung Galaxy Note 9 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Note 20 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Note 20 Ultra Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1127,16 +1127,16 @@ Samsung Galaxy S21 Rear Main Camera DNG 2020 unverified DNG decoder (uncompresse Samsung Galaxy S21 Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S21 Ultra Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 Edge DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S7 Edge DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S8 DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S8+ DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S7 Edge DNG 2015 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 DNG 2017 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ DNG 2017 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S9 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S9+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Z Fold3 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1154,11 +1154,11 @@ Samsung Galaxy S22 Ultra Front Camera DNG 2021 unverified DNG decoder (uncompres Samsung Galaxy S22 Ultra Rear Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S23 Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S23 Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23+ Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1230,8 +1230,8 @@ Sigma BF DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JP Sigma DP1 X3F 2007 preview X3F is opened from its embedded JPEG only Sigma DP1s X3F 2008 preview X3F is opened from its embedded JPEG only Sigma DP2 X3F 2008 preview X3F is opened from its embedded JPEG only -Sigma fp DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Sigma fp L DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp DNG 2019 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L DNG 2020 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sigma SD9 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD10 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD14 X3F 2007 preview X3F is opened from its embedded JPEG only @@ -1243,12 +1243,12 @@ Sony Xperia 1 V Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92 Sony Xperia 1 V Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony Xperia 1 V Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony Xperia 1 V Zoom Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Sony A1 II (ILCE-1M2) (** see note) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A1 II (ILCE-1M2) (** see note) ARW 2024 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A7 (ILCE-7) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7 II (ILCE-A7M2) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A7 III (ILCE-7M3) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7 IV (ILCE-7M4) ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony A7 V (ILCE-7M5) (** see note) ARW 2025 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A7 V (ILCE-7M5) (** see note) ARW 2025 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A7C (ILCE-7C) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7C II (ILCE-7CM2) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7CR (ILCE-7CR) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -1310,7 +1310,7 @@ Sony DSC-F828 SRF 2003 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-V3 SRF 2005 unsupported SRF is discovered; sensor decoding unsupported, usable preview not verified on a real file -Sony DSC-RX100M7A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony DSC-RX100M7A ARW 2024 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3 (ILME-FX3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony FX30 (ILME-FX30) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony NEX-3 ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -1375,16 +1375,16 @@ Sony Xperia 5 V Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, Sony Xperia Pro-I Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony ZV-E10 ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony ZV-E10M2 ARW verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony ZV-1M2 ARW 2022 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-E10M2 ARW verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-1M2 ARW 2022 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony ZV-1A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILX-LR1 ARW 2023 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILCE - 1M2 (Final Support) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILCE - 6100A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony ILCE - 6400A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony FX2 (ILMEFX2) (** see note) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ILCE - 6400A ARW 2024 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony FX2 (ILMEFX2) (** see note) ARW 2024 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3A (ILMEFX3A) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony RX1R III (DSC-RX1RM3) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony RX1R III (DSC-RX1RM3) ARW 2024 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Xiaomi Mi 11 Lite Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Xiaomi Mi 11 Lite Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Xiaomi Mi 11 Lite Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index a09978c8d..621255858 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -1,119 +1,119 @@ # Per-model evidence that overrides the format rules in coverage.py. status: verified | preview | unsupported. # "verified" means a real file from this body decoded from sensor data in a corpus test or a recorded bulk check (source column). maker camera status source -Apple iPhone 12 Pro Rear Wide Camera verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 40D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 5D Mark II verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 5DS R verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 6D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 7D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 80D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS M50 (EOS Kiss M) verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS R100 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS R5 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files -Canon EOS R8 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS-1D X Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS 90D verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS M200 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS M50 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS M6 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R (** see note) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R3 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R5 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R6 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R6 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; C-RAW with adaptive QP above 168 still opens as the preview) -Canon EOS R6 Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R7 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; lossless only, C-RAW unverified) -Canon EOS R10 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS RP verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R50 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS R50 V verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon PowerShot G5 X Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon PowerShot G7 X Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon PowerShot SX70 HS verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon PowerShot V1 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) -Canon EOS-5D Mark III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 70D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G9X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 3000D (EOS Rebel T100, EOS 4000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G1 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S110 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G1 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G16 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G3 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-1D X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 5DS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S100V verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G11 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G15 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-1D Mark IV verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot SX50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G12 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 6D Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 77D (EOS 9000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-5D Mark IV (** see note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-1D Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 60D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS-1D Mark II N verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G5 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G9 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 5D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS M6 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G7 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 7D Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S120 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot S95 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G1 X Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 50D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 20D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G7 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot SX60 HS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon PowerShot G9 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Canon EOS 30D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 50S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A2 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-E1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-E5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-H2 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 12 Pro Rear Wide Camera verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 40D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 5D Mark II verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 5DS R verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 6D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 7D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 80D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS M50 (EOS Kiss M) verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS R100 verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS R5 Mark II verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files +Canon EOS R8 verified corpus: crates/raw/tests/it/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS-1D X Mark III verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 90D verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M200 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M50 Mark II verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M6 Mark II verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R (** see note) verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R3 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R5 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 Mark II verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; C-RAW with adaptive QP above 168 still opens as the preview) +Canon EOS R6 Mark III verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R7 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; lossless only, C-RAW unverified) +Canon EOS R10 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS RP verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 V verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G5 X Mark II verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G7 X Mark III verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot SX70 HS verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot V1 verified corpus: crates/raw/tests/it/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS-5D Mark III verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 70D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9X verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S110 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G16 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G3 X verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D X Mark II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5DS verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100V verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G11 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G15 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark IV verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M5 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX50 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M2 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M3 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G12 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 6D Mark II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M10 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 77D (EOS 9000D) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-5D Mark IV (** see note) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark III verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 60D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark II N verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M100 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G5 X verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M6 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 7D Mark II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S120 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S95 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark III verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 50D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 20D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX60 HS verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 X Mark II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 30D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 100S verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 50S verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A2 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-A5 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-E1 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-E5 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-H2 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-H2S verified docs/raf-compression.md (supplied files, exact sensor arrays) -Fujifilm X-M5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T2 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T20 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T4** verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T50 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Google Pixel 2 XL verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-M5 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T2 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T20 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T4** verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T5 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Fujifilm X-T50 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Google Pixel 2 XL verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Hasselblad Lunar verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Leica C (Typ 112) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux (Typ 109) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format @@ -129,147 +129,147 @@ Leica V-LUX (Typ 114) verified PR #215: 178 raw.pixls.us files from 118 Panasoni Leica V-LUX 1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Leica M Monochrom verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) -Leica CL verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica M10 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica C-Lux verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica M Monochrom (Typ 246) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) -Leica M8 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) -Leica X2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica Q2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica SL (Typ 601) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica M10-R verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica SL2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Leica M9 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) -Leica Q (Typ 116) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX B700 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D5100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D7000 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D7500 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon Z f verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D700 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XF10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-Pro1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 V1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 100S II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D610 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D2H verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 6 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX P1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D2Xs verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D800 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D780 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D850 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E2S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 50R verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Df verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-S10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 V2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 V3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3s verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z fc verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D810 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D40 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D800E verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100V** verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D4S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T30 III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XQ2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D2X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D80 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-H1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D7200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T30 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XQ1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix HS30 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D70S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX P1100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-S20 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D60 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5600 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix SL1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-Pro3** verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-Pro2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100VI verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix F550 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D5500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon 1 J3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 9 (*See note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX A1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D2Hs verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D90 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100T verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 50 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D600 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm XF1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D7100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-A1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix HS50 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 100 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X100F verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D40x verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX A verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X20 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-E4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm GFX 50S II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D1H verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix HS33 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 7 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D300s verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X-S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D750 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D3400 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix X-M1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm X-T30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon COOLPIX P950 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Fujifilm FinePix F770 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M Monochrom verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) +Leica CL verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M10 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica C-Lux verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M Monochrom (Typ 246) verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) +Leica M8 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) +Leica X2 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica Q2 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL (Typ 601) verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M10-R verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL2 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M9 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash) +Leica Q (Typ 116) verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX B700 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon D5100 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon D7000 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon D7500 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon Z f verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Nikon D700 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF10 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A10 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5300 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T100 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J4 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100S II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D610 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2H verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 6 II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1000 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X100 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X30 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X10 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Xs verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D780 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D850 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2S verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D500 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 50R verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Df verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S10 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V2 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V3 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3s verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z fc verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D810 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A3 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800E verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3X verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100V** verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4S verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 III verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ2 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T10 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2X verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X70 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D80 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-H1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J5 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7200 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5000 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS30 EXR verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70S verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5200 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1100 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S20 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 30 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D60 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J2 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5600 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix SL1000 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro3** verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro2 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 S1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100VI verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T200 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3200 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T3 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F550 EXR verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5500 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A7 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3300 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 5 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J3 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 9 (*See note) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A1000 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100S verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Hs verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D90 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D50 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3000 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100T verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D600 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7100 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E3 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3500 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS50 EXR verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100 II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100F verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40x verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X20 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E4 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 50S II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D1H verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D200 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS33 EXR verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix S1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 7 II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300s verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-S1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D750 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3100 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3400 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-M1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P950 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F770 EXR verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX 5700 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Nikon COOLPIX 8400 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Nikon COOLPIX 8800 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) -Olympus E-1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Olympus EVOLT E-400 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Olympus Stylus XZ-2 iHS verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus E-1 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Olympus EVOLT E-400 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Olympus Stylus XZ-2 iHS verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Panasonic LUMIX DC - G97 (DC-G99M2) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-FZ1000M2 (DC-FZ10002) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-FZ80 (DC-FZ82, DC-FZ85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format @@ -363,47 +363,47 @@ Panasonic LUMIX DMC-ZS40 (DMC-TZ60, DMC-TZ61) verified PR #215: 178 raw.pixls.us Panasonic LUMIX DMC-ZS50 (DMC-TZ70, DMC-TZ71) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DMC-ZS60 (DMC-TZ80, DMC-TZ81, DMC-TZ85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX ZS200 (DC-TX2, DC-TZ200, DC-TZ202, DC-TZ220, DC-ZS220) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Pentax K-3 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax *ist DL verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Ricoh GX200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) -Pentax KP verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-3 Mark III Monochrome verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) -Pentax K-70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Ricoh GR IIIx verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K20D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax *ist DS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-3 Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-01 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K110D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax Q7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax *ist D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K200D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-x verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Ricoh GR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax 645D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K100D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K2000 K-m verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax 645Z verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-5 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax Q verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax MX-1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K100D Super verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Ricoh GR II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-S2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-r verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax KF verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-3 II († see note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Pentax K-7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K-3 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Pentax K-5 IIs verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Pentax K10D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Ricoh GR III verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Pentax K-1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DL verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GX200 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash) +Pentax KP verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III Monochrome verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash) +Pentax K-70 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR IIIx verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K20D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DS verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-01 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K110D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q7 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-30 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K200D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-x verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-500 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K2000 K-m verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-50 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645Z verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax MX-1 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D Super verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR II verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S2 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-r verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KF verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 II († see note) verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-7 verified corpus: crates/raw/tests/it/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung TL500 (EX1) verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Sony A100 (DSLR-A100) preview issue #702 fixed (#774): opens from its embedded JPEG; its raw (no CFA image IFD) is not decoded Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample Sony A230 (DSLR-A230) preview issue #535: preview-only on the raw.pixls.us sample @@ -502,70 +502,70 @@ Sony RX1R II (DSC-RX1RM2) verified issue #535: raw.pixls.us bulk check of all 10 Sony ZV-1 (DCZV1/B) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony ZV-1M2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony ZV-E10M2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony RX1R III (DSC-RX1RM3) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony A1 II (ILCE-1M2) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony DSC-RX100M7A verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony A7 V (ILCE-7M5) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony FX2 (ILMEFX2) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Sony ILCE - 6400A verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus SP-550 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus E-10 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash) -Olympus Camedia C-5060 Zoom verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus SP-565 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus OM-D E-M5 Mark II verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus EVOLT E-300 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus Stylus SH-2 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus Camedia C-7070 Wide Zoom verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus Stylus XZ-10 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus EVOLT E-330 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus SP-570 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus SP-510 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus PEN-F verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Olympus EVOLT E-500 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Samsung TL350 (WB2000) verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Samsung NX1000 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) -Samsung NX1100 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-1M2 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-E10M2 verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony RX1R III (DSC-RX1RM3) verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A1 II (ILCE-1M2) (** see note) verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony DSC-RX100M7A verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A7 V (ILCE-7M5) (** see note) verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony FX2 (ILMEFX2) (** see note) verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ILCE - 6400A verified corpus: crates/raw/tests/it/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-550 UZ verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus E-10 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash) +Olympus Camedia C-5060 Zoom verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-565 UZ verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus OM-D E-M5 Mark II verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-300 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus SH-2 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Camedia C-7070 Wide Zoom verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus XZ-10 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-330 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-570 UZ verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-510 UZ verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus PEN-F verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-500 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung TL350 (WB2000) verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1000 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1100 verified corpus: crates/raw/tests/it/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo Sony DSC-F828 preview 2026-10-12 release CLI: raw.pixls.us DSC06227.SRF imports as preview-only through recursive and direct import (docs/raw-parity.md) -Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Mamiya ZD verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Apple iPhone 6s Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Apple iPhone 7 Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Apple iPhone XS verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -DJI DJI Mavic 3 (Hasselblad L2D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -DJI DJI Mavic 4 Pro (DJI FC9287) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -DJI DJI Mini 2 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Google Pixel 4 XL verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Google Pixel 4a Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Google Pixel 6 Pro Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Google Pixel 8 Pro Wide Camera* verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Hasselblad CFV-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration -Hasselblad H5D-40 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration -Hasselblad H5D-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration -Hasselblad X1D verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration -Hasselblad X1D II 50C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration -Hasselblad X2D 100C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration -Nikon D6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) -Nikon Z 6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) -Nikon Z 7 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) -Nikon Z 8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only -Parrot Anafi verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S21 Ultra Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S22 Ultra Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S23 Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S7 Edge verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Samsung Galaxy S8+ verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Sigma fp verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Sigma fp L verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) -Konica Minolta DiMAGE 7Hi verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Konica Minolta DiMAGE A1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Konica Minolta DiMAGE A200 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Konica Minolta Maxxum 7D Dynax 7D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1s verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1x verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Mamiya ZD verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 6s Plus verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone 7 Plus verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone XS verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 3 (Hasselblad L2D-20c) verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 4 Pro (DJI FC9287) verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mini 2 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4 XL verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4a Rear Wide Camera verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 6 Pro Rear Main Camera verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 8 Pro Wide Camera* verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Hasselblad CFV-50c verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-40 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-50c verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D II 50C verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Nikon D6 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 6 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 7 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 8 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only +Parrot Anafi verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S21 Ultra Rear Main Camera verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S22 Ultra Rear Wide Camera verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S23 Rear Wide Camera verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S7 Edge verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L verified corpus: crates/raw/tests/it/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Konica Minolta DiMAGE 7Hi verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A1 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A200 verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta Maxxum 7D Dynax 7D verified corpus: crates/raw/tests/it/corpus.rs (xtask RAW_SAMPLES) Konica Minolta DiMAGE 7 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) Konica Minolta DiMAGE A2 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index dff886b13..0252151cd 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -181,7 +181,7 @@ profile correction control when an existing edit has it disabled. ## Local inventory check -`crates/raw/tests/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a +`crates/raw/tests/it/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a directory of ARWs it decodes each from its headers and prints its record (settings, sources, copies' agreement, sample ranges, statuses); `LIGHTCRAFT_SONY_LENS_FULL=1` also compares header and full decode. With `LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory it checks every file's table counts, sizes, sample counts, @@ -300,7 +300,7 @@ The current `cargo xtask corpus --download` manifest contains 25 Sony CC0 sample including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm), each with a pinned SHA-256 that the download checks. `sony_embedded_distortion_is_limited_to_validated_models` checks 23 of them (the two ILCE-7CM2 codings have their own test), and `sony_lens_records_of_the_public_samples` verifies all 25 against their pins -(`SONY_SAMPLES` in `crates/raw/tests/corpus.rs`, itself anchored by a pinned digest and compared with the manifest) +(`SONY_SAMPLES` in `crates/raw/tests/it/corpus.rs`, itself anchored by a pinned digest and compared with the manifest) before decoding. **No CI job fetches the corpus**: these fixture tests run only where it has been downloaded, and otherwise skip each absent file. `LIGHTCRAFT_CORPUS_REQUIRED=1` makes `sony_lens_records_of_the_public_samples` fail on any absent pinned file instead. diff --git a/docs/tint-direction.md b/docs/tint-direction.md index 4f9097a78..40249e459 100644 --- a/docs/tint-direction.md +++ b/docs/tint-direction.md @@ -12,7 +12,7 @@ Run the evidence without private samples: ```sh cargo test -p lightcraft-pipeline tint_ -- --nocapture -cargo test -p lightcraft-gpu --test equivalence tint_directions -- --nocapture +cargo test -p lightcraft-gpu --test it equivalence::tint_directions -- --nocapture cargo test -p lightcraft-color cct ``` diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f267092eb..7a746fdf3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -66,7 +66,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. Remaining Classic gaps: ⌘L (Enable Filters). +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. ⌘L / Ctrl+L (Enable Filters) puts the filter aside and shows every photo, and the next press brings back the same filter, stars, flag and text together; setting or clearing a filter while it is off drops the one put aside (not documented by Adobe, our choice). **Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). @@ -80,7 +80,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. ## Accessibility audit -`crates/ui-egui/tests/accessibility.rs` walks the AccessKit tree egui hands to VoiceOver, Narrator and +`crates/ui-egui/tests/it/accessibility.rs` walks the AccessKit tree egui hands to VoiceOver, Narrator and AT-SPI, headless, in Photo Grid, Square Grid, Detail, Compare, Survey and Reference, in each right panel (Edit, Profiles, Crop, Remove, Masking, Red Eye, Versions, Activity, Keywords, Info) and in 13 dialogs (Export, Copy / Paste Settings, Create Preset, Rename, New Album, New Smart Album, Capture @@ -149,6 +149,7 @@ establish OS keyboard delivery or cross-platform acceptance. | Date | Change | Summary | |---|---|---| | 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged | +| 2026-10-23 | patch | KEYC-RATING: ⌘L / Ctrl+L (Library ▸ Enable Filters) switches the filter off and on, restoring it as it was | | 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); ⌘L is the filter-bar key left | | 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it | | 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | diff --git a/docs/whats-new.md b/docs/whats-new.md index c84beaf0c..949b1f8f0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -24,6 +24,50 @@ library you open and are imported into it, as files given on the command line are; presets dropped then are refused with a note, since they belong to a library. +### Tab leaves the name field in New Album, New Smart Album and the name prompts +- In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the + keyboard focus when the dialog opens, and Tab now moves on to the check box, Cancel and OK (Shift+Tab goes back). + Before, the field took the focus back every frame, so only Return and Esc got you out of it. Return still + confirms and Esc still cancels. + +### Foreign XMP metadata survives namespace-prefix collisions +- Saving a sidecar keeps another application's properties even when its namespace uses prefixes such as `lc` + or `xmp`. Those foreign properties no longer become LightCraft settings or ratings when imported. + +### Find Missing Photos finds the files of a library made on Windows +- A library made on Windows records its originals with Windows paths. Opened on macOS or Linux, its photos are + missing until relinked, and Find Missing Photos found them only by their content, so it missed every photo it had + no fingerprint for (such as photos brought in from a Lightroom catalog) or that had been edited since, even in a + folder holding every file under the same name. It now matches them by name as it does any other photo: the name + after the last `/` or `\`, in any letter case, checked against the size when the library has one. Several files + of that name are still reported rather than guessed, and undo puts back the paths as they were. + +### Auto straighten leaves straight photos alone +- Auto straighten (Auto in the crop panel, or a double-click with the Straighten tool) no longer turns a photo by + more than 10°, and acts only when at least three lines agree. Before, it often turned photos that were straight: + the edges of wooden planks, a branch or a bird's wing were taken for the horizon, sometimes for a 25° turn. +- When it finds no horizon or vertical lines, it says so instead of doing nothing without a word. + +### PSD and PSB metadata stays available +- LightCraft now imports PSD and PSB files with EXIF and XMP image resources. Capture dates, GPS and descriptive fields + stay available in All Metadata; PSD edit copies keep their capture date. Original exports with Remove Location Info enabled + are refused when a PSD or PSB embeds GPS, since LightCraft cannot strip metadata from a byte-for-byte original copy. + +## 2026-10-22 + +### Reliability +- Opening a library no longer writes through a link left at the lock's owner note (`catalog.lock.owner`): the link + is removed and a new note made in its place, so the file it pointed to is never changed (as for the catalog's + temporary files, issue #1502). + +### Keys reach text fields and the shortcut editor on macOS +- While a text field has focus, the native menu now lets go of the keys the field needs (`G`, the digits `0`–`9`, + `P`, `U` and the like), and while Keyboard Shortcuts is recording a key it lets go of all of them (⌘, and ⌘Q in + the LightCraft menu too), except the system's own items (Hide LightCraft ⌘H, Hide Others ⌥⌘H, Close Window ⌘W + and Minimize ⌘M) and the two keys of the Help menu (⌘/ and F1). Before, the menu item kept its key on macOS, so the + key could still run the menu command instead of reaching the field or the editor. The keys come back on the menu + items when the field loses focus or recording ends; the menu's order, checkmarks and enabled state are unchanged. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index b90e76c40..7fd11a172 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -81,6 +81,9 @@ the only copy of those edits outside that application's catalog. Saving never dr ones), `xmp:CreatorTool`, comments, the packet wrapper and padding. LightCraft's properties go into one `rdf:Description` of their own; owned properties written by another application (in attribute or element form) are removed from its description, which otherwise stays as it was. +- Ownership follows the namespace URI, not the prefix spelling. A foreign namespace bound to `lc:` or `xmp:` + stays foreign: its properties are preserved and do not become LightCraft settings or ratings. Genuine namespace + aliases remain interchangeable, including namespace declarations containing XML character references. - A sidecar that can't be read as XMP (not well-formed, not UTF-8, no `rdf:RDF`) is first copied to `.xmp.bak-