mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The integration tests, one module per file, linked into one test binary.
|
||||
|
||||
mod cli;
|
||||
mod mcp_shutdown;
|
||||
@@ -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");
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<_>>()}))
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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};
|
||||
@@ -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
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
//! The integration tests, one module per file, linked into one test binary.
|
||||
|
||||
mod e2e;
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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() };
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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#">
|
||||
|
||||
@@ -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/" 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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! The integration tests, one module per file, linked into one test binary.
|
||||
|
||||
mod robust;
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! The integration tests, one module per file, linked into one test binary.
|
||||
|
||||
mod preview_save;
|
||||
Vendored
+1
-1
@@ -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");
|
||||
+1
-1
@@ -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() {
|
||||
@@ -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
Reference in New Issue
Block a user