Merge storytold/main into fix/2553-gif-bmp-header

Picks up #2667 (the shared Linux gate) so this PR runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PtjHGt5gnCHXgLt4NbGorf
This commit is contained in:
insideTheFlux
2026-10-23 13:53:22 -07:00
co-authored by Claude Opus 5.5
130 changed files with 1840 additions and 1914 deletions
+32
View File
@@ -0,0 +1,32 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
timeout-minutes: 90
# xtask ci runs cargo test: the demo scene cache is per process, so nextest is slower here.
tools: ""
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev libdbus-1-dev
+8 -13
View File
@@ -1,23 +1,18 @@
# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
branches: [release]
pull_request:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
schedule:
- cron: "7 4 * * *"
workflow_dispatch:
concurrency:
+38
View File
@@ -18,6 +18,44 @@ concurrency:
cancel-in-progress: true
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+40
View File
@@ -20,7 +20,47 @@ concurrency:
cancel-in-progress: false
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: git-sources
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+7 -13
View File
@@ -5,23 +5,17 @@
# on GitHub Actions' native windows-11-arm runner.
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "3 5 * * *"
push:
branches: [main]
paths:
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
branches: [release]
pull_request:
branches: [main]
paths:
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
workflow_dispatch:
concurrency:
Generated
+33 -33
View File
@@ -3087,7 +3087,7 @@ dependencies = [
[[package]]
name = "lightcraft"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"chrono",
"eframe",
@@ -3113,7 +3113,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"chrono",
"crc32fast",
@@ -3131,7 +3131,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3147,7 +3147,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3178,7 +3178,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3187,7 +3187,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3204,7 +3204,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3219,7 +3219,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3231,7 +3231,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"ab_glyph",
"chrono",
@@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-testkit",
"matrixmultiply",
@@ -3290,7 +3290,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"log",
"rustls",
@@ -3301,7 +3301,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3309,7 +3309,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"proptest",
"serde",
@@ -3317,7 +3317,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3337,7 +3337,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3345,14 +3345,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3372,7 +3372,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3388,7 +3388,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"chrono",
"lightcraft-geom",
@@ -3401,7 +3401,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3419,7 +3419,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3431,7 +3431,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3443,7 +3443,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"dhat",
"jpeg-encoder",
@@ -3467,7 +3467,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3478,7 +3478,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3492,7 +3492,7 @@ dependencies = [
[[package]]
name = "lightcraft-slideshow"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"lightcraft-raster",
"serde",
@@ -3501,15 +3501,15 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
version = "0.11.0"
version = "0.13.0"
[[package]]
name = "lightcraft-testkit"
version = "0.11.0"
version = "0.13.0"
[[package]]
name = "lightcraft-tiff"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"proptest",
"serde",
@@ -3518,7 +3518,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"craft-ui",
"egui",
@@ -3547,7 +3547,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"crc32fast",
"eframe",
@@ -7017,7 +7017,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.13.0"
dependencies = [
"brotli",
"flate2",
+3 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -125,9 +125,11 @@ excessive_precision = "allow"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
debug = "line-tables-only"
+6 -4
View File
@@ -410,10 +410,12 @@ cargo xtask ci # fmt, clippy, HEIF, tes
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
available when it starts (at most one per CPU; 4 if memory can't be read), so an
8 GB machine with 6 GB free builds with 4, and a busy machine with little free
builds with fewer. Test threads follow, capped at 4. Full-debuginfo linkers, one
per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. Explicit
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
override these defaults. Ordinary development commands keep their own settings.
builds with fewer. Test threads follow, capped at 4.
Full-debuginfo linkers, one per CPU, otherwise exhaust RAM before any test runs. GPU coverage is
unchanged. Explicit `CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS`, `RUST_TEST_THREADS` and
`NEXTEST_TEST_THREADS` settings override these defaults. The tests run under
`cargo test`; `LIGHTCRAFT_NEXTEST=1` runs them under [cargo-nextest](https://nexte.st) instead (one
process per test, slower here because each regenerates the demo scenes). Ordinary development commands keep their own settings.
**Release builds on memory-constrained machines:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
reports Arch/AUR release-link OOM kills on a 16 GB machine when the desktop and CLI build concurrently
+1 -1
View File
@@ -357,7 +357,7 @@ CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一
(每个 CPU 最多一个;读不到内存时为 4 个),所以一台 8 GB、空闲 6 GB 的机器会用 4 个任务构建,
而一台空闲内存很少的繁忙机器会用更少。测试线程数随之调整,上限为 4。否则,每个 CPU 一个的
完整调试信息链接器会在任何测试运行之前就耗尽内存。GPU 覆盖范围不变。显式设置
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS`、`RUST_TEST_THREADS` 和 `NEXTEST_TEST_THREADS` 会覆盖这些默认值。
日常开发命令保持它们各自的设置。
**内存受限机器上的 release 构建:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
+1 -1
View File
@@ -375,7 +375,7 @@ CI 預設使用行表除錯資訊,並依啟動時可用記憶體每 1.5 GB 開
(每個 CPU 最多一個;讀不到記憶體時為 4 個),所以一台 8 GB、剩餘 6 GB 可用的機器會用 4 個任務建置,
而一台忙碌、剩餘記憶體很少的機器會用更少。測試執行緒數隨之調整,上限為 4。若不限制,每個 CPU 各跑
一個帶完整除錯資訊的連結器,測試還沒開始記憶體就會耗盡。GPU 測試的涵蓋範圍不受影響。明確設定
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 與 `RUST_TEST_THREADS` 會覆寫這些預設值。
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS`、`RUST_TEST_THREADS` 與 `NEXTEST_TEST_THREADS` 會覆寫這些預設值。
日常開發指令保留各自的設定。
**記憶體受限機器上的 release 建置:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
+2 -2
View File
@@ -121,7 +121,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on
CC0 files. 551 models verified, 201 preview-only; headline numbers unchanged.
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
checked against the camera JPEG (`crates/raw/tests/it/canon_pentax_corpus.rs`). 334 models verified, ~50% of
photographers on a verified body; headline numbers unchanged.
- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models
verified, 35 unsupported; headline numbers unchanged.
@@ -135,7 +135,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW
decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%.
- 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked
against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800
against the camera JPEG (`crates/raw/tests/it/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800
decoding wrongly. 354 models verified, ~58% of photographers on a verified body; headline numbers unchanged.
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
+4
View File
@@ -0,0 +1,4 @@
//! The integration tests, one module per file, linked into one test binary.
mod cli;
mod mcp_shutdown;
+20 -11
View File
@@ -240,11 +240,14 @@ mod tests {
}
impl Connection {
fn new(open: &Arc<AtomicUsize>, idle: Duration) -> Self {
/// `before` acts on the client before the worker starts reading, so what it sends is
/// already there however late this thread gets scheduled.
fn new(open: &Arc<AtomicUsize>, idle: Duration, before: impl FnOnce(&mut TcpStream)) -> Self {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let (stream, _) = listener.accept().unwrap();
before(&mut client);
let (tx, requests) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), Arc::clone(open), idle).unwrap();
Self { client: BufReader::new(client), requests, worker: Some(worker) }
@@ -271,9 +274,8 @@ mod tests {
fn idle_connections_release_their_slots_without_dispatching() {
for input in ["", "{\"method\":\"engine.execute\""] {
let open = Arc::new(AtomicUsize::new(0));
let mut connection = Connection::new(&open, Duration::from_millis(100));
let mut connection = Connection::new(&open, Duration::from_millis(100), |c| c.write_all(input.as_bytes()).unwrap());
assert_eq!(open.load(Ordering::SeqCst), 1);
connection.client.get_mut().write_all(input.as_bytes()).unwrap();
let mut reply = String::new();
let closed = connection.client.read_to_string(&mut reply);
assert!(closed.is_ok(), "idle connection stayed open: {closed:?}");
@@ -285,8 +287,7 @@ mod tests {
assert!(reply["error"].as_str().unwrap().contains("idle read timed out"));
// A subsequent connection is admitted through the same accounting path.
let mut next = Connection::new(&open, Duration::from_secs(1));
next.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
let mut next = Connection::new(&open, Duration::from_secs(1), |c| c.shutdown(std::net::Shutdown::Write).unwrap());
let mut eof = String::new();
next.client.read_to_string(&mut eof).unwrap();
next.join();
@@ -298,12 +299,20 @@ mod tests {
#[test]
fn persistent_requests_and_slow_replies_outlive_the_idle_read_timeout() {
let open = Arc::new(AtomicUsize::new(0));
let mut connection = Connection::new(&open, Duration::from_millis(100));
let request = |id: u64| format!("{}\n", json!({"id": id, "method": "engine.commands"}));
// Each request reaches the socket before the worker reads it (the next one while the worker
// waits for the reply), so only the reply waits outlast the idle timeout, however slowly
// this thread runs.
let mut connection = Connection::new(&open, Duration::from_millis(100), |c| c.write_all(request(1).as_bytes()).unwrap());
for id in [1, 2] {
writeln!(connection.client.get_mut(), "{}", json!({"id": id, "method": "engine.commands"})).unwrap();
let req = connection.requests.recv_timeout(Duration::from_secs(3)).unwrap();
let req = connection.requests.recv_timeout(Duration::from_secs(10)).unwrap();
assert_eq!(req.method, "engine.commands");
assert!(req.take());
if id == 1 {
connection.client.get_mut().write_all(request(2).as_bytes()).unwrap();
} else {
connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
}
// A dispatched command waits for its reply, not for socket input.
std::thread::sleep(Duration::from_millis(250));
req.reply.send(json!({"ok": true, "result": id})).unwrap();
@@ -312,7 +321,6 @@ mod tests {
let reply: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(reply, json!({"id": id, "ok": true, "result": id}));
}
connection.client.get_mut().shutdown(std::net::Shutdown::Write).unwrap();
let mut eof = String::new();
connection.client.read_to_string(&mut eof).unwrap();
connection.join();
@@ -327,7 +335,8 @@ mod tests {
let (req, rrx) = ControlRequest::new("ui.inspect", json!({}));
let t0 = std::time::Instant::now();
let r = await_reply(&rrx, &req.state, || 7, || {}, Duration::from_millis(50), Duration::from_secs(10));
assert!(t0.elapsed() < Duration::from_secs(2));
// the frame window (50 ms), not the 10 s timeout; the margin is for a slow machine
assert!(t0.elapsed() < Duration::from_secs(5), "{:?}", t0.elapsed());
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap().contains("not visible"), "{r}");
assert!(!req.take(), "the app does not run it once it shows up");
-4
View File
@@ -456,10 +456,6 @@ 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);
}
+1 -1
View File
@@ -100,7 +100,7 @@ jpegxl-rs / imagequant (GPL / C), dav1d / libheif (C).
## Performance (Apple M-series, release, synthetic 24 MP JPEG q90 4:2:0)
Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`.
Run `cargo test --release -p lightcraft-codecs --test it bench:: -- --ignored --nocapture`.
| Operation | Time |
|---|---|
@@ -1,5 +1,5 @@
//! Benchmark-ish, ignored by default. Run with:
//! `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocapture`
//! `cargo test --release -p lightcraft-codecs --test it bench:: -- --ignored --nocapture`
use lightcraft_codecs::*;
use std::time::Instant;
@@ -1,7 +1,6 @@
//! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from
//! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a
//! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input.
#![cfg(feature = "heif")]
use lightcraft_codecs::*;
use lightcraft_heif::testdata::{Prop, Spec, build};
@@ -1,5 +1,3 @@
#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
use lightcraft_codecs::exif::minimal_exif;
use lightcraft_codecs::*;
use lightcraft_raster::Rgba8;
+13
View File
@@ -0,0 +1,13 @@
//! The integration tests, one module per file, linked into one test binary.
//! `tests/jxl_tail_heap.rs` stays a binary of its own: it installs dhat as the global allocator.
mod bench;
mod corpus;
mod float_tiff;
mod fuzz;
mod header;
#[cfg(feature = "heif")]
mod heif;
#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
mod jxl_streaming;
mod roundtrip;
+4
View File
@@ -0,0 +1,4 @@
//! The integration tests, one module per file, linked into one test binary.
#[cfg(feature = "runtime")]
mod real_model;
@@ -2,7 +2,7 @@
//!
//! ```text
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_MOSAICS=<folder> LC_DENOISE_OUT=<folder> \
//! cargo test --release -p lightcraft-denoise --features runtime --test real_model -- --ignored --nocapture
//! cargo test --release -p lightcraft-denoise --features runtime --test it real_model:: -- --ignored --nocapture
//! ```
//!
//! `LC_DENOISE_MOSAICS` holds `<name>.mosaic.f32` (little-endian f32, normalised: black 0, white 1, not white
@@ -11,8 +11,6 @@
//! once, default 8), `LC_DENOISE_ONLY` (one name), `LC_DENOISE_ISO` (noise strength, default 1.0; 0 adds none),
//! `LC_DENOISE_FULL` (the whole frame instead of a 2048 × 1536 crop).
#![cfg(feature = "runtime")]
use std::path::{Path, PathBuf};
use std::time::Instant;
+13 -6
View File
@@ -38,13 +38,13 @@ impl std::error::Error for NotFollowed {}
/// was opened from.
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
// A path that is still relative would be taken from the working directory, which the
// document does not choose.
if path.is_relative() {
return Err(NotFollowed::NotHere);
match craft_docpath::resolve_absolute(stored, document.as_deref()) {
Ok(path) => Ok(path),
// A usable path that is not absolute here: a Windows path on macOS or Linux, or a
// relative path with no document folder to take it from.
Err(Refused::Invalid) if craft_docpath::resolve(stored, document.as_deref()).is_ok() => Err(NotFollowed::NotHere),
Err(r) => Err(NotFollowed::Refused(r)),
}
Ok(path)
}
#[cfg(test)]
@@ -59,5 +59,12 @@ mod tests {
assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
#[cfg(windows)]
assert_eq!(resolve("Photos/2024:05/a.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid)));
#[cfg(not(windows))]
assert_eq!(resolve(r"C:\Photos\a.jpg", Some(&doc)), Err(NotFollowed::NotHere));
let gvfs = Path::new("/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/catalog.lrcat");
#[cfg(not(windows))]
assert_eq!(resolve("a.jpg", Some(gvfs)), Ok(gvfs.with_file_name("a.jpg")));
}
}
+1 -1
View File
@@ -2809,7 +2809,7 @@ pub(crate) mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs`
/// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/it/vendor_robust.rs`
/// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037).
fn sony_arw(model: &str) -> Vec<u8> {
use lightcraft_tiff::tags::{self as t, photometric};
+1 -1
View File
@@ -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;
@@ -1,10 +1,9 @@
//! Opt-in same-machine denoise model-stage benchmark on one public Bayer raw.
//! LC_DENOISE_MODEL=<onnx> LC_DENOISE_RAW=<CC0 corpus/raw> cargo test --release
//! -p lightcraft-engine --features denoise --test denoise_cpu -- --ignored --nocapture
//! -p lightcraft-engine --features denoise --test it denoise_cpu:: -- --ignored --nocapture
//! LC_DENOISE_ONLY selects a file (default arw-sony-a7m3-compressed.arw).
//! The whole model stage includes packing, scale/clip protection, blending and normalization;
//! file decode, model loading and GPU setup are outside the timer. It uses the application's tile pool convention.
#![cfg(feature = "denoise")]
use lightcraft_denoise::{
TileRunner,
@@ -4,12 +4,11 @@
//!
//! ```text
//! LC_DENOISE_MODEL=<model_bayer.onnx> LC_DENOISE_RAW=<folder with raw files> \
//! cargo test --release -p lightcraft-engine --features denoise --test denoise_real -- --ignored --nocapture
//! cargo test --release -p lightcraft-engine --features denoise --test it denoise_real:: -- --ignored --nocapture
//! ```
//!
//! `LC_DENOISE_RAW` is a folder of CC0 raw files (`cargo xtask corpus --download` → `corpus/raw`); every file in it is
//! tried. Optional: `LC_DENOISE_ONLY` (a file name to try alone), `LC_DENOISE_RUN_ON=cpu|gpu|auto` (where the model runs; `cpu` to compare with the card).
#![cfg(feature = "denoise")]
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
+7
View File
@@ -0,0 +1,7 @@
//! The integration tests, one module per file, linked into one test binary.
#[cfg(feature = "denoise")]
mod denoise_cpu;
#[cfg(feature = "denoise")]
mod denoise_real;
mod preview_read;
@@ -2,7 +2,7 @@
//! network share, which is where reading only the header instead of the whole raw matters:
//!
//! LC_PREVIEW_RAW="/run/user/1000/gvfs/smb-share:.../DSCF0001.RAF" \
//! cargo test --release -p lightcraft-engine --test preview_read -- --ignored --nocapture
//! cargo test --release -p lightcraft-engine --test it preview_read:: -- --ignored --nocapture
//!
//! It reports what the preview cost and fails when the read is not the small part of the file the
//! header path promises. Skipped without `LC_PREVIEW_RAW` (there is no committed raw fixture: media
+3 -1
View File
@@ -118,7 +118,9 @@ impl Drop for Buf {
let mut b = Some(b);
let _ = RETIRED.try_with(|r| r.0.borrow_mut().extend(b.take()));
match b {
Some(b) => std::mem::forget(b),
// Leaking is deliberate during thread teardown — dropping a wgpu buffer whose device
// thread-locals have already gone can abort the process. The device owns it.
Some(b) => std::mem::forget(b), // NOSONAR
None => {
RETIRED_BYTES.fetch_add(size, Ordering::Relaxed);
}
@@ -1,7 +1,7 @@
//! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of
//! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs;
//! xtask runs it after the workspace tests, so it costs no extra build:
//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`.
//! `cargo test --workspace --test it -- --ignored --exact gpu_probe::gpu_probe --nocapture`.
//!
//! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`,
//! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build
@@ -11,7 +11,6 @@
//! a driver that crashes while it is enumerated (issue #136) costs only its own line.
//!
//! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy.
#![cfg(not(target_arch = "wasm32"))]
use lightcraft_gpu::backend::{self, Backends};
+9
View File
@@ -0,0 +1,9 @@
//! The integration tests, one module per file, linked into one test binary.
//! Binaries of their own: `tests/shutdown.rs` and `tests/shutdown_first.rs` close the GPU for the whole
//! process, and `tests/memory.rs` checks the process's device-memory counters, which renders on other test
//! threads would change.
mod equivalence;
mod fallback;
#[cfg(not(target_arch = "wasm32"))]
mod gpu_probe;
+3
View File
@@ -0,0 +1,3 @@
//! The integration tests, one module per file, linked into one test binary.
mod e2e;
+5
View File
@@ -0,0 +1,5 @@
//! The integration tests, one module per file, linked into one test binary.
mod hdr;
mod pano;
mod white_balance;
+3
View File
@@ -0,0 +1,3 @@
//! The integration tests, one module per file, linked into one test binary.
mod robust;
+3
View File
@@ -0,0 +1,3 @@
//! The integration tests, one module per file, linked into one test binary.
mod preview_save;
+1 -1
View File
@@ -373,7 +373,7 @@ fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) {
/// low-pass columns (bands 8 and 5, whose low-pass columns the next level transforms again) at an odd
/// width: an even synthesis width gains one column, which synthesis does not use. Every other band,
/// including band 2 of the last level, is coded at its synthesis width. Measured on every C-RAW plane
/// that `tests/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816,
/// that `tests/it/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816,
/// 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, R100, 250D, RP, R, 90D, M6 Mark II), where bands 5
/// and 8 gain a column in left tiles exactly when the rule says so, and single-tile `0x100` and
/// `0x200` files, which never extend a band. In every one of those planes, each band's entropy stream
@@ -4,7 +4,8 @@
//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, generic CFA TIFF,
//! older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report
//! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times
//! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`).
//! (`cargo test -p lightcraft-raw --release --test it corpus:: -- --nocapture --skip _corpus::`; the
//! `--skip` leaves out the other `*_corpus` modules).
use lightcraft_raw::{RawError, RawFormat, decode, embedded_preview, probe, probe_info};
use std::path::{Path, PathBuf};
@@ -946,7 +947,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 +1201,7 @@ fn sony_pins_match_the_corpus_manifest() {
lines.sort();
let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor");
let manifest = include_str!("../../../xtask/src/main.rs");
let manifest = include_str!("../../../../xtask/src/main.rs");
for (name, sha) in SONY_SAMPLES {
let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest"));
assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest");
@@ -10,7 +10,7 @@ use lightcraft_raw::{RawData, decode, probe_info};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
const RECORDS: &str = include_str!("../../../docs/raw/coverage-sweep.tsv");
const RECORDS: &str = include_str!("../../../../docs/raw/coverage-sweep.tsv");
#[test]
fn coverage_sweep_sensor_decodes_are_pinned() {
+20
View File
@@ -0,0 +1,20 @@
//! The integration tests, one module per file, linked into one test binary.
//! `tests/sr2_heap.rs` stays a binary of its own: it installs dhat as the global allocator.
mod body_corpus;
mod canon_pentax_corpus;
mod corpus;
mod coverage_sweep_corpus;
mod cr3_corpus;
mod cr3_variants;
mod crx_properties;
mod dng;
mod lossy_dng;
mod nrw_be16;
mod olympus_samsung_corpus;
mod rendered_dng;
mod robust;
mod sony_leica_corpus;
mod sony_lens_inventory;
mod sparse_corpus;
mod vendor_robust;
@@ -5,7 +5,7 @@
//! For every file the plan reaches `Ready` on, probing the partly loaded buffer must give exactly
//! what probing the whole file gives — with the skipped bytes zero and again with them 0xFF, so a
//! reader that looked at skipped data would be caught. CR3 and DNG files must be described from a
//! small part of the file (`cargo test -p lightcraft-raw --release --test sparse_corpus -- --nocapture`
//! small part of the file (`cargo test -p lightcraft-raw --release --test it sparse_corpus:: -- --nocapture`
//! prints what each format reads).
use lightcraft_raw::sparse::{HeaderPlan, Ranges, header_plan, preview_plan};
+4
View File
@@ -0,0 +1,4 @@
//! The integration tests, one module per file, linked into one test binary.
mod reference;
mod remote;
+3 -3
View File
@@ -16,7 +16,7 @@
//!
//! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file
//! (`cargo xtask ci` sets it), appends one JSON line there:
//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`.
//! `{"need":"gpu","reason":"no GPU adapter","test":"equivalence::camera_tone","binary":"it","location":"crates/gpu/tests/it/equivalence.rs:22"}`.
//! `cargo xtask ci` lists the skips by need and reason in its summary and in
//! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records
//! the part it could not run the same way.
@@ -142,13 +142,13 @@ mod tests {
#[test]
fn an_entry_is_one_json_line_with_its_text_escaped() {
let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28);
let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "it", "crates\\raw\\tests\\it\\corpus.rs", 28);
assert!(line.ends_with('\n'));
assert_eq!(line.matches('\n').count(), 1, "{line}");
assert_eq!(
line,
"{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\
\"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n"
\"binary\":\"it\",\"location\":\"crates/raw/tests/it/corpus.rs:28\"}\n"
);
}
+3
View File
@@ -0,0 +1,3 @@
//! The integration tests, one module per file, linked into one test binary.
mod roundtrip;
+8 -16
View File
@@ -7,9 +7,9 @@
//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means
//! adding its file (and its `language_table!` entry); this script picks the file up by name.
//!
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog
//! does not compile, so a message missing from one language would otherwise go unnoticed until it
//! shows English.
//! A message a language lacks (or translates as an empty string) shows the English source: that
//! language has no arm for it, and a message no catalog has formats the English source through the
//! macro's last arm. `i18n::tests::translation_gaps_report` lists the gaps per language.
//!
//! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`,
//! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from
@@ -61,25 +61,14 @@ fn generate() -> Result<String, Box<dyn std::error::Error>> {
let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue };
println!("cargo:rerun-if-changed=locales/{name}");
let variant = variant_for(code)?;
let messages: BTreeMap<String, String> =
let mut messages: BTreeMap<String, String> =
serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?;
messages.retain(|_, translated| !translated.is_empty());
catalogs.push((code.to_string(), variant, messages));
}
catalogs.sort_by(|a, b| a.0.cmp(&b.0));
// Every catalog translates the same messages.
let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect();
let mut problems = Vec::new();
for (code, _, messages) in &catalogs {
for key in &all {
if !messages.contains_key(*key) {
problems.push(format!("locales/{code}-formats.json is missing {key:?}"));
}
}
}
if !problems.is_empty() {
return Err(problems.join("\n").into());
}
let mut source = String::from(concat!(
"/// Translate a format string, checked by `format!` in every language.\n",
@@ -98,6 +87,9 @@ fn generate() -> Result<String, Box<dyn std::error::Error>> {
source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n"));
}
source.push_str(concat!(
" ($english:literal $(, $($args:tt)*)?) => {\n",
" format!($english $(, $($args)*)?)\n",
" };\n",
"}\n",
"\n",
"// `macro_rules!` is textual: the module re-export is what lets call sites say\n",
-1
View File
@@ -2344,7 +2344,6 @@
"Loupe Info 1": "Info lente 1",
"Loupe Info 2": "Info lente 2",
"Use Defaults": "Usa predefiniti",
"Dimensions": "Dimensioni",
"Camera & Lens": "Fotocamera e obiettivo",
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2."
}
+1 -4
View File
@@ -2495,7 +2495,7 @@ mod tests {
assert!(!row.cancellable, "{row:?}");
assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked");
let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default();
assert_eq!(current, "Wird zu Apple Fotos hinzugefügt…");
assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…"));
// the row says so on the next frame (issue #345)
h.step();
let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row");
@@ -4570,9 +4570,6 @@ mod tests {
assert_eq!(crate::i18n::language(), locale);
for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] {
let expected = locale.tr(key);
if locale != Locale::En {
assert_ne!(expected, key, "this locale must translate the tested message");
}
assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true);
assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode));
assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected);
+383 -806
View File
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -684,25 +684,17 @@ mod tests {
}
#[test]
fn skipped_images_are_one_line_in_the_ui_language() {
fn skipped_images_are_one_line() {
let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [
{"image": 7, "reason": "missingFile"},
{"image": 8, "reason": "missingRoot"},
{"image": 9, "reason": "masterSkipped"},
]}});
let details = |locale| {
crate::i18n::set_language(locale);
let dialog = result_dialog(&value, "done");
crate::i18n::set_language(crate::i18n::Locale::En);
let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") };
details
};
let en = details(crate::i18n::Locale::En);
crate::i18n::set_language(crate::i18n::Locale::En);
let crate::state::Dialog::LightroomResult { details: en, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") };
assert_eq!(en.len(), 4, "{en:?}");
assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete.");
assert_eq!(&en[2..], ["w1", "w2"]);
let es = details(crate::i18n::Locale::Es);
assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos.");
}
#[test]
-12
View File
@@ -1533,18 +1533,6 @@ mod tests {
}
}
#[test]
fn library_context_menu_labels_have_french_translations() {
let saved = crate::i18n::language();
crate::i18n::set_language(crate::i18n::Locale::Fr);
for (label, expected) in
[("Pick", "Sélectionnée"), ("Reject", "Rejeter"), ("Group into Stack", "Regrouper en pile"), ("HDR Panorama…", "Panorama HDR…")]
{
assert_eq!(crate::i18n::tr(label), expected);
}
crate::i18n::set_language(saved);
}
#[test]
fn square_grid_groups_and_auto_levels() {
let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true);
+29 -41
View File
@@ -520,6 +520,35 @@ fn in_tree(tree: &[KeywordNode], path: &str) -> bool {
tree.iter().any(|n| same(&n.path, path) || (lightcraft_catalog::keywords::is_under(path, &n.path) && in_tree(&n.children, path)))
}
/// The labels the Keyword List and its dialogs pass to `tr` through variables, out of reach of a
/// literal search: the translation gaps report (`i18n`) checks them.
#[cfg(test)]
pub(crate) const VARIABLE_LABELS: &[&str] = &[
"Include on Export",
"Export Containing Keywords",
"Export Synonyms",
"Person",
"Merge Keywords",
"No keywords yet",
"No keywords match",
"Add to Selected Photos",
"Remove from Selected Photos",
"Keywords",
"& Containing",
"Will Export",
"No keywords are exported",
"Add to All Selected Photos",
"Set name",
"Edit Set…",
"Edit Keyword Set",
"A keyword set needs a name",
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
"Import Keywords…",
"Export Keywords…",
];
#[cfg(test)]
mod tests {
use lightcraft_catalog::{Op, Photo, Source};
@@ -589,45 +618,4 @@ mod tests {
let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]);
assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some);
}
/// The labels the Keyword List and its dialogs pass to `tr` through variables (out of reach of
/// a literal search) are translated in every language.
#[test]
fn labels_passed_through_variables_are_translated() {
use crate::i18n::Locale;
let labels = [
"Include on Export",
"Export Containing Keywords",
"Export Synonyms",
"Person",
"Merge Keywords",
"No keywords yet",
"No keywords match",
"Add to Selected Photos",
"Remove from Selected Photos",
"Keywords",
"& Containing",
"Will Export",
"No keywords are exported",
"Add to All Selected Photos",
"Set name",
"Edit Set…",
"Edit Keyword Set",
"A keyword set needs a name",
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
"Import Keywords…",
"Export Keywords…",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs", "sv", "fi", "id", "vi"] {
let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap();
let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap();
for label in labels {
assert!(catalog.contains_key(label), "{code}: “{label}”");
}
}
assert_eq!(Locale::ALL.len(), 18, "a language added: list its file above");
}
}
-19
View File
@@ -897,25 +897,6 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
mod tests {
use super::*;
#[test]
fn masking_context_actions_use_catalog_translations() {
let saved = crate::i18n::language();
crate::i18n::set_language(crate::i18n::Locale::Fr);
for (english, translated) in [
("Duplicate Mask", "Dupliquer le masque"),
("Invert Mask", "Inverser le masque"),
("Hide Mask", "Masquer le masque"),
("Move Up", "Monter"),
("Brush", "Pinceau"),
("Luminance Range", "Plage de luminance"),
] {
assert_eq!(menu_label(english), translated, "{english}");
}
// An arbitrary user name has no catalog entry and is left unchanged.
assert_eq!(menu_label("My custom mask"), "My custom mask");
crate::i18n::set_language(saved);
}
/// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label
/// room by dropping a column rather than drawing it past its tile.
#[test]
+1 -9
View File
@@ -343,20 +343,12 @@ mod import_review {
}
}
/// Every shipped locale paints translated destination choices and initializes a translated new name.
/// Every shipped locale paints its own destination choices and names a new album in its language.
#[test]
fn import_album_picker_localizes_choices_in_every_shipped_language() {
use crate::i18n::Locale;
let Review { mut h, .. } = review();
for locale in Locale::ALL {
for source in
["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"]
{
assert!(!locale.tr(source).is_empty());
if *locale != Locale::En {
assert_ne!(locale.tr(source), source, "{locale:?}: {source}");
}
}
let r = h.request("ui.set", json!({"language": locale.code()}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
+5
View File
@@ -0,0 +1,5 @@
//! The integration tests, one module per file, linked into one test binary.
//! `tests/shutdown.rs` stays a binary of its own: it closes the GPU for the whole process.
mod accessibility;
mod docking_workflows;
+1 -1
View File
@@ -238,7 +238,7 @@ wait and return the result (when built with the `sam` feature; the default CLI h
the model on the CPU from the start.
- Hostile documents: a selection keeps at most 4 detail patches (when read and when rendered),
each at most 1024² logits; more than 64 clicks are never sent to the model.
- Accuracy: `crates/segment/tests/reference.rs` compares every stage against the Python
- Accuracy: `crates/segment/tests/it/reference.rs` compares every stage against the Python
reference (`tools/sam3_reference.py`); on Metal the backbone matches to 7e-5 relative error
and masks to ~5e-5. f16 was tried and rejected (overflow, ~7 % faster).
- The engine feature `sam` (on in the desktop app, off for web and CLI) gates the model; without
+12 -1
View File
@@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`cargo xtask ci` reports the machine and skipped tests; `testkit`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (`cargo xtask ci`: one integration-test binary per crate, doctests apart, one WASM check) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only
(`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted
@@ -63,6 +63,16 @@ dispatch by id, and headless snapshots render the UI without a window.
`assets` (every asset attributed, no Adobe formats) and the WASM build. Other workflows build and
test FreeBSD and Windows ARM64, lint packaging, and build releases.
The tests run under `cargo test --workspace --lib --bins --tests`, or `cargo nextest run` with the
same targets when `LIGHTCRAFT_NEXTEST=1` is set and cargo-nextest is installed; doctests run after
them with `cargo test --workspace --doc`. `cargo test` is the default because the demo library's
generated scenes are cached per process, and nextest's process per test regenerates them.
Examples are left out of the test build (clippy `--all-targets` still
checks them). Each crate links its integration tests into one binary, `tests/it/main.rs` with one
module per file (`cargo test -p <crate> --test it <module>::`); a test that changes state for its
whole process (a global allocator, closing the GPU) keeps a binary of its own in `tests/`. The WASM
build is one `cargo check --target wasm32-unknown-unknown --keep-going` over the wasm-safe crates.
`ci` also probes the machine: OS, CPUs, RAM, the GPU adapters of each backend (from the ignored
`gpu_probe` test of `lightcraft-gpu`, run after the workspace tests so it reuses their build; xtask
has no wgpu), the corpus and `CRAFT_FONTS_DIR`. Tests
@@ -77,5 +87,6 @@ the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | Quality gates: one integration-test binary per crate, doctests apart, nextest on request, one WASM check |
| 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` |
| 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) |
+3 -3
View File
@@ -39,7 +39,7 @@ decoded.
## Variant status
The supported and rejected CRX coding variants. Every rejection is an explicit `RawError::Unsupported` whose
text is part of the contract: `crates/raw/tests/cr3_variants.rs` rebuilds each variant as a synthetic CR3
text is part of the contract: `crates/raw/tests/it/cr3_variants.rs` rebuilds each variant as a synthetic CR3
fixture and asserts the exact string, and that header probing (`probe_info`, the import path) and full
decoding fail with the same error, because the engine shows that reason when it falls back to the embedded
preview. The same tests decode a control fixture at both edges of each accepted range, so a rejection can
@@ -87,7 +87,7 @@ CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data.
Other unverified coding variants return explicit unsupported errors (the table above lists them all). Header probing validates the same coding variants and
tile/plane layout as full decoding.
`crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a
`crates/raw/tests/it/cr3_corpus.rs` checks every full-sensor sample against a
black-box reference, before cropping or colour processing. Public CC0 files
are downloaded by `cargo xtask corpus --download` and kept outside version
control. The exact inputs and reference hashes are:
@@ -194,7 +194,7 @@ All 110 CR3 files on raw.pixls.us (2026-10-12, every one CC0, 31 folders) decode
falls back to the preview, so none of them uses a CRX variant the decoder refuses (vertical C-RAW tiles,
partial-subband or rounded-plane coding, multi-tile 0x200, adaptive low-frequency gains) or a CR3
sRAW / mRAW. HEIF stills are separate `.HIF` files, not CR3. One file per body is pinned in
`canon_cr3_bodies_decode_like_the_camera_jpeg` (`crates/raw/tests/cr3_corpus.rs`; URLs and SHA-256 in
`canon_cr3_bodies_decode_like_the_camera_jpeg` (`crates/raw/tests/it/cr3_corpus.rs`; URLs and SHA-256 in
`RAW_SAMPLES`, `xtask/src/main.rs`): EOS-1D X Mark III, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II,
R, R3, R5, R6, R6 Mark II, R6 Mark III, R7, R10, RP, R50, R50 V and PowerShot G5 X Mark II, G7 X Mark III,
SX70 HS and V1, C-RAW where the body has a CC0 C-RAW sample. No external decoder was run: the sensor
+2 -2
View File
@@ -117,8 +117,8 @@ Fresh checks can be run using an already installed model and CC0 raw samples:
```text
LC_DENOISE_MODEL=<model.onnx> cargo test --release -p lightcraft-gpu --lib real_model_on_the_gpu_matches_cpu -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_cpu -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test denoise_real -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test it denoise_cpu:: -- --ignored --nocapture
LC_DENOISE_MODEL=<model.onnx> LC_DENOISE_RAW=<corpus/raw> cargo test --release -p lightcraft-engine --features rawnind-model --test it denoise_real:: -- --ignored --nocapture
```
## Another computer's graphics card
+1 -1
View File
@@ -161,5 +161,5 @@ before demosaicing would be needed.
vignette gain on the X-T4's real arrays, and the X-T4 geometry against its measured free fit.
- `crates/raw/src/vendor/raf.rs`: a synthetic RAF through header and full decode, only for validated models, and a DNG
round trip.
- `crates/raw/tests/corpus.rs` `fuji_embedded_lens_tables_follow_the_validated_models`: the corpus' `raf-fuji-*` files,
- `crates/raw/tests/it/corpus.rs` `fuji_embedded_lens_tables_follow_the_validated_models`: the corpus' `raf-fuji-*` files,
or any folder of RAFs in `LIGHTCRAFT_FUJI` (the pixls set: 129 decoded, 88 warps, 98 vignette gains).
+3 -3
View File
@@ -229,7 +229,7 @@ passes on as `RenderResult::gpu_fallback`; an export carries it as `Exported::gp
each file of an `app.export` batch result has `gpuFallback` when its render fell back (issue #675).
**Reproducing a smaller GPU:** `LIGHTCRAFT_GPU_LIMITS=webgpu` (or `downlevel`) creates the device
with 128 MiB storage bindings / 256 MiB buffers, `LIGHTCRAFT_GPU_LIMITS=<n>` with n MiB / 2n MiB.
Tests inject failures with `lightcraft_gpu::inject_fault` (`crates/gpu/tests/fallback.rs`,
Tests inject failures with `lightcraft_gpu::inject_fault` (`crates/gpu/tests/it/fallback.rs`,
`export_falls_back_to_the_cpu_when_gpu_work_is_lost`). The fault is thread-local, and so are its
consequences: a simulated fatal failure stops GPU use on the injecting thread only (`available()`
false, `unavailable_reason()` set there), until `reset_failures()`, which undoes nothing else — a
@@ -237,7 +237,7 @@ real failure still stops the whole process. Such a test asserts on its own rende
`last_fallback()`, so it can run beside other tests rendering on the GPU (issue #675).
## Correctness: CPU oracle and equivalence tests
`crates/gpu/tests/equivalence.rs` renders the same settings on both and compares 8-bit sRGB:
`crates/gpu/tests/it/equivalence.rs` renders the same settings on both and compares 8-bit sRGB:
bounds **mean |Δ| < 0.5 LSB and max |Δ| ≤ 3 LSB** per channel. Measured (Apple M4 Pro, Metal):
**max 1 LSB, mean ≤ 0.0003 LSB** for every case — 26 settings cases (all tone/colour/effects tools,
3 vignette styles, grain, curves, grading, B&W, WB, NR, crop/straighten/flip, lens + perspective,
@@ -254,7 +254,7 @@ cut back after the readback. The tests skip (pass with a note) when no adapter e
non-Apple GPU verified: all 16 tests pass, 135 compared cases. 124 differ by max 1 LSB, 10 are
bit-identical, and one (the point colour range overlay) has max 2 LSB on 0.0002% of pixels. Worst
mean 0.0003 LSB. That is the same picture as on Metal, inside the bounds
(`cargo test -p lightcraft-gpu --release --test equivalence -- --nocapture`).
(`cargo test -p lightcraft-gpu --release --test it equivalence:: -- --nocapture`).
Remaining differences come from f32 vs f64 coordinate math, fast-math transcendental functions on
Metal and summation order in the box filters — all far below one 8-bit step.
+3 -2
View File
@@ -46,8 +46,9 @@ ani anglické zdrojové řetězce. Rozložení panelů a seznamu jazyků je beze
## Ověření a revize
Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál,
uložení jazyka, pokrytí katalogů, placeholdery, neutrální formulace počtů, datum, čas
a české glyfy. Překladové šablony kontroluje také Rust při sestavení.
uložení jazyka, placeholdery, datum, čas a české glyfy. Překladové šablony kontroluje také
Rust při sestavení. Chybějící překlady se zobrazují anglicky a vypisuje je
`i18n::tests::translation_gaps_report`; žádný test kvůli nim neselže.
Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack,
„bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení.
+3 -3
View File
@@ -29,8 +29,8 @@ cargo test -p lightcraft-ui-egui i18n::tests
LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000
```
Die Tests prüfen Katalogabdeckung, Platzhalter, Sprachwechsel per Menü und Steuerkanal,
Speicherung, Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute
und ß. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft
Die Tests prüfen Platzhalter, Sprachwechsel per Menü und Steuerkanal, Speicherung,
Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute und ß. Fehlende
Übersetzungen erscheinen auf Englisch; `i18n::tests::translation_gaps_report` listet sie auf. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft
auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`;
gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md).
+3 -2
View File
@@ -52,8 +52,9 @@ englanniksi. Käännös ei muuta kirjastotietoja eikä englanninkielisiä lähde
## Testaus
`i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta,
kielen tallennuksen, luetteloiden kattavuuden, paikkamerkit, määrämuodot, päivämäärän, ajan ja
suomen kirjaimet. Rust tarkistaa muotoilumallit käännösvaiheessa.
kielen tallennuksen, paikkamerkit, päivämäärän, ajan ja suomen kirjaimet. Rust tarkistaa
muotoilumallit käännösvaiheessa. Puuttuvat käännökset näkyvät englanniksi, ja
`i18n::tests::translation_gaps_report` luettelee ne; mikään testi ei epäonnistu niiden takia.
Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading),
”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing),
+4 -4
View File
@@ -19,7 +19,7 @@ not translated. Descriptions for SAM 3 go to the model unchanged.
## Georgian script
Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the
interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts
interface never uses Mtavruli. Commands are verbal nouns. Counts
are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with
`{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`.
@@ -73,7 +73,7 @@ cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000
```
The tests check catalog coverage, placeholders, switching language by menu and control channel,
saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case
keeps Georgian lowercase. More notes are in
The tests check placeholders, switching language by menu and control channel, saving, the `ka`
locale tags, dates and that sentence case keeps Georgian lowercase. Messages Georgian lacks show in
English; `i18n::tests::translation_gaps_report` lists them. More notes are in
[localization.md](localization.md).
+3 -2
View File
@@ -66,8 +66,9 @@ LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi.png --size 1600x1000
LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800
```
Các test tiếng Việt bắt buộc phủ catalog union, nhãn lệnh/control/Settings/Smart Album,
đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng, và glyph dấu.
Các test tiếng Việt kiểm tra đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng và glyph
dấu. Chuỗi còn thiếu hiện bằng tiếng Anh và được `i18n::tests::translation_gaps_report` liệt kê;
không test nào thất bại vì thiếu bản dịch.
## Nguồn dịch (clean-room)
+2 -1
View File
@@ -34,5 +34,6 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。
翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由
两种目录的占位符是否一致、字形是否齐全,由
`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。
缺少的译文显示为英文,由 `i18n::tests::translation_gaps_report` 列出,不会让测试失败。

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