Merge upstream/main (integration batch 43) into feat/issue-1290-launch-library-choice

docs/parity-checklist.md: header lists both sides' changes, revision rows of
both sides newest-first, summary recomputed (`cargo xtask parity --write`).
docs/whats-new.md: #1290's entry joins upstream's 2026-10-23 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
francoisgn
2026-10-24 02:32:25 +03:00
co-authored by Claude Opus 5.5
184 changed files with 3891 additions and 2009 deletions
+32
View File
@@ -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
+8 -13
View File
@@ -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:
+38
View File
@@ -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
+40
View File
@@ -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:
+2 -1
View File
@@ -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:
+7 -13
View File
@@ -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:
+2
View File
@@ -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"
+6 -4
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+5 -1
View File
@@ -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())?;
@@ -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");
+4
View File
@@ -0,0 +1,4 @@
//! The integration tests, one module per file, linked into one test binary.
mod cli;
mod mcp_shutdown;
+20 -11
View File
@@ -240,11 +240,14 @@ mod tests {
}
impl Connection {
fn new(open: &Arc<AtomicUsize>, 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<AtomicUsize>, 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");
+259 -16
View File
@@ -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<String>,
/// The accelerator the item's shortcut maps to.
accel: Option<Accelerator>,
/// The accelerator the native item has now: `accel`, or `None` while it yields.
current: Option<Accelerator>,
label: String,
enabled: bool,
checked: Option<bool>,
/// 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<Accelerator>) {
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<Accelerator>, want: Option<Accelerator>, native_call_worked: bool) -> Option<Accelerator> {
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<T: SwapTarget + ?Sized>(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<Accelerator>, sc: &str, text_focus: bool, capturing: bool) -> Option<Accelerator> {
if capturing || (text_focus && yields_to_text(sc)) { None } else { accel }
}
pub struct NativeMenu {
menu: Menu,
items: HashMap<String, Item>,
/// 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<Item>,
rx: Receiver<String>,
/// 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<String, Item>) {
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<Vec<u32>>,
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<u32>) {
*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)));
+3
View File
@@ -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
+4 -2
View File
@@ -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());
}
+7
View File
@@ -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<PhotoId>,
/// 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<ColorLabel>,
@@ -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;
}
+26 -2
View File
@@ -670,6 +670,11 @@ fn number(v: &Value) -> Option<f64> {
}
}
/// 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<f64>, 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::<f64>().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();
+2 -2
View File
@@ -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<std::fs::File> {
/// 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<std::fs::File> {
if std::fs::symlink_metadata(tmp).is_ok() {
std::fs::remove_file(tmp)?;
}
+22
View File
@@ -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() {
+1 -1
View File
@@ -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 |
|---|---|
@@ -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;
@@ -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};
@@ -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;
+13
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
//! The integration tests, one module per file, linked into one test binary.
#[cfg(feature = "runtime")]
mod real_model;
@@ -2,7 +2,7 @@
//!
//! ```text
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_MOSAICS=<folder> LC_DENOISE_OUT=<folder> \
//! 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 `<name>.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;
+6 -2
View File
@@ -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) {
+3
View File
@@ -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)
{
+4 -1
View File
@@ -109,13 +109,16 @@ fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>()}))
}
+5
View File
@@ -176,6 +176,11 @@ fn rename_person(s: &mut Session, p: &Value) -> Result<Value> {
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}))
}
+1
View File
@@ -38,6 +38,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
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()}))
}
+7
View File
@@ -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<Arc<GeoNames>, lightcraft_geocode::GeocodeError> {
let file = dir.join(GEONAMES_FILES[0]);
+6
View File
@@ -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}))
}
+36
View File
@@ -426,6 +426,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
}
s.filter = f;
s.shelved_filter = None;
Ok(json!({"count": s.visible().len()}))
}
),
@@ -469,6 +470,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
),
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;
@@ -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<Session> {
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);
}
+47 -2
View File
@@ -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<MaskShape> {
Ok(match kind {
"brush" => MaskShape::Brush { strokes: vec![] },
@@ -35,7 +47,12 @@ fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
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<CommandSpec> {
}
),
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<Vec<f32>> {
.collect::<Option<Vec<f32>>>()
.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<Value> = (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);
}
}
+16 -4
View File
@@ -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<String, Vec<String>> = HashMap::new();
let files: Vec<String> = 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<HashMap<u64, Vec<String>>> = None;
@@ -239,7 +251,7 @@ pub fn plan_find_missing(candidates: &[FindCandidate], folder: &str) -> std::res
let mut used: HashSet<String> = 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
+13 -6
View File
@@ -38,13 +38,13 @@ impl std::error::Error for NotFollowed {}
/// was opened from.
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
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")));
}
}
+8 -4
View File
@@ -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,
+17 -1
View File
@@ -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<u8> {
use lightcraft_tiff::tags::{self as t, photometric};
+1 -1
View File
@@ -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
+8
View File
@@ -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<Filter>,
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 =
+4 -5
View File
@@ -845,13 +845,12 @@ impl SyncCommit {
}
}
SyncStep::Remove(gone) => {
let ops: Vec<Op> = 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<PhotoId> = gone.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|p| p.in_library())).collect();
let mut ops: Vec<Op> = 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())),
+43
View File
@@ -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::<Vec<_>>(), ["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<f64> = 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;
+1 -1
View File
@@ -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;
+87 -1
View File
@@ -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 = "<x:xmpmeta><broken";
let embedded = lightcraft_codecs::EncodeMeta { xmp: Some(xmp), ..Default::default() };
let bytes = lightcraft_codecs::encode_psd(&image, &embedded).unwrap();
assert_eq!(lightcraft_meta::extract(&bytes), lightcraft_meta::Metadata::default(), "ordinary extraction stays best-effort");
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 verify location metadata") && err.contains("malformed PSD/PSB"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_location_refuses_a_psd_with_invalid_utf8_xmp() {
let dir = temp_dir("export-noloc-psd-invalid-utf8");
let path = dir.join("Invalid.psd");
let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), ..Default::default() };
let xmp = lightcraft_meta::write_xmp(&gps, 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 { xmp: Some(&xmp), ..Default::default() };
let mut bytes = lightcraft_codecs::encode_psd(&image, &embedded).unwrap();
assert_eq!(lightcraft_meta::extract(&bytes).gps, gps.gps);
let latitude = bytes.windows(2).position(|bytes| bytes == b"45").unwrap();
bytes[latitude] = 0xff;
assert!(lightcraft_meta::extract(&bytes).gps.is_none());
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 verify location metadata") && err.contains("malformed PSD/PSB"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_location_refuses_a_psd_with_unreadable_resources() {
let dir = temp_dir("export-noloc-psd-malformed");
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 valid_bytes = synthetic_psd_with_metadata(&gps);
assert_eq!(lightcraft_meta::extract(&valid_bytes).gps, gps.gps, "the fixture embeds EXIF GPS before corruption");
let mut bytes = valid_bytes;
let colour_length = usize::try_from(u32::from_be_bytes(bytes[26..30].try_into().unwrap())).unwrap();
let resource_length_at = 30 + colour_length;
let resource_length = usize::try_from(u32::from_be_bytes(bytes[resource_length_at..resource_length_at + 4].try_into().unwrap())).unwrap();
let resource_start = resource_length_at + 4;
let resource_end = resource_start + resource_length;
bytes.splice(resource_end..resource_end, *b"BAD!");
bytes[resource_length_at..resource_length_at + 4].copy_from_slice(&u32::try_from(resource_length + 4).unwrap().to_be_bytes());
assert!(lightcraft_meta::extract(&bytes).gps.is_none(), "the malformed trailing resource reproduces the metadata gap");
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 verify location metadata") && err.contains("malformed PSD/PSB"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hdr_png_export_is_16_bit_pq_rec2020() {
let mut s = Session::with_demo();
+64
View File
@@ -263,6 +263,70 @@ fn find_missing_plan_is_rechecked_when_applied() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A library made on Windows names its originals with Windows paths; opened on macOS or Linux
/// they are missing until Find Missing finds them by name, there as everywhere: the name follows
/// the last `/` or `\`, any letter case, the size checked when one is on record, look-alikes
/// reported rather than guessed, and undo puts back the path exactly as spelled.
#[test]
fn find_missing_finds_the_files_of_a_library_made_on_windows() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let dir = temp_dir("missing-windows");
for (rel, bytes) in [("found/a.jpg", &b"x"[..]), ("found/deep/b.jpg", b"xyz"), ("found/one/twin.jpg", b"x"), ("found/two/twin.jpg", b"x")] {
std::fs::create_dir_all(dir.join(rel).parent().unwrap()).unwrap();
std::fs::write(dir.join(rel), bytes).unwrap();
}
let mut s = Session::new();
// as a Windows library may list them: no content hash, and a size only for b
let mut add = |path: &str, size: u64| {
let id = s.catalog.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");
p.file_size = size;
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let (a, b, twin) = (add(r"C:\Users\me\Photos\a.jpg", 0), add(r"D:\Shoot\2019\B.JPG", 3), add(r"C:\Users\me\Photos\twin.jpg", 0));
let path_of = |s: &Session, id: PhotoId| match &s.catalog.photo(id).unwrap().source {
Source::File { path } => 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()));
+18
View File
@@ -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");
}
+106 -1
View File
@@ -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<u8> {
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<u8> {
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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTc
</x:xmpmeta>
<?xpacket end="w"?>"#;
/// 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#"<rdf:Description xmlns:lc="https://example.org/vendor/" xmlns:xmp="https://example.org/vendor/"
lc:Secret="keep-me" xmp:Rating="2"><lc:Other>also keep</lc:Other></rdf:Description>"#;
let packet = format!(r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{foreign}</rdf:RDF>"#);
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() {
+3 -2
View File
@@ -214,8 +214,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
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
}
@@ -1,10 +1,9 @@
//! Opt-in same-machine denoise model-stage benchmark on one public Bayer raw.
//! LC_DENOISE_MODEL=<onnx> LC_DENOISE_RAW=<CC0 corpus/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,
@@ -4,12 +4,11 @@
//!
//! ```text
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_RAW=<folder with raw files> \
//! 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};
+7
View File
@@ -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;
@@ -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
+3 -1
View File
@@ -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);
}
@@ -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};
+9
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
//! The integration tests, one module per file, linked into one test binary.
mod e2e;
+5
View File
@@ -0,0 +1,5 @@
//! The integration tests, one module per file, linked into one test binary.
mod hdr;
mod pano;
mod white_balance;
+12 -2
View File
@@ -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<XmpData>` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). |
| `write_xmp(&Metadata, Option<&str>) -> String` | Complete `<?xpacket?>` 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<Metadata, ExtractError>` | 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
+10
View File
@@ -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<Embedded, crate::ExtractError> {
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";
+57 -5
View File
@@ -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<Metadata, ExtractError> {
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>) -> 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<String> {
@@ -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() };
+285
View File
@@ -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<u16> {
let bytes = self.take(2)?;
Some(u16::from_be_bytes([*bytes.first()?, *bytes.get(1)?]))
}
fn u32(&mut self) -> Option<u32> {
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<Embedded> {
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<Embedded> {
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<u8> {
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<u8> {
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<u8> {
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"<x:xmpmeta><broken"));
assert_eq!(crate::extract(&file), Metadata::default());
assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd)));
}
#[test]
fn try_extract_rejects_invalid_utf8_psd_xmp() {
let mut xmp = crate::write_xmp(&dated_metadata(), None).into_bytes();
let latitude = xmp.windows(2).position(|bytes| bytes == b"45").unwrap();
xmp[latitude] = 0xff;
let lossy = crate::parse_xmp(&String::from_utf8_lossy(&xmp)).unwrap();
assert!(lossy.metadata.gps.is_none(), "lossy decoding hides the corrupted GPS value without an XML error");
for version in [1, 2] {
let file = image(version, &[], &resource(1060, b"", &xmp));
assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd)));
assert_eq!(embedded(&file), Embedded::default());
assert_eq!(crate::extract(&file), Metadata::default());
}
}
#[test]
fn psd_xmp_preserves_valid_utf8_and_trailing_nul_trimming() {
let metadata = Metadata { title: Some("Café 山".into()), ..dated_metadata() };
let xmp = crate::write_xmp(&metadata, None);
let mut padded = xmp.as_bytes().to_vec();
padded.extend_from_slice(b"\0\0");
for version in [1, 2] {
let file = image(version, &[], &resource(1060, b"", &padded));
assert_eq!(embedded(&file).xmp.as_deref(), Some(xmp.as_str()));
let actual = crate::try_extract(&file).unwrap();
assert_eq!(actual.gps, metadata.gps);
assert_eq!(actual.title, metadata.title);
}
}
#[test]
fn try_extract_rejects_duplicate_psd_exif_and_xmp_resources() {
let gps = dated_metadata();
let no_gps = Metadata::default();
let gps_exif = crate::write_exif(&gps);
let no_gps_exif = crate::write_exif(&no_gps);
let gps_xmp = crate::write_xmp(&gps, None);
let no_gps_xmp = crate::write_xmp(&no_gps, None);
let duplicate_exif = [resource(1058, b"", &gps_exif), resource(1058, b"", &no_gps_exif)].concat();
let duplicate_xmp = [resource(1060, b"", gps_xmp.as_bytes()), resource(1060, b"", no_gps_xmp.as_bytes())].concat();
for (id, resources) in [(1058, duplicate_exif), (1060, duplicate_xmp)] {
let file = image(1, &[], &resources);
assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd)), "duplicate resource {id} must be rejected");
}
}
#[test]
fn psd_skips_unknown_resources_with_pascal_name_and_payload_padding() {
let exif = crate::write_exif(&dated_metadata());
let mut resources = resource(4000, b"xy", &[1, 2, 3]);
resources.extend(resource(1058, b"", &exif));
let file = image(1, &[8, 7, 6], &resources);
assert_eq!(crate::extract(&file).capture_time, dated_metadata().capture_time);
}
#[test]
fn psd_truncation_and_hostile_lengths_return_no_embedded_metadata() {
let file = dated_image(1);
for end in 0..file.len() {
assert_eq!(embedded(&file[..end]), Embedded::default(), "truncated at {end}");
}
let mut hostile_colour_length = file.clone();
hostile_colour_length[26..30].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(embedded(&hostile_colour_length), Embedded::default());
let mut hostile_resource_length = file.clone();
hostile_resource_length[30..34].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(embedded(&hostile_resource_length), Embedded::default());
let mut hostile_payload_length = file;
hostile_payload_length[42..46].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(embedded(&hostile_payload_length), Embedded::default());
}
#[test]
fn psd_rejects_unknown_versions_and_incomplete_resource_records() {
let file = dated_image(1);
let mut unknown_version = file.clone();
unknown_version[4..6].copy_from_slice(&3u16.to_be_bytes());
assert_eq!(embedded(&unknown_version), Embedded::default());
let mut truncated_record = file;
truncated_record.pop();
assert_eq!(embedded(&truncated_record), Embedded::default());
}
}
+1 -1
View File
@@ -351,7 +351,7 @@ pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
}
/// [`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<TagRow> {
if Tiff::sniff(bytes).is_some()
&& let Ok(t) = Tiff::parse(bytes)
+71 -11
View File
@@ -69,7 +69,8 @@ pub struct XmpData {
/// The opaque `lc:settings` JSON string, if present.
pub lc_settings: Option<String>,
/// 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<String, Vec<String>>,
/// 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<String, XmpError> {
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<String, XmpError> {
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<char> {
@@ -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<Node, XmpError> {
let mut stack: Vec<Node> = vec![Node { name: "#root".into(), ..Default::default() }];
let mut scopes: Vec<Vec<(String, String)>> = 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<Node, XmpError> {
}
}
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::<Result<_, XmpError>>()?;
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#"><xmp:Rating>2</xmp:Rating><lc:settings>foreign settings</lc:settings></rdf:Description>"#,
] {
let packet = format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
xmlns:xmp="https://example.org/vendor/" xmlns:lc="https://example.org/vendor/" {body}</rdf:RDF>"#
);
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:Rating xmlns:xmp="https://example.org/one/">1</xmp:Rating>
<xmp:Rating xmlns:xmp="https://example.org/two/">2</xmp:Rating>
<xmp:Rating>4</xmp:Rating>
</rdf:Description></rdf:RDF>"#;
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#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+50 -5
View File
@@ -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<Scan, XmpError> {
let mut stack: Vec<Kind> = Vec::new();
let mut scopes: Vec<Vec<(String, String)>> = 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<Scan, XmpError> {
}
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::<Result<_, XmpError>>()?;
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#"><lc:Secret>keep-me</lc:Secret></rdf:Description>"#] {
let desc = format!(r#"<rdf:Description rdf:about="" xmlns:lc="https://example.org/vendor/" {property}"#);
let old = format!(r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{desc}</rdf:RDF>"#);
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#"<rdf:Description xmlns:xmp="https://example.org/vendor/" xmp:Rating="2"/>"#;
let old = format!(
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{foreign}
<rdf:Description xmlns:a="http://ns.adobe.com/xap/1.0&#47;" a:Rating="3"/>
</rdf:RDF>"#
);
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
xmlns:lc="https://example.org/{}">{}</rdf:Description></rdf:RDF>"#,
"a".repeat(1024),
"<lc:p/>".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
+3
View File
@@ -0,0 +1,3 @@
//! The integration tests, one module per file, linked into one test binary.
mod robust;
+34 -9
View File
@@ -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<f32> = (-r_..=r_).map(at).collect();
win.sort_unstable_by(f32::total_cmp);
let mut win: Vec<i32> = (-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);
+36
View File
@@ -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}");
}
+21
View File
@@ -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::<Vec<_>>();
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<f32>| {
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
+27 -4
View File
@@ -375,6 +375,12 @@ fn focal_from_vps(v: [f64; 3], h: [f64; 3]) -> Option<f64> {
/// Pure rotation (degrees, clockwise +) that levels the dominant lines.
fn level_angle(vert: &[Segment], horiz: &[Segment]) -> Option<f64> {
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<f64> {
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<f64> {
})?;
// 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<f64> {
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<f64> {
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).
+3
View File
@@ -0,0 +1,3 @@
//! The integration tests, one module per file, linked into one test binary.
mod preview_save;
+1 -1
View File
@@ -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
@@ -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");
@@ -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() {
+20
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More