diff --git a/.gitignore b/.gitignore
index 51531a34..8c2047fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,9 @@ plan/
.DS_Store
.claude/
/craft-fonts
+# nix build / nix develop
+/result
+/result-*
# model weights are never committed (SAM 3: docs/ai-masks.md)
*.safetensors
*.safetensors.part
diff --git a/AGENTS.md b/AGENTS.md
index 588b6f41..eb5b9430 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -119,4 +119,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## Map of the code
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
-photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
+photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
+`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
+desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
+in CI: it may lag behind the workspace; see README → Quick start.
diff --git a/CLAUDE.md b/CLAUDE.md
index 588b6f41..eb5b9430 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -119,4 +119,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
## Map of the code
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
-photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`).
+photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
+`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
+desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
+in CI: it may lag behind the workspace; see README → Quick start.
diff --git a/Cargo.lock b/Cargo.lock
index 3f086161..8de1ee49 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2900,6 +2900,17 @@ dependencies = [
"web-time",
]
+[[package]]
+name = "lightcraft-fetch"
+version = "0.2.1"
+dependencies = [
+ "log",
+ "rustls",
+ "rustls-rustcrypto",
+ "sha2",
+ "webpki-roots",
+]
+
[[package]]
name = "lightcraft-geom"
version = "0.2.1"
@@ -3039,14 +3050,11 @@ version = "0.2.1"
dependencies = [
"candle-core",
"candle-nn",
+ "lightcraft-fetch",
"log",
- "rustls",
- "rustls-rustcrypto",
"serde",
"serde_json",
- "sha2",
"thiserror 2.0.21",
- "webpki-roots",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index d59f8f45..7af2f898 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,6 +29,7 @@ lightcraft-merge = { path = "crates/merge" }
lightcraft-engine = { path = "crates/engine" }
lightcraft-ui-egui = { path = "crates/ui-egui" }
lightcraft-mcp = { path = "crates/mcp" }
+lightcraft-fetch = { path = "crates/fetch" }
lightcraft-segment = { path = "crates/segment" }
# foundation
diff --git a/README.md b/README.md
index a1dea118..0cf23b19 100644
--- a/README.md
+++ b/README.md
@@ -258,8 +258,8 @@ lightcraft --control 7980 ~/Pictures/trip
## Fast, native, private
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
- Panasonic RW2, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, PSD
- composites and JPEG XL open today.
+ Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
+ PSD composites and JPEG XL open today.
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
@@ -301,9 +301,9 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
-| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; neutral fallback today) |
+| Camera colour: DNG files use their own matrices | ✅ · our own calibration for other raws ⬜ (top priority; ARW, NEF and RW2 start from a look fitted to their own JPEG, other raws from a neutral fallback) |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
-| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2, Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
+| RAW: DNG, CR2, ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed); embedded previews for every format incl. CR3 | ✅ · CR3, compressed RAF/ORF decode ⬜ |
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ |
@@ -357,6 +357,31 @@ Language**; the choice applies immediately and persists. See [docs/localization.
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
+**Nix** builds the desktop app and `lightcraft-cli` (the Nix build always includes the craft-fonts input, so Japanese
+text has glyphs):
+
+```sh
+nix run github:storytold/lightcraft # the desktop app
+nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
+nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
+```
+
+In a flake configuration (NixOS, home-manager, nix-darwin):
+
+```nix
+# flake.nix
+inputs.lightcraft.url = "github:storytold/lightcraft";
+# optional: build against your own nixpkgs instead of the one LightCraft pins
+# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
+
+# then, in a NixOS or home-manager module (where `inputs` is in scope):
+nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # makes `pkgs.lightcraft` available
+environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home.packages = [ pkgs.lightcraft ];
+```
+
+`nix build` installs the same desktop file, hicolor icons and AppStream metadata as the .deb/.rpm, and runs
+`cargo test --workspace` as its check phase (skip it with `pkgs.lightcraft.overrideAttrs { doCheck = false; }`).
+
**Keyboard:** G grid · D detail · E edit · C crop · M masking ·
Shift+P presets · \\ original · Y before/after · Z zoom ·
J clipping · ⌘Z undo · ⌘/ all shortcuts.
diff --git a/ROADMAP.md b/ROADMAP.md
index f2dcb9bc..dcfba518 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -23,8 +23,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
-| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~40 corpus files vs >1,000 models) |
-| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW and NEF have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
+| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
+| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF and RW2 have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
@@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| User | Readiness | What blocks them |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
-| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW and NEF preview estimates are only a starting point) |
+| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | Object / Describe masks via optional SAM 3 (download not yet hosted); no AI denoise |
@@ -46,9 +46,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
- matrices the files carry themselves, then chart shots. Sony ARW and Nikon NEF have the file-local fit (matrix + tone
- curve from their own JPEG); generalise it to the other makes' raws (RW2, PEF, ORF…), then validate fidelity.
-2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
+ matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF and Panasonic RW2 have the file-local fit
+ (matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
+2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
3. **Verified camera coverage** (LR-IMP-CAMERA-COVERAGE, P0): a CC0 sample per model in the corpus, each decoded and
@@ -84,7 +84,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
-| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW and NEF preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
+| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF and RW2 preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
@@ -118,7 +118,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
| Work package | Tracker rows | Agent-hours | Risk |
|---|---|---:|---|
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
-| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, RW2 v4, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
+| Raw codecs: CR3 (CRX), compressed ORF / RAF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
@@ -165,7 +165,9 @@ The milestone estimates in the table above were made before work started and are
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
-X-Trans), RW2 packed 12/14-bit, PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
+X-Trans), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
+packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
+118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Every
supported container also yields its embedded JPEG preview (CR3 too), and the engine shows that preview for raw variants
it can't decode yet.
@@ -174,14 +176,11 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
-- **Panasonic RW2 raw format 4** (quantised): the block layout is known (0x4000-byte chunks rotated by 0x1ff8; 128-bit
- blocks of two 12-bit seeds plus four groups of a 2-bit scale and three 8-bit codes; scales 0/1 are ×1/×2 differences
- from the same-colour pixel two to the left), the reconstruction rule for scales 2/3 is not established.
- **Olympus compressed ORF**, **Fujifilm compressed RAF**, **Canon CR3/CRX** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
-**Camera colour matrices:** ARW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
+**Camera colour matrices:** ARW, NEF and RW2 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
-matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax/Panasonic equivalents) and our
+matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.
## Log
@@ -193,6 +192,9 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
Local roots and cleanup, 85k-photo grid and catalog performance, background compaction, failed-save errors, GPU
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
tracker rows for camera colour, camera coverage and render fidelity.
+- 2026-10-07: Panasonic RW2 / Leica RWL / Panasonic RAW decode in every raw format (compressed 4/6/8, packed 2/5/7,
+ 16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118
+ bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview).
## Chinese and Japanese interfaces, and text watermarks
diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs
index 6e015af3..7cd0710b 100644
--- a/apps/lightcraft-cli/tests/cli.rs
+++ b/apps/lightcraft-cli/tests/cli.rs
@@ -292,7 +292,9 @@ fn devices_are_listed_and_imported_from() {
assert_eq!(dev["name"], "CARD", "{line}");
// the import review of its DCIM folder finds the photo
let dcim = dev["path"].as_str().unwrap().to_string();
- let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={}", serde_json::json!([dcim]))]).output().unwrap();
+ // JSON-escaped: a Windows path has backslashes
+ let paths = serde_json::to_string(&[&dcim]).unwrap();
+ let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths={paths}")]).output().unwrap();
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
}
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
index 6d0483a9..2efabebb 100644
--- a/apps/lightcraft-web/src/web.rs
+++ b/apps/lightcraft-web/src/web.rs
@@ -428,8 +428,9 @@ impl WebApp {
let cores = window().map_or(1, |w| w.navigator().hardware_concurrency() as usize);
cores.saturating_sub(1).clamp(1, 4)
});
+ let cache = app.session.media.rendered.clone();
let workers =
- (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, cc.egui_ctx.clone()));
+ (n > 0).then(|| Workers::start(n, backend.as_ref().map_or("memory", |b| b.kind()), backend.clone(), index, &cache, cc.egui_ctx.clone()));
if let Some(w) = &workers {
app.renderer.set_offload(Box::new(w.clone()));
}
diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs
index 372e84c1..0474e2a0 100644
--- a/apps/lightcraft-web/src/wire.rs
+++ b/apps/lightcraft-web/src/wire.rs
@@ -11,13 +11,13 @@
//! ([`ThumbIndex`]).
use std::collections::HashMap;
-use std::sync::Arc;
+use std::sync::{Arc, Weak};
use lightcraft_engine::catalog::Source;
use lightcraft_engine::develop::{DevelopSettings, EmbeddedLens};
use lightcraft_engine::media::{DecodedSource, MediaCache, RenderJob, SourceLevel, SourceRef};
use lightcraft_engine::pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
-use lightcraft_preview::{Hash128, Lru};
+use lightcraft_preview::{Hash128, Lru, PreviewCache};
use serde::{Deserialize, Serialize};
use crate::store::hash_of_path;
@@ -299,6 +299,33 @@ impl ThumbIndex {
}
}
+/// Which rendered-thumbnail cache, at which generation, the stored thumbnails ([`ThumbIndex`])
+/// belong to (main thread). Clearing the previews bumps the cache's generation, and opening
+/// another library replaces the cache: either makes every stored thumbnail obsolete.
+pub struct CacheWatch(Option<(Weak, u64)>);
+
+impl CacheWatch {
+ /// Watch `active`, the session's cache when the workers start: the persisted index belongs
+ /// to it at its current generation. (Without this baseline, the first observation after a
+ /// clear would only record the cleared generation and keep the obsolete thumbnails.)
+ pub fn new(active: &Arc) -> CacheWatch {
+ CacheWatch(Some((Arc::downgrade(active), active.generation())))
+ }
+
+ /// The watched cache, while it's alive.
+ pub fn cache(&self) -> Option> {
+ self.0.as_ref().and_then(|(cache, _)| cache.upgrade())
+ }
+
+ /// `cache` at `generation` is the active one now. If it was cleared or replaced since the
+ /// last observation, invalidate `index` and return the keys whose files should be deleted.
+ pub fn observe(&mut self, cache: &Arc, generation: u64, index: &mut ThumbIndex) -> Vec {
+ let changed = self.0.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation);
+ self.0 = Some((Arc::downgrade(cache), generation));
+ if changed { index.invalidate() } else { Vec::new() }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -381,6 +408,62 @@ mod tests {
assert_eq!(core.needs_original(&refreshed).as_deref(), Some(hash.as_str()), "explicit refresh must not reuse the old decoded source");
}
+ /// The persisted index as a restarted page loads it: one stored thumbnail.
+ fn persisted_index(key: Hash128) -> (ThumbIndex, String) {
+ let mut index = ThumbIndex::default();
+ let hex = index.cache_key(key);
+ index.insert(&hex, 100);
+ (ThumbIndex::from_json(&index.to_json()), hex)
+ }
+
+ #[test]
+ fn clearing_previews_before_the_first_request_invalidates_stored_thumbnails() {
+ let key = lightcraft_preview::hash_bytes(b"thumbnail");
+ let (mut index, old) = persisted_index(key);
+ let cache = Arc::new(PreviewCache::memory(1 << 20));
+ let mut watch = CacheWatch::new(&cache);
+ let mut gone = Vec::new();
+ // each frame (`Workers::finished`) observes the watched cache, even with no requests
+ let mut frame = |watch: &mut CacheWatch, index: &mut ThumbIndex| {
+ if let Some(c) = watch.cache() {
+ gone.extend(watch.observe(&c, c.generation(), index));
+ }
+ };
+ frame(&mut watch, &mut index); // an empty filtered view: no render request yet
+ cache.clear(); // File ▸ Clear Preview Cache
+ frame(&mut watch, &mut index);
+ // the filter is removed: the first thumbnail request (`Workers::try_start`)
+ gone.extend(watch.observe(&cache, cache.generation(), &mut index));
+ let disk_key = index.cache_key(key);
+ assert!(!index.touch(&disk_key), "the pre-clear thumbnail must not be read back");
+ assert_eq!(gone, vec![old.clone()], "the stored file is deleted");
+ assert_ne!(disk_key, old, "a fresh namespace: a late old write can't come back");
+ assert!(index.dirty, "the invalidated index is saved");
+ }
+
+ #[test]
+ fn replacing_the_cache_before_the_first_request_invalidates_stored_thumbnails() {
+ let key = lightcraft_preview::hash_bytes(b"thumbnail");
+ let (mut index, old) = persisted_index(key);
+ let first = Arc::new(PreviewCache::memory(1 << 20));
+ let mut watch = CacheWatch::new(&first);
+ let other = Arc::new(PreviewCache::memory(1 << 20)); // another library, generation 0 again
+ assert_eq!(watch.observe(&other, other.generation(), &mut index), vec![old]);
+ }
+
+ #[test]
+ fn a_normal_start_keeps_stored_thumbnails() {
+ let key = lightcraft_preview::hash_bytes(b"thumbnail");
+ let (mut index, old) = persisted_index(key);
+ let cache = Arc::new(PreviewCache::memory(1 << 20));
+ let mut watch = CacheWatch::new(&cache);
+ for _ in 0..3 {
+ assert!(watch.observe(&cache, cache.generation(), &mut index).is_empty());
+ }
+ assert_eq!(index.cache_key(key), old);
+ assert!(index.touch(&old), "persisted thumbnails survive a restart");
+ }
+
#[test]
fn thumb_index_prunes_lru() {
let mut i = ThumbIndex::default();
diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs
index 21e90f7d..a90ed61e 100644
--- a/apps/lightcraft-web/src/workers.rs
+++ b/apps/lightcraft-web/src/workers.rs
@@ -27,7 +27,7 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use crate::backend::Backend;
-use crate::wire::{ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
+use crate::wire::{CacheWatch, ThumbIndex, WireJob, WorkerCore, thumb_storage_key};
/// Storage key of the thumbnail index.
pub const THUMB_INDEX: &str = "thumbs/index.json";
@@ -175,7 +175,8 @@ struct W {
}
struct Inner {
- preview_generation: Option<(std::sync::Weak, u64)>,
+ /// The rendered-thumbnail cache the stored thumbnails belong to.
+ preview_generation: CacheWatch,
workers: Vec,
done: Vec<(Slot, RenderResult, f64)>,
next_id: u32,
@@ -192,17 +193,16 @@ struct Inner {
impl Inner {
fn observe_cache(&mut self, cache: &Arc, generation: u64) {
- if self.preview_generation.as_ref().is_some_and(|(old, g)| old.as_ptr() != Arc::as_ptr(cache) || *g != generation) {
- let gone = self.index.invalidate();
- if let Some(backend) = self.backend.clone() {
- wasm_bindgen_futures::spawn_local(async move {
- for key in gone {
- let _ = backend.remove(&thumb_storage_key(&key)).await;
- }
- });
- }
+ let gone = self.preview_generation.observe(cache, generation, &mut self.index);
+ if !gone.is_empty()
+ && let Some(backend) = self.backend.clone()
+ {
+ wasm_bindgen_futures::spawn_local(async move {
+ for key in gone {
+ let _ = backend.remove(&thumb_storage_key(&key)).await;
+ }
+ });
}
- self.preview_generation = Some((Arc::downgrade(cache), generation));
}
}
@@ -212,9 +212,11 @@ pub struct Workers(Rc>);
impl Workers {
/// Start `n` workers (none: every job runs inline). `store` is the backend kind for workers.
- pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, ctx: egui::Context) -> Workers {
+ /// `index` lists the stored thumbnails of `cache`, the session's rendered-thumbnail cache
+ /// now: watching it from the start, a clear before the first render request still counts.
+ pub fn start(n: usize, store: &str, backend: Option, index: ThumbIndex, cache: &Arc, ctx: egui::Context) -> Workers {
let w = Workers(Rc::new(RefCell::new(Inner {
- preview_generation: None,
+ preview_generation: CacheWatch::new(cache),
workers: Vec::new(),
done: Vec::new(),
next_id: 0,
@@ -471,7 +473,7 @@ impl RenderOffload for Workers {
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
let mut g = self.0.borrow_mut();
// Persist invalidation even when the cleared library has no visible render requests.
- if let Some(cache) = g.preview_generation.as_ref().and_then(|(cache, _)| cache.upgrade()) {
+ if let Some(cache) = g.preview_generation.cache() {
g.observe_cache(&cache, cache.generation());
}
g.inline_this_frame = false;
diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs
index e46fd2ee..4bb1c6ff 100644
--- a/apps/lightcraft/src/main.rs
+++ b/apps/lightcraft/src/main.rs
@@ -286,8 +286,8 @@ fn services() -> Services {
.add_filter(
lightcraft_ui_egui::i18n::tr("Photos"),
&[
- "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
- "jxl", "gif", "bmp",
+ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw",
+ "pef", "psd", "jxl", "gif", "bmp",
],
)
.pick_files()
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index 9248b220..fe8d1b05 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -336,11 +336,12 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
- /// The current ARW and NEF readers have vendor WB multipliers but no measured camera
+ /// The current ARW, NEF and RW2 readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
- /// photographs (the engine's `camera_preview::file_local_look` covers the same formats).
+ /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
+ /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
- self.develops_raw() && ["ARW", "NEF", "NRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
+ self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
@@ -430,7 +431,15 @@ mod edited_tests {
#[test]
fn sony_and_nikon_raws_use_relative_white_balance() {
- for (name, format, relative) in [("a.arw", "ARW", true), ("a.nef", "NEF", true), ("a.nrw", "nrw", true), ("a.dng", "DNG", false)] {
+ for (name, format, relative) in [
+ ("a.arw", "ARW", true),
+ ("a.nef", "NEF", true),
+ ("a.nrw", "nrw", true),
+ ("a.rw2", "RW2", true),
+ ("a.rwl", "RWL", true),
+ ("a.raw", "RAW", true),
+ ("a.dng", "DNG", false),
+ ] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs
index f0120641..1a3e9a9b 100644
--- a/crates/engine/src/camera_preview.rs
+++ b/crates/engine/src/camera_preview.rs
@@ -1,5 +1,5 @@
-//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF) from its
-//! own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
+//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
+//! RW2) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
@@ -31,7 +31,7 @@ pub(crate) const PROFILE_PROXY: usize = 192;
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
- matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw)
+ matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option {
@@ -63,14 +63,24 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
let edge = (2 * size).max(384) as u32;
let decoded = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 }).ok()?;
- let reference = decoded.to_working();
- let crop = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
+ let mut reference = decoded.to_working();
+ let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
return None;
}
- let aspect = crop.width as f64 / crop.height as f64;
- if (reference.width as f64 / reference.height as f64 / aspect - 1.0).abs() > 0.02 {
- return None;
+ let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02;
+ if !matches(crop.width, crop.height, reference.width, reference.height) {
+ // Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio
+ if !matches(a.width, a.height, reference.width, reference.height) {
+ return None;
+ }
+ let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64);
+ let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize);
+ let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize);
+ if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height {
+ return None;
+ }
+ reference = reference.into_crop(x, y, w, h);
}
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
@@ -731,8 +741,8 @@ mod tests {
}
#[test]
- fn sony_and_nikon_raws_get_a_file_local_look() {
- assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw].into_iter().all(file_local_look));
+ fn sony_nikon_and_panasonic_raws_get_a_file_local_look() {
+ assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
}
@@ -753,6 +763,24 @@ mod tests {
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
+ /// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3
+ /// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it.
+ #[test]
+ fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() {
+ let path = std::env::var_os("LIGHTCRAFT_CORPUS")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
+ .join("raw/rw2-panasonic-fz1000m2-4x3.rw2");
+ let Ok(bytes) = std::fs::read(&path) else {
+ eprintln!("skip: {} absent", path.display());
+ return;
+ };
+ let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
+ assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio");
+ assert!(info.camera_tone.is_some(), "no camera look fitted");
+ assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
+ }
+
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs
index df0e96e8..4895eee7 100644
--- a/crates/engine/src/import.rs
+++ b/crates/engine/src/import.rs
@@ -32,8 +32,8 @@ use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
pub const EXTENSIONS: &[&str] = &[
- "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp",
- "heic", "avif",
+ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl",
+ "gif", "bmp", "heic", "avif",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs
index e45cc92e..76a9bf1a 100644
--- a/crates/engine/src/media.rs
+++ b/crates/engine/src/media.rs
@@ -244,8 +244,11 @@ fn source_bytes(img: &Rgb32f) -> usize {
}
impl MediaCache {
- /// Keep rendered thumbnails on disk in `dir` as well.
+ /// Keep rendered thumbnails on disk in `dir` as well. The cache this replaces is retired
+ /// (its files stay): render jobs still holding it can't write through it any more, even
+ /// into a directory a later clear emptied (`dir` may be the same one).
pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) {
+ self.rendered.retire();
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
}
diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs
index 0479a4e5..259d51fc 100644
--- a/crates/engine/src/rename.rs
+++ b/crates/engine/src/rename.rs
@@ -774,12 +774,13 @@ mod tests {
fail: RefCell::new(Vec::new()),
}
}
+ /// The model volume spells paths with `/`; the code under test joins them with the host's separator.
+ fn norm(p: &Path) -> String {
+ p.to_string_lossy().replace('\\', "/")
+ }
fn idx(&self, p: &Path) -> Option {
- let p = p.to_string_lossy();
- self.files
- .borrow()
- .iter()
- .position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { Path::new(f) == Path::new(p.as_ref()) })
+ let p = Self::norm(p);
+ self.files.borrow().iter().position(|(f, _)| if self.ci { folded_path(f) == folded_path(&p) } else { *f == p })
}
/// The listing: (exact path, contents), sorted.
pub fn listing(&self) -> Vec<(String, String)> {
@@ -800,14 +801,14 @@ mod tests {
self.idx(a).is_some() && self.idx(a) == self.idx(b)
}
fn rename_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
- if self.fail.borrow().iter().any(|f| a.to_string_lossy().contains(f.as_str())) {
+ if self.fail.borrow().iter().any(|f| Self::norm(a).contains(f.as_str())) {
return Err(std::io::Error::other("injected failure"));
}
if self.exists(b) {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists"));
}
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
- self.files.borrow_mut()[i].0 = b.to_string_lossy().to_string();
+ self.files.borrow_mut()[i].0 = Self::norm(b);
Ok(())
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
@@ -816,7 +817,7 @@ mod tests {
}
let i = self.idx(a).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"))?;
let c = self.files.borrow()[i].1.clone();
- self.files.borrow_mut().push((b.to_string_lossy().to_string(), c));
+ self.files.borrow_mut().push((Self::norm(b), c));
Ok(())
}
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
@@ -825,17 +826,19 @@ mod tests {
Ok(())
}
fn stem_sibling(&self, sidecar: &Path) -> Option {
- let stem = sidecar.with_extension("").to_string_lossy().to_string();
+ let stem = Self::norm(&sidecar.with_extension(""));
self.files.borrow().iter().map(|(f, _)| f.clone()).find(|f| {
let p = Path::new(f);
- p.with_extension("").to_string_lossy() == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
+ Self::norm(&p.with_extension("")) == stem && !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"))
})
}
}
fn file_photo(id: u64, path: &str) -> Photo {
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
- Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
+ // the host's separator, so `dir.join(name)` in the planner spells the same path the same way
+ let path = path.replace('/', std::path::MAIN_SEPARATOR_STR);
+ Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
}
#[cfg(windows)]
@@ -845,8 +848,11 @@ mod tests {
// Any attempt to move the unchanged photo (including rollback) must fail.
fs.fail.borrow_mut().push("IMG".into());
let mut s = Session::new();
+ // spelled with `/` on purpose (not `file_photo`, which uses the host's separator)
for (id, path) in [(1, "N:/photos/IMG.jpg"), (2, "N:/photos/other.jpg")] {
- s.catalog.apply(Op::AddPhoto { photo: Box::new(file_photo(id, path)) }).unwrap();
+ let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ let photo = Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00");
+ s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();
}
let before = fs.listing();
let plans = s.plan_rename_with(&fs, &[PhotoId(1)], "{name}", 1);
diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs
index 84397042..3cf14712 100644
--- a/crates/engine/src/segment/mod.rs
+++ b/crates/engine/src/segment/mod.rs
@@ -260,7 +260,7 @@ impl Segmenter {
{
let mirrors = self.mirrors();
if mirrors.is_empty() {
- return Err(lightcraft_segment::fetch::DownloadError::NoMirrors.to_string());
+ return Err(lightcraft_segment::fetch::no_mirrors_message());
}
self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::Options::default())
}
diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs
index ac0e5c57..f9e0f492 100644
--- a/crates/engine/src/tests_libops.rs
+++ b/crates/engine/src/tests_libops.rs
@@ -273,7 +273,7 @@ fn import_review_preview_and_options() {
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// c.png is already in the library
- s.execute("library.import", &json!({"paths": [src.join("sub/c.png").to_string_lossy()]})).unwrap();
+ s.execute("library.import", &json!({"paths": [src.join("sub").join("c.png").to_string_lossy()]})).unwrap();
let n0 = s.catalog.len();
let undo0 = s.undo.len();
let pv = s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs
index 9b343bfd..3534064f 100644
--- a/crates/engine/src/tests_prefs.rs
+++ b/crates/engine/src/tests_prefs.rs
@@ -288,3 +288,32 @@ fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() {
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
+
+#[test]
+fn reopening_a_library_retires_the_old_cache_so_its_jobs_cannot_write_after_a_clear() {
+ let lib = temp_dir("reopen-writer");
+ let mut s = Session::new().with_fs();
+ s.open_library(&lib, true).unwrap();
+ let id = s.catalog.photos().next().unwrap().id;
+ // a thumbnail job still holding the first open's cache object
+ let job = s.thumb_job(id, 128).unwrap();
+ let (old, key) = job.cache.clone().unwrap();
+ // a thumbnail written before the reopen is still valid afterwards
+ let kept = lightcraft_preview::hash_bytes(b"kept thumbnail");
+ let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[10, 20, 30, 255]; 16] };
+ old.put(kept, Arc::new(img.clone()));
+ s.open_library(&lib, false).unwrap();
+ assert!(!Arc::ptr_eq(&old, &s.media.rendered));
+ assert!(s.media.rendered.get(kept).is_some(), "a plain reopen keeps the disk cache");
+ s.execute("library.clearPreviews", &json!({})).unwrap();
+ // the old job finishes: its render must not land in the cleared directory
+ let r = job.run();
+ assert!(r.rendered.is_ok());
+ old.put(kept, Arc::new(img));
+ let fresh = lightcraft_preview::PreviewCache::with_disk(1 << 20, &lib.join("thumbs"), 1 << 30);
+ assert!(fresh.get(key).is_none(), "stale thumbnail written through the replaced cache");
+ assert!(fresh.get(kept).is_none(), "stale put through the replaced cache");
+ assert!(s.media.rendered.get(key).is_none());
+ drop(s);
+ let _ = std::fs::remove_dir_all(&lib);
+}
diff --git a/crates/fetch/Cargo.toml b/crates/fetch/Cargo.toml
new file mode 100644
index 00000000..2e65583f
--- /dev/null
+++ b/crates/fetch/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "lightcraft-fetch"
+version.workspace = true
+edition.workspace = true
+license = "Apache-2.0"
+description = "LightCraft model downloads: mirrors, resume, pinned SHA-256, over HTTPS in pure Rust (L0)"
+
+[lints]
+workspace = true
+
+# downloads run natively only (the web build fetches no models)
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+log = { workspace = true }
+rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] }
+rustls-rustcrypto = "0.0.2-alpha"
+webpki-roots = "1"
+sha2 = { version = "0.10", default-features = false, features = ["std"] }
diff --git a/crates/segment/src/fetch/http.rs b/crates/fetch/src/http.rs
similarity index 100%
rename from crates/segment/src/fetch/http.rs
rename to crates/fetch/src/http.rs
diff --git a/crates/fetch/src/lib.rs b/crates/fetch/src/lib.rs
new file mode 100644
index 00000000..499a6eda
--- /dev/null
+++ b/crates/fetch/src/lib.rs
@@ -0,0 +1,394 @@
+//! Downloading a model's files, only when the user asked for it: from an ordered list of mirrors
+//! (the next one when a mirror fails), resuming partial files, with connect and stall timeouts,
+//! a size cap, progress and cancellation, and checked (exact size, SHA-256 where pinned) before
+//! each file is moved into the model folder. Runs on whatever thread calls [`download`] (the
+//! engine uses a background thread; the UI thread never waits for the network). Pure Rust: the
+//! HTTPS client is rustls with the RustCrypto provider (see [`http`]).
+//!
+//! Files are written to `.part` next to their final place and renamed when verified, so a
+//! model folder never holds a half-written file under a real name, and an interrupted download
+//! resumes from where it stopped. A file that fails its check is deleted, never kept.
+//!
+//! What to download and where from belongs to the caller: it passes the [`FileSpec`]s and the
+//! default mirrors (see [`mirrors`]). `lightcraft-segment` does so for the SAM 3 weights, which
+//! are never part of LightCraft (SAM License, see docs/ai-masks.md).
+//!
+//! Native only: on wasm32 this crate is empty (the web build downloads no models).
+
+#![cfg(not(target_arch = "wasm32"))]
+#![forbid(unsafe_code)]
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+pub mod http;
+
+use std::fs::{File, OpenOptions};
+use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::Duration;
+
+use sha2::{Digest, Sha256};
+
+use http::{HttpError, Limits, Url};
+
+/// One file of the model.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FileSpec {
+ pub name: &'static str,
+ /// Exact size in bytes, when pinned (then the download resumes and is checked against it).
+ pub size: Option,
+ /// SHA-256 (lower-case hex), when pinned.
+ pub sha256: Option<&'static str>,
+ /// Largest size accepted when `size` is not pinned.
+ pub max: u64,
+}
+
+/// Most mirrors used (a hostile list can't make a download loop forever).
+const MAX_MIRRORS: usize = 16;
+/// Redirects followed per request.
+const MAX_REDIRECTS: usize = 8;
+
+/// The mirrors to try, in order: `env`'s (base URLs separated by commas, spaces or newlines), then
+/// the mirrors file's (one base URL per line, `#` comments), then `defaults`; duplicates and
+/// unusable URLs dropped, at most 16.
+pub fn mirrors(env: Option<&str>, file: Option<&Path>, defaults: &[&str]) -> Vec {
+ let mut out: Vec = Vec::new();
+ let mut add = |s: &str| {
+ let s = s.trim().trim_end_matches('/');
+ if !s.is_empty() && Url::parse(s).is_ok() && !out.iter().any(|m| m == s) && out.len() < MAX_MIRRORS {
+ out.push(s.to_string());
+ }
+ };
+ if let Some(env) = env {
+ env.split([',', ' ', '\n', '\t']).for_each(&mut add);
+ }
+ // a small text file; anything larger isn't a mirror list
+ if let Some(text) = file.and_then(|f| std::fs::metadata(f).ok().filter(|m| m.len() < 64 * 1024).and_then(|_| std::fs::read_to_string(f).ok())) {
+ text.lines().map(|l| l.split('#').next().unwrap_or_default()).for_each(&mut add);
+ }
+ defaults.iter().for_each(|m| add(m));
+ out
+}
+
+/// Download settings.
+#[derive(Clone, Debug)]
+pub struct Options {
+ /// Longest wait to connect to a mirror.
+ pub connect_timeout: Duration,
+ /// Longest wait for the next bytes once connected.
+ pub stall_timeout: Duration,
+ /// Tries per mirror and file (each resumes where the last one stopped).
+ pub attempts: u32,
+}
+
+impl Default for Options {
+ fn default() -> Self {
+ Options { connect_timeout: Duration::from_secs(15), stall_timeout: Duration::from_secs(30), attempts: 3 }
+ }
+}
+
+/// Where a download is.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Progress {
+ pub file: String,
+ /// Bytes of all files on disk so far, and the total expected (pinned sizes + announced ones).
+ pub done: u64,
+ pub total: u64,
+ /// The mirror in use (without query).
+ pub mirror: String,
+}
+
+/// Why a download failed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum DownloadError {
+ /// No mirror is configured.
+ NoMirrors,
+ Cancelled,
+ /// A local file problem (folder not writable, disk full…).
+ Disk(String),
+ /// Every mirror failed for `file`; the reason for each.
+ AllMirrorsFailed {
+ file: String,
+ errors: Vec,
+ },
+}
+
+impl std::fmt::Display for DownloadError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ DownloadError::NoMirrors => write!(f, "no download location is configured for this model in this build"),
+ DownloadError::Cancelled => write!(f, "the download was cancelled"),
+ DownloadError::Disk(e) => write!(f, "{e}"),
+ DownloadError::AllMirrorsFailed { file, errors } => write!(f, "could not download {file}: {}", errors.join("; ")),
+ }
+ }
+}
+
+/// One mirror's failure for one file; `Retry` resumes on the same mirror.
+enum Fail {
+ Retry(String),
+ NextMirror(String),
+ Cancelled,
+ Disk(String),
+}
+
+/// Download `files` into `dir` from `mirrors` (in order; see [`mirrors`]). Files already there
+/// and verified are kept. `progress` is called as bytes arrive (often: throttle in it).
+pub fn download(
+ files: &[FileSpec],
+ mirrors: &[String],
+ dir: &Path,
+ opts: &Options,
+ cancel: &AtomicBool,
+ progress: &mut dyn FnMut(&Progress),
+) -> Result<(), DownloadError> {
+ std::fs::create_dir_all(dir).map_err(|e| DownloadError::Disk(format!("can't create {}: {e}", dir.display())))?;
+ // what's left to fetch
+ let mut todo = Vec::new();
+ for f in files {
+ if !verified(f, &dir.join(f.name))? {
+ todo.push(f);
+ }
+ }
+ if todo.is_empty() {
+ return Ok(());
+ }
+ if mirrors.is_empty() {
+ return Err(DownloadError::NoMirrors);
+ }
+ let total: u64 = files.iter().map(|f| f.size.unwrap_or(0)).sum();
+ let mut before: u64 = files.iter().filter(|f| !todo.contains(f)).filter_map(|f| f.size).sum();
+ for f in todo {
+ let mut errors = Vec::new();
+ let mut ok = false;
+ 'mirrors: for m in mirrors {
+ let url = format!("{m}/{}", f.name);
+ for _ in 0..opts.attempts.max(1) {
+ if cancel.load(Ordering::Relaxed) {
+ return Err(DownloadError::Cancelled);
+ }
+ let mut report = |n: u64, announced: u64| {
+ let mirror = Url::parse(m).map(|u| u.display()).unwrap_or_default();
+ progress(&Progress {
+ file: f.name.to_string(),
+ done: before.saturating_add(n),
+ total: total.max(before.saturating_add(announced)),
+ mirror,
+ })
+ };
+ match fetch_file(f, &url, dir, opts, cancel, &mut report) {
+ Ok(()) => {
+ ok = true;
+ break 'mirrors;
+ }
+ Err(Fail::Cancelled) => return Err(DownloadError::Cancelled),
+ Err(Fail::Disk(e)) => return Err(DownloadError::Disk(e)),
+ Err(Fail::Retry(e)) => {
+ log::warn!("download of {url}: {e} (retrying)");
+ errors.push(format!("{}: {e}", short(m)));
+ }
+ Err(Fail::NextMirror(e)) => {
+ log::warn!("download of {url}: {e}");
+ errors.push(format!("{}: {e}", short(m)));
+ continue 'mirrors;
+ }
+ }
+ }
+ }
+ if !ok {
+ return Err(DownloadError::AllMirrorsFailed { file: f.name.to_string(), errors });
+ }
+ before = before.saturating_add(std::fs::metadata(dir.join(f.name)).map(|m| m.len()).unwrap_or(0));
+ }
+ Ok(())
+}
+
+/// A mirror for messages (no query string: it may hold a token).
+fn short(m: &str) -> String {
+ Url::parse(m).map(|u| u.display()).unwrap_or_else(|_| "mirror".into())
+}
+
+/// Whether `path` is already the right file (exact size and hash when pinned).
+fn verified(f: &FileSpec, path: &Path) -> Result {
+ let Ok(meta) = std::fs::metadata(path) else { return Ok(false) };
+ if !meta.is_file() || meta.len() == 0 {
+ return Ok(false);
+ }
+ if f.size.is_some_and(|s| s != meta.len()) || meta.len() > f.max {
+ return Ok(false);
+ }
+ match f.sha256 {
+ Some(want) => Ok(sha256_file(path).map_err(DownloadError::Disk)? == want),
+ None => Ok(true),
+ }
+}
+
+fn sha256_file(path: &Path) -> Result {
+ let mut file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
+ let mut h = Sha256::new();
+ let mut buf = vec![0u8; 1 << 20];
+ loop {
+ let n = file.read(&mut buf).map_err(|e| format!("{}: {e}", path.display()))?;
+ if n == 0 {
+ break;
+ }
+ h.update(buf.get(..n).unwrap_or_default());
+ }
+ Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect())
+}
+
+fn part_path(dir: &Path, name: &str) -> PathBuf {
+ dir.join(format!("{name}.part"))
+}
+
+/// Fetch one file from one mirror into `.part` (resuming it when its size is pinned),
+/// check it and move it into place.
+fn fetch_file(f: &FileSpec, url: &str, dir: &Path, opts: &Options, cancel: &AtomicBool, report: &mut dyn FnMut(u64, u64)) -> Result<(), Fail> {
+ let part = part_path(dir, f.name);
+ let limit = f.size.unwrap_or(f.max);
+ let disk = |e: std::io::Error| Fail::Disk(format!("{}: {e}", part.display()));
+ // resume only a pinned file (an unpinned one can't be checked after resuming)
+ let mut have = match std::fs::metadata(&part) {
+ Ok(m) if f.size.is_some() && m.len() <= limit => m.len(),
+ Ok(_) => {
+ std::fs::remove_file(&part).map_err(disk)?;
+ 0
+ }
+ Err(_) => 0,
+ };
+ let fetched = f.size != Some(have) || have == 0;
+ if fetched {
+ let limits = Limits { connect: opts.connect_timeout, stall: opts.stall_timeout, cancel };
+ let mut url = Url::parse(url).map_err(|e| Fail::NextMirror(e.to_string()))?;
+ let origin = url.host.clone();
+ let mut redirects = 0;
+ let mut resp = loop {
+ let mut headers = vec![];
+ if have > 0 {
+ headers.push(("Range", format!("bytes={have}-")));
+ }
+ // a token for the configured host only (never sent on to a redirect's host)
+ if let Ok(t) = std::env::var("LIGHTCRAFT_SAM3_TOKEN")
+ && !t.trim().is_empty()
+ && url.tls
+ && url.host == origin
+ {
+ headers.push(("Authorization", format!("Bearer {}", t.trim())));
+ }
+ let r = http::get(&url, &headers, &limits).map_err(http_fail)?;
+ match r.status {
+ 301 | 302 | 303 | 307 | 308 => {
+ redirects += 1;
+ if redirects > MAX_REDIRECTS {
+ return Err(Fail::NextMirror("too many redirects".into()));
+ }
+ let loc = r.header("location").ok_or_else(|| Fail::NextMirror("redirect without a location".into()))?;
+ url = url.join(loc).map_err(|e| Fail::NextMirror(e.to_string()))?;
+ }
+ _ => break r,
+ }
+ };
+ let mut file = match resp.status {
+ 200 => {
+ // the whole file (from the start, also when a range was asked for)
+ if resp.content_length().is_some_and(|n| n > limit || f.size.is_some_and(|s| s != n)) {
+ return Err(Fail::NextMirror(format!("the server's file has the wrong size ({:?} bytes)", resp.content_length())));
+ }
+ have = 0;
+ File::create(&part).map_err(disk)?
+ }
+ 206 if have > 0 => {
+ let range = resp.header("content-range").unwrap_or_default();
+ let (start, total) = parse_content_range(range).ok_or_else(|| Fail::NextMirror(format!("bad Content-Range `{range}`")))?;
+ if start != have || total.is_some_and(|t| Some(t) != f.size) {
+ return Err(Fail::NextMirror(format!("the server resumed at the wrong place ({range})")));
+ }
+ OpenOptions::new().append(true).open(&part).map_err(disk)?
+ }
+ 416 => {
+ // the partial file doesn't fit what the server has: start over
+ std::fs::remove_file(&part).map_err(disk)?;
+ return Err(Fail::Retry("the partial download didn't match; starting over".into()));
+ }
+ 401 | 403 => return Err(Fail::NextMirror(format!("access denied (HTTP {})", resp.status))),
+ 404 | 410 => return Err(Fail::NextMirror(format!("not found (HTTP {})", resp.status))),
+ s if s >= 500 => return Err(Fail::Retry(format!("server error (HTTP {s})"))),
+ s => return Err(Fail::NextMirror(format!("HTTP {s}"))),
+ };
+ let announced = f.size.unwrap_or_else(|| resp.content_length().unwrap_or(0));
+ report(have, announced);
+ let mut buf = vec![0u8; 256 * 1024];
+ loop {
+ let n = match resp.read(&mut buf, &limits) {
+ Ok(n) => n,
+ Err(e) => {
+ let _ = file.flush();
+ if f.size.is_none() {
+ drop(file);
+ let _ = std::fs::remove_file(&part);
+ }
+ return Err(http_fail(e));
+ }
+ };
+ if n == 0 {
+ break;
+ }
+ if have.saturating_add(n as u64) > limit {
+ drop(file);
+ let _ = std::fs::remove_file(&part);
+ return Err(Fail::NextMirror(format!("the server sent more than the expected {limit} bytes")));
+ }
+ file.write_all(buf.get(..n).unwrap_or_default()).map_err(disk)?;
+ have = have.saturating_add(n as u64);
+ report(have, announced);
+ }
+ file.sync_all().map_err(disk)?;
+ }
+ // checks: exact size, hash
+ let len = std::fs::metadata(&part).map_err(disk)?.len();
+ if f.size.is_some_and(|s| s != len) {
+ return Err(Fail::Retry(format!("incomplete ({len} of {} bytes)", f.size.unwrap_or(0))));
+ }
+ if len == 0 {
+ let _ = std::fs::remove_file(&part);
+ return Err(Fail::NextMirror("the server sent an empty file".into()));
+ }
+ if let Some(want) = f.sha256 {
+ let got = sha256_file(&part).map_err(Fail::Disk)?;
+ if got != want {
+ std::fs::remove_file(&part).map_err(disk)?;
+ let why = format!("the file is damaged or not the official one (SHA-256 {got})");
+ // a damaged partial file from an earlier run isn't this mirror's fault
+ return Err(if fetched { Fail::NextMirror(why) } else { Fail::Retry(why) });
+ }
+ }
+ std::fs::rename(&part, dir.join(f.name)).map_err(disk)?;
+ Ok(())
+}
+
+fn http_fail(e: HttpError) -> Fail {
+ match e {
+ HttpError::Cancelled => Fail::Cancelled,
+ HttpError::Stalled | HttpError::Io(_) | HttpError::Protocol(_) => Fail::Retry(e.to_string()),
+ HttpError::BadUrl(_) | HttpError::Connect(_) | HttpError::Tls(_) => Fail::NextMirror(e.to_string()),
+ }
+}
+
+/// `bytes a-b/total` → (a, total) (`*` total → None).
+fn parse_content_range(v: &str) -> Option<(u64, Option)> {
+ let v = v.trim().strip_prefix("bytes")?.trim();
+ let (range, total) = v.split_once('/')?;
+ let (a, b) = range.split_once('-')?;
+ let (a, b) = (a.trim().parse::().ok()?, b.trim().parse::().ok()?);
+ if b < a {
+ return None;
+ }
+ let total = match total.trim() {
+ "*" => None,
+ t => Some(t.parse::().ok()?),
+ };
+ Some((a, total))
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/segment/src/fetch/tests.rs b/crates/fetch/src/tests.rs
similarity index 97%
rename from crates/segment/src/fetch/tests.rs
rename to crates/fetch/src/tests.rs
index 81e38f01..57c168a2 100644
--- a/crates/segment/src/fetch/tests.rs
+++ b/crates/fetch/src/tests.rs
@@ -294,13 +294,15 @@ fn no_mirrors_is_a_clear_error_and_mirror_lists_are_parsed() {
let dir = tmp("nomirror");
let e = run(&[pinned()], &[], &dir, &AtomicBool::new(false)).0.unwrap_err();
assert_eq!(e, DownloadError::NoMirrors);
- assert!(e.to_string().contains(MIRRORS_ENV));
+ assert!(e.to_string().contains("no download location"));
let file = std::env::temp_dir().join(format!("lc-sam3-mirrors-{}.txt", std::process::id()));
std::fs::write(&file, "# mine\nhttps://b.example/m/ # second\n\nnot a url\nhttps://a.example/x\n").unwrap();
- let m = mirrors(Some("https://a.example/x/, ftp://no"), Some(&file));
- assert_eq!(m[..2], ["https://a.example/x".to_string(), "https://b.example/m".to_string()]);
- assert_eq!(m.len(), 2 + DEFAULT_MIRRORS.len());
- assert_eq!(mirrors(None, Some(Path::new("/nonexistent/mirrors.txt"))).len(), DEFAULT_MIRRORS.len());
+ let defaults = ["https://c.example/d", "https://a.example/x"];
+ let m = mirrors(Some("https://a.example/x/, ftp://no"), Some(&file), &defaults);
+ // the environment's, then the file's, then the defaults; a repeated one is kept once
+ assert_eq!(m, ["https://a.example/x", "https://b.example/m", "https://c.example/d"]);
+ assert_eq!(mirrors(None, Some(Path::new("/nonexistent/mirrors.txt")), &defaults).len(), 2);
+ assert!(mirrors(None, None, &[]).is_empty());
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_dir_all(&dir);
}
diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs
index c16b46cb..2c1e70e6 100644
--- a/crates/gpu/tests/equivalence.rs
+++ b/crates/gpu/tests/equivalence.rs
@@ -73,7 +73,23 @@ fn check(name: &str, src: &Arc, info: &SourceInfo, s: &DevelopSettings,
let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render");
let (mean, max, over) = diff(&cpu, &gpu.image);
eprintln!("{name:<28} {}x{} mean {mean:.4} max {max} >1: {:.4}%", cpu.width, cpu.height, over * 100.0);
- assert!(mean < MEAN_LSB && max <= MAX_LSB, "{name}: mean {mean:.4} LSB, max {max} LSB");
+ // A pixel on the very edge of the picture can fall either side of the sampling boundary (the
+ // lens warp with a shifted red plane lands exactly outside it): the CPU and a GPU's rounding
+ // may then disagree on one edge pixel. Tolerate a couple of those, nothing in the interior.
+ let (w, h) = (cpu.width, cpu.height);
+ let strays: Vec<(usize, usize)> = cpu
+ .data
+ .iter()
+ .zip(&gpu.image.data)
+ .enumerate()
+ .filter(|(_, (p, q))| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB))
+ .map(|(i, _)| (i % w, i / w))
+ .collect();
+ let on_the_edge = |&(x, y): &(usize, usize)| x == 0 || y == 0 || x + 1 == w || y + 1 == h;
+ assert!(
+ mean < MEAN_LSB && (max <= MAX_LSB || (strays.len() <= 2 && strays.iter().all(on_the_edge))),
+ "{name}: mean {mean:.4} LSB, max {max} LSB, pixels over {MAX_LSB}: {strays:?}"
+ );
(mean, max)
}
diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs
index 2ae21b25..162ccce4 100644
--- a/crates/mcp/src/headless.rs
+++ b/crates/mcp/src/headless.rs
@@ -11,8 +11,10 @@ use serde_json::{Value, json};
use crate::backend::Backend;
/// File extensions recognised as photos when expanding folders.
-pub const PHOTO_EXTENSIONS: &[&str] =
- &["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "avif"];
+pub const PHOTO_EXTENSIONS: &[&str] = &[
+ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif",
+ "bmp", "avif",
+];
/// Headless backend: a [`Session`] with filesystem hooks.
pub struct Headless {
diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs
index 50dc68e5..a571ecda 100644
--- a/crates/preview/src/lib.rs
+++ b/crates/preview/src/lib.rs
@@ -28,7 +28,8 @@ pub use pool::JobPool;
/// Rendered-thumbnail cache: memory LRU over an optional disk cache.
pub struct PreviewCache {
- generation: RwLock,
+ /// Current generation; `None` once [`Self::retire`]d (nothing is read or written any more).
+ generation: RwLock