diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index f39de00b99..8953710cec 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -58,6 +58,11 @@ jobs:
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
done
+ # The completion page the Publish events point at must actually exist: a Publish alone
+ # does not pull the dialog fragment into the MSI, and without the DialogRef the
+ # installer finishes silently (issues #790, #892).
+ grep -qF ' ' packaging/windows/lightcraft.wxs \
+ || { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
diff --git a/CLAUDE.md b/CLAUDE.md
index 4a736f12ac..136e821bbf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,7 +16,8 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers in `ROADMAP.md` /
`docs/target-app-parity.md` when a listed gap closes (bump each doc's *Last updated* line and revision history, per
craftrules `standards/progress-docs.md`); `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and
- path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
+ path the tracker cites still exists and that `docs/gaps.md` lists each gap once, and `cargo xtask parity --write`
+ refreshes its summary.
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only).
diff --git a/Cargo.lock b/Cargo.lock
index f6a5c9476f..5f445192fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2548,9 +2548,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heic-rs"
-version = "0.1.1"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
+checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
dependencies = [
"rayon",
]
@@ -3073,7 +3073,7 @@ dependencies = [
[[package]]
name = "lightcraft"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"eframe",
"egui",
@@ -3097,7 +3097,7 @@ dependencies = [
[[package]]
name = "lightcraft-catalog"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "lightcraft-cli"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"dhat",
"lightcraft-codecs",
@@ -3130,7 +3130,7 @@ dependencies = [
[[package]]
name = "lightcraft-codecs"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"avif-serialize",
"bytemuck",
@@ -3159,7 +3159,7 @@ dependencies = [
[[package]]
name = "lightcraft-color"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"lightcraft-geom",
"proptest",
@@ -3168,7 +3168,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"faer-core",
"lightcraft-denoise-core",
@@ -3185,7 +3185,7 @@ dependencies = [
[[package]]
name = "lightcraft-denoise-core"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"lightcraft-raster",
"miniz_oxide 0.8.9",
@@ -3200,7 +3200,7 @@ dependencies = [
[[package]]
name = "lightcraft-develop"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"lightcraft-color",
"lightcraft-geom",
@@ -3212,7 +3212,7 @@ dependencies = [
[[package]]
name = "lightcraft-engine"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"ab_glyph",
"harfrust",
@@ -3252,7 +3252,7 @@ dependencies = [
[[package]]
name = "lightcraft-faces"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"matrixmultiply",
"ndarray",
@@ -3266,7 +3266,7 @@ dependencies = [
[[package]]
name = "lightcraft-fetch"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"log",
"rustls",
@@ -3277,7 +3277,7 @@ dependencies = [
[[package]]
name = "lightcraft-geocode"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"miniz_oxide 0.8.9",
"serde",
@@ -3285,7 +3285,7 @@ dependencies = [
[[package]]
name = "lightcraft-geom"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3293,7 +3293,7 @@ dependencies = [
[[package]]
name = "lightcraft-gpu"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"bytemuck",
"lightcraft-color",
@@ -3311,7 +3311,7 @@ dependencies = [
[[package]]
name = "lightcraft-heif"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"heic-rs",
"rayon",
@@ -3319,14 +3319,14 @@ dependencies = [
[[package]]
name = "lightcraft-inpaint"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"lightcraft-develop",
"lightcraft-geom",
@@ -3335,7 +3335,7 @@ dependencies = [
[[package]]
name = "lightcraft-mcp"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-engine",
@@ -3346,7 +3346,7 @@ dependencies = [
[[package]]
name = "lightcraft-merge"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"lightcraft-codecs",
"lightcraft-color",
@@ -3362,7 +3362,7 @@ dependencies = [
[[package]]
name = "lightcraft-meta"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"lightcraft-geom",
"lightcraft-tiff",
@@ -3374,7 +3374,7 @@ dependencies = [
[[package]]
name = "lightcraft-pipeline"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3392,7 +3392,7 @@ dependencies = [
[[package]]
name = "lightcraft-preview"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
@@ -3404,7 +3404,7 @@ dependencies = [
[[package]]
name = "lightcraft-raster"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"libc",
"lightcraft-color",
@@ -3416,7 +3416,7 @@ dependencies = [
[[package]]
name = "lightcraft-raw"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"jpeg-encoder",
"jxl-oxide",
@@ -3438,7 +3438,7 @@ dependencies = [
[[package]]
name = "lightcraft-scenes"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"image",
"lightcraft-color",
@@ -3449,7 +3449,7 @@ dependencies = [
[[package]]
name = "lightcraft-segment"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -3462,11 +3462,11 @@ dependencies = [
[[package]]
name = "lightcraft-sysmem"
-version = "0.7.1"
+version = "0.8.0"
[[package]]
name = "lightcraft-tiff"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"proptest",
"serde",
@@ -3475,7 +3475,7 @@ dependencies = [
[[package]]
name = "lightcraft-ui-egui"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"craft-ui",
"egui",
@@ -3502,7 +3502,7 @@ dependencies = [
[[package]]
name = "lightcraft-web"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"crc32fast",
"eframe",
@@ -6972,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.7.1"
+version = "0.8.0"
dependencies = [
"brotli",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index 7effbe0d94..2b89df35e5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
-version = "0.7.1"
+version = "0.8.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
diff --git a/README.md b/README.md
index 2aea14d872..f041111a1d 100644
--- a/README.md
+++ b/README.md
@@ -337,7 +337,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
| XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ |
-| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
+| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
@@ -370,6 +370,31 @@ per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged.
`CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings
override these defaults. 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
+with full debug information, thin LTO and one codegen unit. The CI defaults above do not apply to
+ordinary `cargo build --release`. Start with one job; two may be suitable if enough memory is available:
+
+```sh
+CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
+# Alternatively: CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
+```
+
+If a packaging environment enables full debug information or memory-heavy LTO settings, use
+command-local overrides for a lower-memory source build:
+
+```sh
+CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
+```
+
+These POSIX-shell assignments affect only this command, not global Cargo configuration or the
+repository's release profiles. Disabling LTO may change performance and binary size; disabling debug
+information reduces debugging detail. This is build guidance, not a guarantee that any machine has
+enough memory. Downstream packagers should serialize large binary builds and avoid full debug
+information combined with LTO and one codegen unit on low-memory builders. A PKGBUILD that exports
+its own profile settings can override inherited variables; adjust the packaging recipe locally or
+contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
+
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
```sh
@@ -385,7 +410,9 @@ Language**; the choice applies immediately and persists. See [docs/localization.
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
-`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
+`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library.
+**Portable mode:** a file named `portable.txt` (or `LightCraft.portable`) next to the executable moves the settings
+folder to `LightCraftData` beside it. A
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
Folder** shows it in the file manager). Each start moves the
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
diff --git a/README.zh-hans.md b/README.zh-hans.md
index 9a53314353..0a7feb32ca 100644
--- a/README.zh-hans.md
+++ b/README.zh-hans.md
@@ -359,6 +359,30 @@ CI 默认使用行表调试信息,并按启动时可用内存每 1.5 GB 开一
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 和 `RUST_TEST_THREADS` 会覆盖这些默认值。
日常开发命令保持它们各自的设置。
+**内存受限机器上的 release 构建:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
+报告在 16 GB 的 Arch/AUR 机器上,同时构建桌面应用和 CLI,并启用完整调试信息、thin LTO
+和单个代码生成单元时,链接过程被 OOM 终止。上述 CI 默认设置不适用于普通的
+`cargo build --release`。建议先使用一个构建任务;内存充足时可以尝试两个:
+
+```sh
+CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
+# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
+```
+
+如果打包环境启用了完整调试信息或占用较多内存的 LTO 设置,可以使用仅对本条命令生效的
+覆盖设置,降低源码构建的内存需求:
+
+```sh
+CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
+```
+
+这些 POSIX shell 赋值只影响本条命令,不修改全局 Cargo 配置或仓库的 release profile。
+关闭 LTO 可能改变性能和二进制大小;关闭调试信息会减少可用的调试细节。这是构建建议,
+并不保证任何机器都有足够内存。下游打包者应串行构建大型二进制文件,并避免在低内存
+构建机器上同时使用完整调试信息、LTO 和单个代码生成单元。PKGBUILD 自己导出的 profile
+设置可以覆盖继承的环境变量;应在本地调整打包配方或联系维护者,而不要假定给
+`makepkg` 添加环境变量前缀就能优先生效。
+
**中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它):
```sh
diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs
index bb713a2e78..1c70e19eab 100644
--- a/apps/lightcraft/src/main.rs
+++ b/apps/lightcraft/src/main.rs
@@ -462,6 +462,14 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
pick_folder: Some(Box::new(|| {
file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string())
})),
+ #[cfg(target_os = "linux")]
+ open_model_folder: Some(std::sync::Arc::new(|path: &str| {
+ std::process::Command::new("xdg-open")
+ .arg(path)
+ .status()
+ .map_err(|e| e.to_string())
+ .and_then(|s| if s.success() { Ok(()) } else { Err(format!("opening folder failed: {s}")) })
+ })),
open_with: Some(Box::new(editors::open_with)),
open_all_with: Some(Box::new(editors::open_all_with)),
check_editor: Some(Box::new(editors::check)),
@@ -666,7 +674,8 @@ OPTIONS:
--version, --help
ENVIRONMENT:
- LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
+ LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder).
+ Without it, a portable.txt file next to the program puts the folder beside it (LightCraftData)
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3;
diff --git a/contributors/people.toml b/contributors/people.toml
index 79f20c7261..27a8c8d9cb 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -27,3 +27,7 @@ sync_github_name = true
[people.sanyamsingh001]
display_name = "sanyamsingh001"
+
+[people.ArekatlaNishanthchowdary]
+real_name = "Arekatla Nishanth Chowdary"
+sync_github_name = true
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
index dd92b5e2df..98fd616a38 100644
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -113,6 +113,8 @@ pub struct LoadReport {
/// The library was in an older catalog format (`0` = before format versions: a log without a
/// snapshot) and was rewritten in this version's format ([`VERSION`]).
pub upgraded_from: Option,
+ /// What [`Catalog::repair`] changed in a damaged snapshot (empty for a sound one).
+ pub repaired: Vec,
}
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
@@ -328,7 +330,12 @@ impl Journal {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
- (s.catalog, s.seq, Some(h.version))
+ let mut catalog = s.catalog;
+ report.repaired = catalog.repair();
+ for r in &report.repaired {
+ log::warn!("{SNAPSHOT}: {r}");
+ }
+ (catalog, s.seq, Some(h.version))
}
None => (Catalog::new(), 0, None),
};
diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs
index ecde973ba3..ed4cb75b41 100644
--- a/crates/catalog/src/lib.rs
+++ b/crates/catalog/src/lib.rs
@@ -290,19 +290,21 @@ impl Catalog {
// ---- ids
+ // (saturating: a counter a damaged snapshot left at the top hands out its last id again, which
+ // the op that adds it then refuses as taken, rather than overflowing)
pub fn alloc_photo_id(&mut self) -> PhotoId {
let id = PhotoId(self.next_photo.max(1));
- self.next_photo = id.0 + 1;
+ self.next_photo = id.0.saturating_add(1);
id
}
pub fn alloc_album_id(&mut self) -> AlbumId {
let id = AlbumId(self.next_album.max(1));
- self.next_album = id.0 + 1;
+ self.next_album = id.0.saturating_add(1);
id
}
pub fn alloc_stack_id(&mut self) -> StackId {
let id = StackId(self.next_stack.max(1));
- self.next_stack = id.0 + 1;
+ self.next_stack = id.0.saturating_add(1);
id
}
@@ -497,7 +499,7 @@ impl Catalog {
return Err(CatalogError::Invalid(format!("photo {:?} exists", photo.id)));
}
let id = photo.id;
- self.next_photo = self.next_photo.max(id.0 + 1);
+ self.next_photo = self.next_photo.max(id.0.saturating_add(1));
self.photos.insert(id, Arc::new(*photo));
Op::RemovePhoto { id }
}
@@ -587,7 +589,7 @@ impl Catalog {
self.validate_rules(rules)?;
}
let id = album.id;
- self.next_album = self.next_album.max(id.0 + 1);
+ self.next_album = self.next_album.max(id.0.saturating_add(1));
self.albums.insert(id, album);
Op::RemoveAlbum { id }
}
@@ -607,13 +609,15 @@ impl Catalog {
if p == id || !self.albums.get(&p).is_some_and(|a| a.folder) {
return Err(CatalogError::Invalid("parent must be another folder".into()));
}
- // no cycles
+ // no cycles (bounded: a loop elsewhere in a damaged catalog must not hang this)
let mut cur = Some(p);
+ let mut steps = 0usize;
while let Some(c) = cur {
- if c == id {
+ if c == id || steps > self.albums.len() {
return Err(CatalogError::Invalid("cannot move a folder into itself".into()));
}
cur = self.albums.get(&c).and_then(|a| a.parent);
+ steps += 1;
}
}
let a = self.album_mut(id)?;
@@ -659,7 +663,7 @@ impl Catalog {
}
self.validate_stack(stack.id, &stack.photos)?;
let id = stack.id;
- self.next_stack = self.next_stack.max(id.0 + 1);
+ self.next_stack = self.next_stack.max(id.0.saturating_add(1));
self.stacks.insert(id, stack);
Op::RemoveStack { id }
}
@@ -823,7 +827,94 @@ impl Catalog {
}
pub fn from_snapshot(s: &str) -> Result {
- serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))
+ let mut c: Catalog = serde_json::from_str(s).map_err(|e| CatalogError::Corrupt(e.to_string()))?;
+ c.repair();
+ Ok(c)
+ }
+
+ /// Make a catalog read from a snapshot hold together where a damaged or hand-edited file left
+ /// it inconsistent, so nothing that relies on it can fail (code indexes stacks by the ids it
+ /// finds in them, and walks folders up to the top): every photo, album and stack carries the
+ /// id it is filed under; the id counters lie past every id in use; a stack keeps only photos
+ /// in the catalog, once each, and goes when fewer than two are left; an album whose parent is
+ /// missing, not a folder or inside itself moves to the top level. Returns what was changed
+ /// (nothing, for a catalog this build wrote).
+ pub fn repair(&mut self) -> Vec {
+ let mut changed = Vec::new();
+ for (id, p) in self.photos.iter_mut() {
+ if p.id != *id {
+ changed.push(format!("photo {} was filed as {}", p.id.0, id.0));
+ Arc::make_mut(p).id = *id;
+ }
+ }
+ for (id, a) in self.albums.iter_mut() {
+ if a.id != *id {
+ changed.push(format!("album {} was filed as {}", a.id.0, id.0));
+ a.id = *id;
+ }
+ }
+ let photos = &self.photos;
+ self.stacks.retain(|id, st| {
+ if st.id != *id {
+ changed.push(format!("stack {} was filed as {}", st.id.0, id.0));
+ st.id = *id;
+ }
+ let mut seen = std::collections::HashSet::new();
+ let before = st.photos.len();
+ st.photos.retain(|p| photos.contains_key(p) && seen.insert(*p));
+ if st.photos.len() != before {
+ changed.push(format!("stack {}: {} missing or repeated photo(s) left out", id.0, before - st.photos.len()));
+ }
+ let keep = st.photos.len() >= 2;
+ if !keep {
+ changed.push(format!("stack {} had fewer than two photos: removed", id.0));
+ }
+ keep
+ });
+ // a photo in two stacks stays in the first
+ let mut stacked = std::collections::HashSet::new();
+ self.stacks.retain(|id, st| {
+ let before = st.photos.len();
+ st.photos.retain(|p| stacked.insert(*p));
+ if st.photos.len() != before {
+ changed.push(format!("stack {}: {} photo(s) already in another stack left out", id.0, before - st.photos.len()));
+ }
+ st.photos.len() >= 2
+ });
+ let parents: Vec<(AlbumId, Option)> = self.albums.values().map(|a| (a.id, a.parent)).collect();
+ for (id, parent) in parents {
+ let Some(p) = parent else { continue };
+ let mut cur = Some(p);
+ let mut steps = 0usize;
+ let ok = loop {
+ match cur {
+ None => break true,
+ Some(c) if c == id || steps > self.albums.len() => break false,
+ Some(c) => match self.albums.get(&c) {
+ Some(a) if a.folder => cur = a.parent,
+ _ => break false,
+ },
+ }
+ steps += 1;
+ };
+ if !ok && let Some(a) = self.albums.get_mut(&id) {
+ changed.push(format!("album {}: its folder {} is missing, not a folder or inside it: moved to the top level", id.0, p.0));
+ a.parent = None;
+ }
+ }
+ let past = |last: Option| last.map_or(1, |v| v.saturating_add(1));
+ let (photo, album, stack) = (
+ past(self.photos.keys().last().map(|k| k.0)),
+ past(self.albums.keys().last().map(|k| k.0)),
+ past(self.stacks.keys().last().map(|k| k.0)),
+ );
+ if self.next_photo < photo || self.next_album < album || self.next_stack < stack {
+ changed.push("id counters behind the ids in use: moved past them".into());
+ self.next_photo = self.next_photo.max(photo);
+ self.next_album = self.next_album.max(album);
+ self.next_stack = self.next_stack.max(stack);
+ }
+ changed
}
/// Replay an op log (JSON lines) on top of `self`. A torn final line (crash mid-write) is ignored.
@@ -878,4 +969,6 @@ mod tests_local;
#[cfg(test)]
mod tests_lock;
#[cfg(test)]
+mod tests_repair;
+#[cfg(test)]
mod tests_torn_append;
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index be6ab6cdcf..a9956703dc 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -253,8 +253,8 @@ impl Filter {
return false;
}
match &self.folder {
- // browsed photos only show in folder views
- None if p.local => return false,
+ // browsed photos only show in folder views, and in Recently Deleted once deleted (#1430)
+ None if p.local && !p.deleted => return false,
None => {}
Some(dir) => {
let crate::Source::File { path } = &p.source else { return false };
diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs
index df239a177d..fa067de0fd 100644
--- a/crates/catalog/src/safe_file.rs
+++ b/crates/catalog/src/safe_file.rs
@@ -20,8 +20,8 @@
//! it is then simply exported again.
//! - [`write_new`] / [`write_new_unique`] write a file that must not exist yet: the same temp file
//! and sync, plus a read-back check, and the file then appears under its final name **without
-//! replacing anything** (a hard link, which fails on a taken name; a check-then-rename where the
-//! volume has no hard links).
+//! replacing anything** (a hard link, which fails on a taken name; where the volume has no hard
+//! links, an exclusive create that claims the name, then a rename over that placeholder).
//! - [`same_file`] tells whether two paths name the same file on disk (device + inode on Unix,
//! resolved paths elsewhere), for "is this target an original?" checks.
@@ -226,16 +226,24 @@ fn publish_new(tmp: &Path, path: &Path) -> io::Result<()> {
match fs::hard_link(tmp, path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
- // no hard links on this volume (FAT/exFAT, some shares): check, then rename
- Err(_) => {
- if fs::symlink_metadata(path).is_ok() {
- return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} exists", path.display())));
- }
- fs::rename(tmp, path)
- }
+ // no hard links on this volume (FAT/exFAT, some shares)
+ Err(_) => publish_new_by_rename(tmp, path, &|| {}),
}
}
+/// Fallback of [`publish_new`]: reserve the name with an exclusive create (atomic, so a file that
+/// appears at the same time is never replaced), then rename the complete temp file over our own
+/// empty placeholder. `before_claim` runs just before the name is claimed (a test hook).
+fn publish_new_by_rename(tmp: &Path, path: &Path, before_claim: &dyn Fn()) -> io::Result<()> {
+ before_claim();
+ OpenOptions::new().write(true).create_new(true).open(path)?;
+ if let Err(e) = fs::rename(tmp, path) {
+ let _ = fs::remove_file(path);
+ return Err(e);
+ }
+ Ok(())
+}
+
/// Does the file at `path` hold exactly `data`?
fn same_content(path: &Path, data: &[u8]) -> io::Result {
use std::io::Read;
@@ -296,6 +304,22 @@ mod tests {
v
}
+ #[test]
+ fn fallback_publish_never_replaces_a_file_that_appears_at_the_same_time() {
+ let d = temp("nolink-race");
+ let tmp = d.join(".tmp-new");
+ let dest = d.join("IMG_1.dng");
+ fs::write(&tmp, b"new").unwrap();
+ let e = publish_new_by_rename(&tmp, &dest, &|| fs::write(&dest, b"other writer").unwrap()).unwrap_err();
+ assert_eq!(e.kind(), io::ErrorKind::AlreadyExists);
+ assert_eq!(fs::read(&dest).unwrap(), b"other writer");
+ fs::remove_file(&dest).unwrap();
+ publish_new_by_rename(&tmp, &dest, &|| {}).unwrap();
+ assert_eq!(fs::read(&dest).unwrap(), b"new");
+ assert!(!tmp.exists());
+ let _ = fs::remove_dir_all(&d);
+ }
+
#[test]
fn atomic_write_replaces_and_leaves_no_temp() {
let d = temp("replace");
diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs
index 04a9f0beb2..6ffb11f52c 100644
--- a/crates/catalog/src/stacks.rs
+++ b/crates/catalog/src/stacks.rs
@@ -25,7 +25,7 @@ impl Catalog {
}
/// Photo → (stack, position in the stack), for every stacked photo.
pub fn stack_index(&self) -> HashMap {
- self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect()
+ self.stacks.iter().flat_map(|(id, s)| s.photos.iter().enumerate().map(move |(i, p)| (*p, (*id, i)))).collect()
}
pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> {
@@ -70,7 +70,7 @@ impl Catalog {
}
}
for sid in &touched {
- for p in self.stacks[sid].photos.clone() {
+ for p in self.stacks.get(sid).map(|s| s.photos.clone()).unwrap_or_default() {
push(&mut order, p);
}
}
@@ -186,7 +186,10 @@ impl Catalog {
if !done.insert(*sid) {
continue;
}
- let s = &self.stacks[sid];
+ let Some(s) = self.stacks.get(sid) else {
+ out.push(*id);
+ continue;
+ };
let members = s.photos.iter().copied().filter(|p| present.contains(p));
if s.collapsed {
out.extend(members.take(1));
diff --git a/crates/catalog/src/tests_folders.rs b/crates/catalog/src/tests_folders.rs
index 556f77defc..51a9db10a7 100644
--- a/crates/catalog/src/tests_folders.rs
+++ b/crates/catalog/src/tests_folders.rs
@@ -237,6 +237,23 @@ fn recently_deleted_shows_the_deleted_photos_of_the_chosen_folder() {
assert!(!f.matches(c.photo(kept).unwrap(), &c));
}
+#[test]
+fn a_deleted_browsed_photo_shows_in_recently_deleted_and_comes_back() {
+ let mut c = Catalog::new();
+ let browsed = add_with(&mut c, "/pics/trip/a.jpg", |p| p.local = true);
+ let recently_deleted = Filter { deleted: true, ..Default::default() };
+ let library = Filter::default();
+ assert!(!recently_deleted.matches(c.photo(browsed).unwrap(), &c));
+ assert!(!library.matches(c.photo(browsed).unwrap(), &c), "browsed photos stay out of the library view");
+
+ c.apply(Op::SetDeleted { id: browsed, deleted: true }).unwrap();
+ assert_eq!(c.query(&recently_deleted, &Sort::default()), vec![browsed], "#1430: it was invisible everywhere");
+ assert!(c.query(&library, &Sort::default()).is_empty());
+
+ c.apply(Op::SetDeleted { id: browsed, deleted: false }).unwrap();
+ assert!(c.query(&recently_deleted, &Sort::default()).is_empty());
+}
+
#[test]
fn an_empty_folder_choice_is_no_choice() {
let c = library(&["/pics/a.jpg"]);
diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs
index d464d5c25a..baa8da6596 100644
--- a/crates/catalog/src/tests_lock.rs
+++ b/crates/catalog/src/tests_lock.rs
@@ -126,33 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
-/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
+/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a
+/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few
+/// children have been spawned during them, at most a minute.
#[cfg(unix)]
#[test]
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
use std::sync::Arc;
- use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+ use std::time::{Duration, Instant};
+
+ /// Children spawned while the lock cycles, at least, before the test may stop.
+ const CHILDREN: usize = 5;
let dir = temp_dir("spawn");
let stop = Arc::new(AtomicBool::new(false));
+ let count = Arc::new(AtomicUsize::new(0));
let spawner = {
- let stop = Arc::clone(&stop);
+ let (stop, count) = (Arc::clone(&stop), Arc::clone(&count));
std::thread::spawn(move || {
- let mut spawned = 0usize;
while !stop.load(Ordering::Relaxed) {
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
{
let _ = c.wait();
- spawned += 1;
+ count.fetch_add(1, Ordering::Relaxed);
}
}
- spawned
+ count.load(Ordering::Relaxed)
})
};
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ let t0 = Instant::now();
+ let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN;
let mut cycles = 0usize;
let mut refused = Vec::new();
- while cycles < 200_000 && std::time::Instant::now() < deadline {
+ while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) {
match LibraryLock::acquire(&dir, "LightCraft") {
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
diff --git a/crates/catalog/src/tests_repair.rs b/crates/catalog/src/tests_repair.rs
new file mode 100644
index 0000000000..493c895446
--- /dev/null
+++ b/crates/catalog/src/tests_repair.rs
@@ -0,0 +1,152 @@
+//! A damaged or hand-edited snapshot that still parses is made to hold together on load
+//! ([`Catalog::repair`]): found by fuzzing snapshots of a real library.
+
+use crate::journal::SNAPSHOT;
+use crate::*;
+
+fn album(id: u64, parent: Option, folder: bool, photos: Vec) -> Album {
+ Album {
+ id: AlbumId(id),
+ name: format!("A{id}"),
+ parent: parent.map(AlbumId),
+ folder,
+ photos,
+ cover: None,
+ smart: None,
+ quick: false,
+ order: None,
+ }
+}
+
+/// A catalog of four photos, a stack of two, a folder with an album in it.
+fn sound() -> (Catalog, Vec) {
+ let mut c = Catalog::new();
+ let mut ids = Vec::new();
+ for i in 0..4 {
+ let id = c.alloc_photo_id();
+ c.apply(Op::AddPhoto {
+ photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{i}.jpg") }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01")),
+ })
+ .unwrap();
+ ids.push(id);
+ }
+ let sid = c.alloc_stack_id();
+ c.apply(Op::AddStack { stack: Stack { id: sid, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap();
+ let (f, a) = (c.alloc_album_id(), c.alloc_album_id());
+ c.apply(Op::AddAlbum { album: album(f.0, None, true, vec![]) }).unwrap();
+ c.apply(Op::AddAlbum { album: album(a.0, Some(f.0), false, vec![ids[2]]) }).unwrap();
+ (c, ids)
+}
+
+/// The snapshot file of `c` with `edit` applied to its JSON, loaded through the journal.
+fn load_edited(c: &Catalog, edit: impl FnOnce(&mut serde_json::Value)) -> (Catalog, LoadReport) {
+ let m = MemStore::new();
+ let (mut j, _, _) = Journal::open(Box::new(m.clone())).unwrap();
+ j.snapshot(c).unwrap();
+ let mut v: serde_json::Value = serde_json::from_slice(&m.get(SNAPSHOT).unwrap()).unwrap();
+ edit(&mut v["catalog"]);
+ m.set(SNAPSHOT, serde_json::to_vec(&v).unwrap());
+ drop(j);
+ let (_, c, report) = Journal::open(Box::new(m)).unwrap();
+ (c, report)
+}
+
+/// A stack whose `id` differs from the key it is filed under made `arrange_stacks` index the map
+/// with an id it doesn't hold, which panics: the grid of any library with such a stack.
+#[test]
+fn a_stack_filed_under_another_id_is_arranged() {
+ let (c, ids) = sound();
+ let (c, report) = load_edited(&c, |v| {
+ let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
+ v["stacks"][&key]["id"] = 77.into();
+ });
+ assert!(report.repaired.iter().any(|r| r.contains("stack 77 was filed as")), "{:?}", report.repaired);
+ assert_eq!(c.arrange_stacks(&ids), vec![ids[0], ids[2], ids[3]], "the collapsed stack shows its top");
+ assert!(c.stacks().all(|s| c.stack_of(s.photos[0]).is_some_and(|t| t.id == s.id)));
+}
+
+/// Stacks of missing, repeated or too few photos (or one photo in two stacks) are put right: a
+/// stack of none had no top (`photos[0]`).
+#[test]
+fn stacks_keep_only_photos_in_the_catalog() {
+ let (c, ids) = sound();
+ let (c, report) = load_edited(&c, |v| {
+ let key = v["stacks"].as_object().unwrap().keys().next().unwrap().clone();
+ v["stacks"][&key]["photos"] = serde_json::json!([ids[0].0, 999, ids[0].0, ids[1].0]);
+ v["stacks"]["8"] = serde_json::json!({"id": 8, "photos": []});
+ v["stacks"]["9"] = serde_json::json!({"id": 9, "photos": [ids[1].0, ids[3].0, ids[2].0]});
+ });
+ let stacks: Vec<(StackId, Vec)> = c.stacks().map(|s| (s.id, s.photos.clone())).collect();
+ assert_eq!(stacks.len(), 2, "{stacks:?} {:?}", report.repaired);
+ assert_eq!(stacks[0].1, vec![ids[0], ids[1]], "missing and repeated photos left out");
+ assert_eq!(stacks[1].1, vec![ids[3], ids[2]], "a photo already in a stack stays in the first");
+ assert!(!c.arrange_stacks(&ids).is_empty());
+}
+
+/// Folders inside each other (a loop), or under an album or a missing folder, move to the top
+/// level: walking up from them never ends otherwise, and moving any folder there hung.
+#[test]
+fn folder_loops_and_missing_parents_move_to_the_top() {
+ let (c, _) = sound();
+ let (mut c, report) = load_edited(&c, |v| {
+ v["albums"]["10"] = serde_json::to_value(album(10, Some(11), true, vec![])).unwrap();
+ v["albums"]["11"] = serde_json::to_value(album(11, Some(10), true, vec![])).unwrap();
+ v["albums"]["12"] = serde_json::to_value(album(12, Some(12), true, vec![])).unwrap();
+ v["albums"]["13"] = serde_json::to_value(album(13, Some(404), false, vec![])).unwrap();
+ });
+ assert!(!report.repaired.is_empty());
+ for id in [10, 11, 12, 13] {
+ let a = c.album(AlbumId(id)).unwrap();
+ assert!(
+ a.parent.is_none() || a.parent.is_some_and(|p| c.album(p).is_some_and(|f| f.folder && f.parent.is_none())),
+ "album {id}: {:?}",
+ a.parent
+ );
+ }
+ // moving a folder around terminates either way
+ let _ = c.apply(Op::MoveAlbum { id: AlbumId(10), parent: Some(AlbumId(11)) });
+ let _ = c.apply(Op::MoveAlbum { id: AlbumId(11), parent: Some(AlbumId(10)) });
+}
+
+/// Id counters left at the top (or behind the ids in use) by a damaged file: allocating no longer
+/// overflows (a panic in debug builds) and never hands out an id already in use.
+#[test]
+fn id_counters_are_moved_past_the_ids_in_use() {
+ let (c, ids) = sound();
+ let (mut c, report) = load_edited(&c, |v| {
+ v["next_photo"] = 1.into();
+ v["next_album"] = 1.into();
+ v["next_stack"] = 1.into();
+ });
+ assert!(report.repaired.iter().any(|r| r.contains("id counters")), "{:?}", report.repaired);
+ assert!(c.alloc_photo_id().0 > ids[3].0);
+ let (mut c, _) = load_edited(&c, |v| v["next_photo"] = u64::MAX.into());
+ assert_eq!((c.alloc_photo_id().0, c.alloc_photo_id().0), (u64::MAX, u64::MAX), "saturates");
+ let p = Photo::new(PhotoId(u64::MAX), Source::Demo { scene: 1 }, "a", "JPEG", 1, 1, "t");
+ c.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap();
+ assert!(c.apply(Op::AddPhoto { photo: Box::new(p) }).is_err(), "the id is taken: refused, not overwritten");
+}
+
+/// A sound catalog loads unchanged.
+#[test]
+fn a_sound_snapshot_needs_no_repair() {
+ let (c, _) = sound();
+ let (loaded, report) = load_edited(&c, |_| {});
+ assert!(report.repaired.is_empty(), "{:?}", report.repaired);
+ assert_eq!(loaded.to_snapshot(), c.to_snapshot());
+}
+
+/// Found by fuzzing: an album filed under one id but naming another (here: the folder's) put
+/// itself inside itself, and listing the folder tree overflowed the stack (an abort, which no
+/// panic guard catches, every time the library opened).
+#[test]
+fn an_album_filed_under_another_id_gets_its_own() {
+ let (c, _) = sound();
+ let folder = c.albums().find(|a| a.folder).unwrap().id;
+ let inner = c.albums().find(|a| !a.folder).unwrap().id;
+ let (c, report) = load_edited(&c, |v| v["albums"][inner.0.to_string()]["id"] = folder.0.into());
+ assert!(report.repaired.iter().any(|r| r.contains(&format!("album {} was filed as {}", folder.0, inner.0))), "{:?}", report.repaired);
+ let a = c.album(inner).unwrap();
+ assert_eq!((a.id, a.parent), (inner, Some(folder)));
+ assert_eq!(c.album_children(Some(folder)).iter().map(|a| a.id).collect::>(), vec![inner]);
+}
diff --git a/crates/denoise-core/README.md b/crates/denoise-core/README.md
index 74d1e3247a..4294f93694 100644
--- a/crates/denoise-core/README.md
+++ b/crates/denoise-core/README.md
@@ -5,3 +5,8 @@ No weights or inference backend. See [the user workflow](../../docs/denoise.md).
`lightcraft-denoise` re-exports these types for CPU/GPU users; the engine uses this
crate directly when inference is disabled. The default download catalog is empty.
Run `cargo test -p lightcraft-denoise-core`; optional catalog checks use `--features rawnind-model`.
+
+The Bayer runner rejects non-finite source samples before inference and non-finite
+results after gain matching, highlight protection and tile blending. Finite negative
+and HDR source samples remain supported. These guards do not implement Raw Details
+or Super Resolution.
diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs
index 1df96a9e16..987a859b97 100644
--- a/crates/denoise-core/src/run.rs
+++ b/crates/denoise-core/src/run.rs
@@ -95,6 +95,9 @@ pub fn denoise_bayer(
if mosaic.len() != pixels {
return Err(Error::Input("the mosaic does not have width × height samples".into()));
}
+ if mosaic.iter().any(|v| !v.is_finite()) {
+ return Err(Error::Input("the mosaic has samples that are not finite".into()));
+ }
if !(16..=2048).contains(&p.tile) || p.overlap >= p.tile.div_ceil(2) || layout.dx > 1 || layout.dy > 1 {
return Err(Error::Input("the tile size and overlap do not fit".into()));
}
@@ -148,6 +151,9 @@ pub fn denoise_bayer(
return Err(Error::Model("numbers that are not finite".into()));
}
finish_tile(&input, &mut out, p, (bx, ax, by, ay));
+ if out.iter().any(|v| !v.is_finite()) {
+ return Err(Error::Model("numbers that are not finite after scale matching or highlight protection".into()));
+ }
Ok(out)
})
.collect()
@@ -201,6 +207,9 @@ pub fn denoise_bayer(
}
}
});
+ if acc.iter().flatten().any(|v| !v.is_finite()) {
+ return Err(Error::Model("numbers that are not finite after tile blending".into()));
+ }
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
eprintln!(
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
@@ -388,6 +397,53 @@ mod tests {
Params { tile, overlap, gain, clip: None, parallel: 2 }
}
+ #[test]
+ fn nonfinite_mosaics_are_rejected_before_inference() {
+ struct Counting(std::sync::atomic::AtomicUsize);
+ impl TileRunner for Counting {
+ fn run(&self, _: &[f32]) -> Result, Error> {
+ self.0.fetch_add(1, Ordering::Relaxed);
+ Ok(vec![0.0; 3 * 32 * 32])
+ }
+ }
+ let model = Counting(std::sync::atomic::AtomicUsize::new(0));
+ let layout = Layout { dx: 0, dy: 0 };
+ for sample in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
+ assert!(matches!(denoise_bayer(&[sample], 1, 1, layout, &model, ¶ms(16, 0, Gain::None), &Control::default()), Err(Error::Input(_))));
+ }
+ assert_eq!(model.0.load(Ordering::Relaxed), 0);
+ // Finite scene-linear samples can be negative or exceed SDR white.
+ for sample in [-0.25, 2.0] {
+ assert!(denoise_bayer(&[sample], 1, 1, layout, &model, ¶ms(16, 0, Gain::None), &Control::default()).is_ok());
+ }
+ }
+
+ #[test]
+ fn finite_model_output_that_overflows_during_gain_matching_is_rejected() {
+ struct Extreme;
+ impl TileRunner for Extreme {
+ fn run(&self, _: &[f32]) -> Result, Error> {
+ Ok(vec![f32::MAX; 3 * 32 * 32])
+ }
+ }
+ let p = params(16, 0, Gain::MatchMean { nominal: 1e-6, max_deviation: 0.0 });
+ assert!(matches!(denoise_bayer(&[0.5], 1, 1, Layout { dx: 0, dy: 0 }, &Extreme, &p, &Control::default()), Err(Error::Model(_))));
+ }
+
+ #[test]
+ fn overlapping_finite_tiles_that_overflow_the_accumulator_are_rejected() {
+ struct Extreme;
+ impl TileRunner for Extreme {
+ fn run(&self, _: &[f32]) -> Result, Error> {
+ Ok(vec![f32::MAX; 3 * 32 * 32])
+ }
+ }
+ assert!(matches!(
+ denoise_bayer(&vec![0.5; 48 * 48], 48, 48, Layout { dx: 0, dy: 0 }, &Extreme, ¶ms(16, 4, Gain::None), &Control::default()),
+ Err(Error::Model(_))
+ ));
+ }
+
#[test]
fn overflowing_tiles_and_nan_gain_are_errors_before_the_model_runs() {
let layout = Layout::from_cell([0, 1, 1, 2]).unwrap();
diff --git a/crates/denoise/src/onnx.rs b/crates/denoise/src/onnx.rs
index fc84a36cf1..074963695f 100644
--- a/crates/denoise/src/onnx.rs
+++ b/crates/denoise/src/onnx.rs
@@ -97,7 +97,12 @@ fn read_bytes(bytes: &[u8]) -> Result {
if next.input.len() != 1 || next.output.len() != 1 || next.attribute.iter().any(|a| a.name != "alpha") {
return invalid("malformed LeakyRelu");
}
- let alpha = attr(next, "alpha").map_or(0.01, |a| a.f);
+ // only an omitted alpha gets the ONNX default; one that is not a single float must not read as 0
+ let alpha = match next.attribute.as_slice() {
+ [] => 0.01,
+ [AttributeProto { f: Some(f), kind: 0 | 1, .. }] => *f,
+ _ => return invalid("LeakyRelu alpha is not a single float"),
+ };
i += 1;
let name = next.output.first().cloned().ok_or_else(|| NetError::Invalid("LeakyRelu without an output".into()))?;
return Ok((Some(alpha), name));
@@ -319,6 +324,25 @@ mod tests {
let _ = std::fs::remove_file(p);
}
+ #[test]
+ fn a_leaky_relu_alpha_that_is_not_a_float_is_rejected() {
+ let good = unet_onnx(4, 3, 1, 7);
+ assert!(read_bytes(&good).is_ok());
+ // an alpha attribute: float field 2, then the declared type (field 20) FLOAT
+ let mut alpha = vec![0x15];
+ alpha.extend_from_slice(&0.2f32.to_le_bytes());
+ let at = good.windows(alpha.len()).position(|w| w == alpha.as_slice()).unwrap() + alpha.len();
+ assert_eq!(good[at..at + 3], [0xa0, 0x01, 1]);
+ // declared INT: the float must not be taken as the slope
+ let mut wrong_type = good.clone();
+ wrong_type[at + 2] = 2;
+ assert!(matches!(read_bytes(&wrong_type), Err(NetError::Invalid(_))));
+ // declared FLOAT but no float value (the float field becomes an int field 3 = 0 of the same length): must not read as 0
+ let mut no_float = good.clone();
+ no_float[at - 5..at].copy_from_slice(&[0x18, 0x80, 0x80, 0x80, 0x00]);
+ assert!(matches!(read_bytes(&no_float), Err(NetError::Invalid(_))));
+ }
+
#[test]
fn an_explicit_empty_or_nonvector_bias_is_not_treated_as_missing() {
let w = TensorProto { name: "w".into(), dims: vec![12, 4, 1, 1], data_type: 1, float_data: vec![0.1; 48], ..Default::default() };
diff --git a/crates/denoise/src/onnx_proto.rs b/crates/denoise/src/onnx_proto.rs
index d8bbba4111..a331f0fe52 100644
--- a/crates/denoise/src/onnx_proto.rs
+++ b/crates/denoise/src/onnx_proto.rs
@@ -113,7 +113,10 @@ fn ints(w: Wire<'_>, out: &mut Vec, cap: usize) -> Result<()> {
#[derive(Default)]
pub(super) struct AttributeProto {
pub name: String,
- pub f: f32,
+ /// `None` when the file has no float value for the attribute.
+ pub f: Option,
+ /// The declared attribute type (1 float, 2 int, 3 string, 7 ints); 0 when the file does not say.
+ pub kind: i64,
pub i: i64,
pub s: Vec,
pub ints: Vec,
@@ -151,7 +154,7 @@ fn attribute(b: &[u8]) -> Result {
while let Some((n, w)) = fields.next()? {
match (n, w) {
(1, Wire::Bytes(b)) => a.name = string(b)?,
- (2, Wire::F32(v)) => a.f = v,
+ (2, Wire::F32(v)) if a.f.is_none() => a.f = Some(v),
(3, Wire::Int(v)) => a.i = v as i64,
(4, Wire::Bytes(b)) => {
if b.len() > MAX_STRING {
@@ -160,7 +163,7 @@ fn attribute(b: &[u8]) -> Result {
a.s = b.to_vec();
}
(8, w) => ints(w, &mut a.ints, 16)?,
- (20, Wire::Int(1 | 2 | 3 | 7)) => {}
+ (20, Wire::Int(v @ (1 | 2 | 3 | 7))) if a.kind == 0 => a.kind = v as i64,
_ => return Err(NetError::Unsupported("unknown attribute value".into())),
}
}
diff --git a/crates/engine/examples/drag_tick_bench.rs b/crates/engine/examples/drag_tick_bench.rs
new file mode 100644
index 0000000000..c45cb68574
--- /dev/null
+++ b/crates/engine/examples/drag_tick_bench.rs
@@ -0,0 +1,42 @@
+//! UI-thread work one slider-drag tick costs outside the render: `develop.set` inside an
+//! interaction, the filmstrip's `visible()` after the catalog revision moved, and the settings
+//! hash the loupe takes every frame. `cargo run --release -p lightcraft-engine --example
+//! drag_tick_bench -- [photos]` (default 20000).
+#![allow(clippy::unwrap_used, clippy::expect_used)]
+
+use lightcraft_catalog::{Op, Photo, PhotoId, Source};
+use lightcraft_engine::Session;
+use serde_json::json;
+use std::time::Instant;
+
+fn main() {
+ let n: u64 = std::env::args().nth(1).and_then(|a| a.parse().ok()).unwrap_or(20_000);
+ let mut s = Session::new();
+ for i in 1..=n {
+ let mut p = Photo::new(PhotoId(i), Source::File { path: format!("/x/{i}.ARW") }, &format!("{i}.ARW"), "ARW", 9984, 6656, "2026-01-01");
+ p.captured = Some(format!("2025-{:02}-{:02}T10:{:02}:00", 1 + i % 12, 1 + i % 28, i % 60));
+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ }
+ s.execute("library.select", &json!({"ids": [1]})).unwrap();
+ let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
+ s.visible_cloned();
+ s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
+ let ticks = 30;
+ let (mut set, mut vis, mut hash) = (0.0, 0.0, 0.0);
+ for k in 0..ticks {
+ let t = Instant::now();
+ s.execute("develop.set", &json!({"control": "light.exposure", "value": k as f64 / 30.0})).unwrap();
+ set += ms(t);
+ let t = Instant::now();
+ let v = s.visible_cloned();
+ vis += ms(t);
+ assert_eq!(v.len() as u64, n);
+ let d = s.develop_of(PhotoId(1)).unwrap();
+ let t = Instant::now();
+ std::hint::black_box(d.hash64());
+ hash += ms(t);
+ }
+ s.execute("develop.endInteraction", &json!({})).unwrap();
+ let f = ticks as f64;
+ println!("{n} photos, per tick: develop.set {:.3} ms, visible() {:.3} ms, settings hash64 {:.3} ms", set / f, vis / f, hash / f);
+}
diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs
index 6a473a2deb..3d7dbc4453 100644
--- a/crates/engine/examples/render_bench.rs
+++ b/crates/engine/examples/render_bench.rs
@@ -9,7 +9,15 @@
//! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU
//! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores.
//! Each scenario runs on the CPU pipeline and, when a GPU adapter exists, on `lightcraft-gpu`
-//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only).
+//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only, `GPU_ONLY=1` for the GPU only).
+//!
+//! `ONLY=window`: a 1:1 zoom window cut from the full-size original (the loupe's `Slot::Region`):
+//! the first window of a photo, a pan, and exposure / draft / NR changes with a warm stage cache;
+//! `DUMP=file` writes the GPU window's RGBA bytes, for pixel A/B comparisons between builds.
+//! Opening a photo, per file: `ONLY=source` (decode steps), `ONLY=load` (what the app's loaders
+//! cost: file read, decode, binning, embedded stand-ins, `load_bytes(2560)`, the fs loader),
+//! `ONLY=load1` (decode checksum + `load_bytes(2560)`; `LOADONLY=1`: only that, for a profiler),
+//! `ONLY=jpeg` (a JPEG decoded to fit 256–2560 px boxes).
use std::sync::Arc;
use std::time::Instant;
@@ -88,6 +96,63 @@ fn main() {
let n: usize = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
let only = std::env::var("ONLY").unwrap_or_default();
let run = |name: &str| only.is_empty() || only.split(',').any(|o| name.contains(o));
+ if only == "jpeg" {
+ // a JPEG (e.g. an embedded preview pulled out with exiftool) decoded to fit a few boxes
+ for path in &args {
+ let bytes = std::fs::read(path).expect("read");
+ for edge in [256u32, 512, 1600, 2560] {
+ let o = lightcraft_codecs::DecodeOptions::fit(edge, edge);
+ let d = lightcraft_codecs::decode(&bytes, o).expect("decode");
+ println!(
+ " {path} fit {edge}: {}×{} {}",
+ d.image.width,
+ d.image.height,
+ best(n, || drop(lightcraft_codecs::decode(&bytes, o).expect("decode")))
+ );
+ }
+ }
+ return;
+ }
+ if only == "load" || only == "load1" {
+ // opening a photo in the loupe, step by step: what the app's loaders cost (per file)
+ for path in &args {
+ let bytes = std::fs::read(path).expect("read");
+ println!("{path}");
+ if std::env::var("LOADONLY").is_ok() {
+ // only the preview load (for a profiler)
+ println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
+ continue;
+ }
+ println!(" {:<36} {}", "file read:", best(n, || drop(std::fs::read(path).expect("read"))));
+ println!(" {:<36} {}", "raw decode:", best(n, || drop(lightcraft_raw::decode(&bytes).expect("decode"))));
+ let mut raw = lightcraft_raw::decode(&bytes).expect("decode");
+ if let lightcraft_raw::RawData::U16(d) = &raw.data {
+ let sum = d.iter().enumerate().fold(0u64, |a, (i, &v)| a.wrapping_mul(31).wrapping_add(v as u64 ^ i as u64));
+ println!(" decode checksum: {sum:016x}");
+ }
+ if only == "load1" {
+ println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
+ continue;
+ }
+ raw.opcodes.list3.retain(|op| !op.is_lens_correction());
+ if let Some(k) = lightcraft_engine::files::bin_factor(&raw, 2560) {
+ println!(" {:<36} {}", format!("binned ×{k} masked:"), best(n, || drop(raw.develop_binned_masked(k, 0.99))));
+ }
+ for edge in [2560usize, 1600] {
+ println!(
+ " {:<36} {}",
+ format!("embedded srgb, whole file ({edge}):"),
+ best(n, || drop(lightcraft_engine::files::embedded_preview_srgb(&bytes, edge)))
+ );
+ let quick = lightcraft_engine::files::fs_preview_loader();
+ println!(" {:<36} {}", format!("fs_preview_loader ({edge}):"), best(n, || drop(quick(path, edge))));
+ }
+ println!(" {:<36} {}", "load_bytes(2560):", best(n, || drop(lightcraft_engine::files::load_bytes(&bytes, 2560).expect("load"))));
+ let (loader, _) = lightcraft_engine::files::fs_hooks();
+ println!(" {:<36} {}", "fs loader (2560, read+load):", best(n, || drop(loader(path, 2560).expect("load"))));
+ }
+ return;
+ }
if only == "source" {
// opening a photo: where the time goes before the first sharp loupe (per file)
for path in &args {
@@ -152,10 +217,16 @@ fn main() {
Some(a) => println!("gpu: {a} (device + kernels: {:.0} ms)", t.elapsed().as_secs_f64() * 1e3),
None => println!("gpu: none"),
}
- let backends: &[bool] = if gpu { &[false, true] } else { &[false] };
+ // `GPU_ONLY=1`: skip the CPU column (profiling the GPU path)
+ let backends: &[bool] = match (gpu, std::env::var_os("GPU_ONLY").is_some()) {
+ (true, true) => &[true],
+ (true, false) => &[false, true],
+ _ => &[false],
+ };
let row = |name: &str, f: &mut dyn FnMut(bool)| {
let cols: Vec = backends.iter().map(|&g| format!("{}", best(n, || f(g)))).collect();
- println!("{name:<38} cpu: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
+ let first = if backends[0] { "gpu" } else { "cpu" };
+ println!("{name:<38} {first}: {}{}", cols[0], cols.get(1).map(|g| format!(" | gpu: {g}")).unwrap_or_default());
};
let preview = Arc::new(fit(&full, 2560, 2560, Filter::Box));
let s = typical();
@@ -214,6 +285,68 @@ fn main() {
drop(develop(&preview, &info, &t, &view, Some(&caches[g as usize]), g));
});
}
+ if run("window") {
+ // a 1:1 zoom window (the loupe's `Slot::Region`): visible 1920×1200 + margin, snapped to the
+ // 256 px grid, cut from the full-resolution original (`ui-egui` `region::window_for`)
+ use lightcraft_pipeline::PixelWindow;
+ let (fw, fh) = (full.width, full.height);
+ let win = |cx: usize, cy: usize| {
+ let snap = |v: usize| v / 256 * 256;
+ let (x, y) = (snap(cx.saturating_sub(960 + 512)), snap(cy.saturating_sub(600 + 512)));
+ PixelWindow { x, y, w: 3072.min(fw - x), h: 2304.min(fh - y) }
+ };
+ let at = |w: PixelWindow, q: Quality| RenderRequest { window: Some(w), quality: q, ..RenderRequest::fit(fw, fh) };
+ let w0 = win(fw / 2, fh / 2);
+ println!("window {}×{} at ({}, {}) of {fw}×{fh}", w0.w, w0.h, w0.x, w0.y);
+ // first window of a photo: nothing cached (the GPU uploads the original: `shared_source`)
+ row("window first (upload original):", &mut |g| {
+ lightcraft_engine::gpu::trim_pool(0);
+ drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), g))
+ });
+ let wc = [StageCache::default(), StageCache::default()];
+ for g in backends {
+ drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
+ }
+ let mut step = 0usize;
+ row("window pan (new window, warm src):", &mut |g| {
+ step += 1;
+ let w = win(fw / 2 + 512 * (step % 4 + 1), fh / 2);
+ drop(develop(&full, &info, &s, &at(w, Quality::Full), Some(&wc[g as usize]), g))
+ });
+ for g in backends {
+ drop(develop(&full, &info, &s, &at(w0, Quality::Full), Some(&wc[*g as usize]), *g));
+ }
+ row("window exposure change (warm):", &mut |g| {
+ let mut t = s.clone();
+ k += 1.0;
+ t.light.exposure = 0.3 + k * 0.01;
+ drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
+ });
+ row("window draft exposure drag (warm):", &mut |g| {
+ let mut t = s.clone();
+ k += 1.0;
+ t.light.exposure = 0.3 + k * 0.01;
+ drop(develop(&full, &info, &t, &at(w0, Quality::Draft), Some(&wc[g as usize]), g));
+ });
+ row("window NR change (warm):", &mut |g| {
+ let mut t = s.clone();
+ k += 1.0;
+ t.detail.nr_luminance = 30.0 + k % 50.0;
+ drop(develop(&full, &info, &t, &at(w0, Quality::Full), Some(&wc[g as usize]), g));
+ });
+ // `DUMP=file`: the GPU window's raw RGBA bytes (A/B pixel comparisons between builds)
+ if let (true, Some(path)) = (gpu, std::env::var_os("DUMP")) {
+ let g = develop(&full, &info, &s, &at(w0, Quality::Full), Some(&StageCache::default()), true).image;
+ let bytes: Vec = g.data.iter().flatten().copied().collect();
+ std::fs::write(path, bytes).expect("dump");
+ }
+ if gpu && std::env::var_os("GPU_ONLY").is_none() {
+ let c = develop(&full, &info, &s, &at(w0, Quality::Full), None, false).image;
+ let g = develop(&full, &info, &s, &at(w0, Quality::Full), None, true).image;
+ let (max, mean) = diff(&c, &g);
+ println!("gpu vs cpu, window: max {max} LSB, mean {mean:.4} LSB");
+ }
+ }
if run("finish") {
// per-pixel stage cost by feature (warm cache: exposure changes only)
let variants: [(&str, fn(&mut DevelopSettings)); 6] = [
diff --git a/crates/engine/examples/scan_setup_bench.rs b/crates/engine/examples/scan_setup_bench.rs
new file mode 100644
index 0000000000..e9d8f29578
--- /dev/null
+++ b/crates/engine/examples/scan_setup_bench.rs
@@ -0,0 +1,63 @@
+//! What a scan, an import and Synchronize Folder cost before they read the disk, and what
+//! naming one photo's sidecar costs, by the size of the library (`N`, default 100,000 photos; the
+//! folder looked at holds 500 of them).
+//!
+//! `N=3500000 cargo run --release -p lightcraft-engine --example scan_setup_bench`
+use std::time::Instant;
+
+use lightcraft_catalog::{Op, Photo, Source};
+use lightcraft_engine::Session;
+
+fn main() {
+ let n: u64 = std::env::var("N").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000);
+ let mut s = Session::new();
+ let t = Instant::now();
+ for chunk in (0..n).collect::>().chunks(10_000) {
+ for i in chunk {
+ let id = s.catalog.alloc_photo_id();
+ let name = format!("IMG_{i:08}.jpg");
+ let path = format!("/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day {:05}/extra/{name}", i / 500);
+ let mut p = Photo::new(id, Source::File { path }, &name, "JPEG", 4032, 3024, "2026-09-30T12:00:00");
+ p.file_size = 3_400_000 + i;
+ p.captured = Some(format!("2019-07-{:02}T10:{:02}:{:02}", 1 + i % 28, i % 60, (i / 60) % 60));
+ p.content_hash = Some(format!("{:032x}", i.wrapping_mul(0x9e37_79b9_7f4a_7c15)));
+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ }
+ }
+ eprintln!("{n} photos built in {:.2} s", t.elapsed().as_secs_f64());
+ let folder = "/Volumes/Photos/Library/Archive/2019/2019-07 Summer/Trip to the coast/Day 00000".to_string();
+ for run in 0..3 {
+ let ms = |t: Instant| t.elapsed().as_secs_f64() * 1e3;
+ let t = Instant::now();
+ let f = lightcraft_catalog::Filter { library_folder: Some(folder.clone()), ..Default::default() };
+ let ids = s.catalog.query(&f, &lightcraft_catalog::Sort::default());
+ let query = ms(t);
+ let t = Instant::now();
+ let namings = s.sidecar_namings(&ids);
+ let naming = ms(t);
+ let t = Instant::now();
+ let one = ids.first().map(|id| s.sidecar_naming(*id));
+ let naming_one = ms(t);
+ let t = Instant::now();
+ let (input, paths) = lightcraft_engine::import::ScanInput::new(&mut s, std::slice::from_ref(&folder));
+ let scan = ms(t);
+ // (the folder is not on this disk: the scan finds no file, and its cost is what it makes of the library)
+ let t = Instant::now();
+ let out = lightcraft_engine::import::scan_with(input, &paths, &Default::default());
+ let scanned = ms(t);
+ let t = Instant::now();
+ let job = lightcraft_engine::import::ImportJob::new(&mut s, Default::default());
+ let import = ms(t);
+ let t = Instant::now();
+ let sync = lightcraft_engine::sync::SyncInput::new(&mut s, &folder, false);
+ let whole = ms(t);
+ println!(
+ "N={n} run {run}: query {query:.0} ms ({} ids) | sidecar_namings {naming:.0} ms | sidecar_naming of one {naming_one:.0} ms ({one:?}) | ScanInput::new {scan:.0} ms + scan_with {scanned:.0} ms ({} found) | ImportJob::new {import:.0} ms (ok {}) | SyncInput::new {whole:.0} ms (ok {})",
+ ids.len(),
+ out.candidates.len(),
+ job.is_ok(),
+ sync.is_ok()
+ );
+ let _ = namings;
+ }
+}
diff --git a/crates/engine/src/apple_photos.rs b/crates/engine/src/apple_photos.rs
index fd1c6f24b3..01394cd5f3 100644
--- a/crates/engine/src/apple_photos.rs
+++ b/crates/engine/src/apple_photos.rs
@@ -308,9 +308,12 @@ pub enum Outcome {
Imported(Imported),
/// Photos (or osascript) failed: why, for the user.
Failed(String),
- /// Photos wasn't asked: the export that reserved the import was cancelled, wrote no files or
- /// stopped early.
- Skipped(String),
+ /// Photos wasn't asked: the export that reserved the import was cancelled (`cancelled`), wrote
+ /// no files or stopped early. `why` is for people; code decides on `cancelled`.
+ Skipped {
+ why: String,
+ cancelled: bool,
+ },
}
/// One import: reserved by an export still writing its files, running, or finished.
@@ -402,9 +405,12 @@ impl ImportJob {
v["running"] = json!(false);
v["error"] = json!(e);
}
- Some(Outcome::Skipped(why)) => {
+ Some(Outcome::Skipped { why, cancelled }) => {
v["running"] = json!(false);
v["skipped"] = json!(why);
+ if cancelled {
+ v["cancelled"] = json!(true);
+ }
}
}
v
@@ -660,7 +666,7 @@ impl Reservation {
None
};
if let Some(why) = skipped {
- job.finish(Outcome::Skipped(why.into()));
+ job.finish(Outcome::Skipped { why: why.into(), cancelled });
return Err(job.json());
}
if let Err(e) = check(&paths, &self.album) {
@@ -700,7 +706,7 @@ impl Reservation {
impl Drop for Reservation {
fn drop(&mut self) {
if let Some(job) = self.job.take() {
- job.finish(Outcome::Skipped("the export ended before writing its files".into()));
+ job.finish(Outcome::Skipped { why: "the export ended before writing its files".into(), cancelled: false });
}
}
}
@@ -1009,8 +1015,10 @@ mod tests {
// nothing written, or cancelled: Photos isn't asked, and the slot is free again
let out = imports.reserve(runner.clone(), "").unwrap().run(&[json!({"skipped": "x"})], false);
assert!(out["skipped"].as_str().unwrap().contains("wrote no files"), "{out}");
+ assert_eq!(out.get("cancelled"), None, "{out}");
let out = imports.reserve(runner.clone(), "").unwrap().run(&exported, true);
assert!(out["skipped"].as_str().unwrap().contains("cancelled"), "{out}");
+ assert_eq!(out["cancelled"], true, "the app decides on the flag, not on the words: {out}");
assert_eq!(calls.lock().unwrap().len(), 1);
assert!(imports.running().is_none());
let _ = std::fs::remove_dir_all(dir);
diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs
index cc278ffcfa..77fe8f6ef4 100644
--- a/crates/engine/src/camera_preview.rs
+++ b/crates/engine/src/camera_preview.rs
@@ -372,6 +372,7 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
let decoded = crate::files::decode_raw_preview(
bytes,
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
+ None,
)?;
let mut reference = decoded.to_working();
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs
index 1ae72e48e6..6f064b260a 100644
--- a/crates/engine/src/cmd/develop.rs
+++ b/crates/engine/src/cmd/develop.rs
@@ -26,8 +26,11 @@ fn section_param(p: &Value, c: &str) -> Result {
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
}
-fn groups_param(p: &Value) -> Option> {
- p.get("groups").and_then(|g| serde_json::from_value(g.clone()).ok())
+fn groups_param(p: &Value, c: &str) -> Result>> {
+ match p.get("groups") {
+ None | Some(Value::Null) => Ok(None),
+ Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(c, format!("invalid `groups`: {e}"))),
+ }
}
fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
@@ -497,13 +500,15 @@ pub fn specs() -> Vec {
}),
cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| {
let ch = str_param(p, "channel").unwrap_or("master").to_string();
- let pts: Vec = p
+ let mut pts: Vec = p
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| bad("develop.curve", "missing points"))?
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?.clamp(0.0, 1.0), q.get(1)?.as_f64()?.clamp(0.0, 1.0))))
.collect();
+ // stored in the order the curve runs, as the editor draws and drags them
+ pts.sort_by(|a, b| a.x.total_cmp(&b.x));
edit(s, "develop.curve", "Tone Curve", |d| {
match ch.as_str() {
"master" | "rgb" => d.curve.master = pts,
@@ -782,8 +787,9 @@ pub fn specs() -> Vec {
"{groups?: [settingsGroup]} (default: all but crop, masks, remove)",
has_active,
|s, p| {
- let id = active(s, "develop.copy")?;
- if let Some(g) = groups_param(p) {
+ let c = "develop.copy";
+ let id = active(s, c)?;
+ if let Some(g) = groups_param(p, c)? {
s.copy_groups = g;
}
let d = s.develop_of(id).unwrap_or_default();
@@ -802,7 +808,7 @@ pub fn specs() -> Vec {
let c = "develop.pastePrevious";
let prev =
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
- let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
+ let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
let ids: Vec = s.targets(p).into_iter().filter(|id| *id != prev).collect();
let ops = ids
@@ -823,8 +829,9 @@ pub fn specs() -> Vec {
"{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",
has_clipboard,
|s, p| {
+ let c = "develop.paste";
let mut clip = s.clipboard.clone().unwrap_or_default();
- if let Some(only) = groups_param(p) {
+ if let Some(only) = groups_param(p, c)? {
// the clipboard holds whole groups: rebuild it with just the chosen (copied) ones
let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
@@ -849,8 +856,9 @@ pub fn specs() -> Vec {
"{groups?} — copy the active photo's settings to all selected photos",
has_selection,
|s, p| {
- let id = active(s, "develop.sync")?;
- let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
+ let c = "develop.sync";
+ let id = active(s, c)?;
+ let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let partial = lightcraft_develop::extract_groups(&d, &groups);
let ops = s
@@ -979,10 +987,11 @@ pub fn specs() -> Vec {
ok()
}),
cmd!("preset.create", "Create Preset", ["Photo"], None, "{name, group?, groups?: [settingsGroup]}", has_active, |s, p| {
- let id = active(s, "preset.create")?;
- let name = str_param(p, "name").ok_or_else(|| bad("preset.create", "missing name"))?;
+ let c = "preset.create";
+ let id = active(s, c)?;
+ let name = str_param(p, "name").ok_or_else(|| bad(c, "missing name"))?;
let group = str_param(p, "group").unwrap_or("User Presets");
- let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
+ let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
@@ -1026,7 +1035,7 @@ pub fn specs() -> Vec {
let c = "preset.update";
let id = active(s, c)?;
let current = s.develop_of(id).unwrap_or_default().to_json();
- let groups = groups_param(p);
+ let groups = groups_param(p, c)?;
let pr = user_preset(s, p, c)?;
pr.settings = match groups {
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs
index ffdedbef16..4676d1c65e 100644
--- a/crates/engine/src/cmd/face_detect.rs
+++ b/crates/engine/src/cmd/face_detect.rs
@@ -18,7 +18,7 @@ use lightcraft_meta::{Region, RegionKind};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_selection};
-use crate::{Result, Session};
+use crate::{AiModel, EngineError, Result, Session};
const C: &str = "faces.detect";
/// Regions made by this command say so in their description; only those are replaced on a new run.
@@ -34,15 +34,39 @@ const EXISTING_IOU: f64 = 0.3;
/// What to tell someone who has not downloaded the detector yet.
const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces";
+/// Why [`detector`] has no detector to give.
+#[derive(Debug)]
+pub(crate) enum NoDetector {
+ /// Not downloaded yet: a frontend offers the download ([`crate::ErrorKind::ModelMissing`]).
+ NotInstalled,
+ /// Nowhere to keep it, or there but unusable: says what to do.
+ Unusable(String),
+}
+
+impl NoDetector {
+ /// Command `cmd`'s error for it. The message reads as before kinds existed (an invalid-parameters
+ /// error), so control-channel and MCP clients see the same `error` text.
+ pub(crate) fn into_error(self, cmd: &str) -> EngineError {
+ match self {
+ Self::NotInstalled => EngineError::ModelMissing { model: AiModel::FaceDetector, msg: bad(cmd, NOT_INSTALLED).to_string() },
+ Self::Unusable(why) => bad(cmd, why),
+ }
+ }
+}
+
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
-pub(crate) fn detector(s: &Session) -> std::result::Result, String> {
+pub(crate) fn detector(s: &Session) -> std::result::Result, NoDetector> {
static LOADED: Mutex)>> = Mutex::new(None);
- let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID);
+ let home = s
+ .face_models_dir
+ .as_deref()
+ .ok_or_else(|| NoDetector::Unusable("this build has nowhere to keep face models (the desktop app does)".into()))?
+ .join(YUNET_ID);
let path = home.join("model.onnx");
// installing writes the manifest last, so a folder without one is not (yet) a model
if !path.is_file() || !home.join("face-model.json").is_file() {
- return Err(NOT_INSTALLED.into());
+ return Err(NoDetector::NotInstalled);
}
let mut loaded = LOADED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((p, d)) = loaded.as_ref()
@@ -50,12 +74,14 @@ pub(crate) fn detector(s: &Session) -> std::result::Result, String
{
return Ok(d.clone());
}
- let len = std::fs::metadata(&path).map_err(|e| format!("could not read the detector: {e}"))?.len();
+ let len = std::fs::metadata(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?.len();
if len > MAX_MODEL_BYTES {
- return Err("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into());
+ return Err(NoDetector::Unusable("the detector's file is far bigger than YuNet: remove it in Settings > Faces and download it again".into()));
}
- let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?;
- let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?);
+ let bytes = std::fs::read(&path).map_err(|e| NoDetector::Unusable(format!("could not read the detector: {e}")))?;
+ let d = Arc::new(
+ Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
+ );
*loaded = Some((path, d.clone()));
Ok(d)
}
@@ -103,7 +129,7 @@ fn detect(s: &mut Session, p: &Value) -> Result {
nms_iou: p.get("nmsIou").and_then(Value::as_f64).map_or(defaults.nms_iou, |v| v as f32),
max_faces: p.get("maxFaces").and_then(Value::as_u64).map_or(defaults.max_faces, |v| usize::try_from(v).unwrap_or(defaults.max_faces)),
};
- let detector = detector(s).map_err(|e| bad(C, e))?;
+ let detector = detector(s).map_err(|e| e.into_error(C))?;
let started = Instant::now();
let (mut results, mut ops) = (Vec::new(), Vec::new());
for id in s.targets(p) {
diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs
index ae8b862b60..25ff3f24da 100644
--- a/crates/engine/src/cmd/masks.rs
+++ b/crates/engine/src/cmd/masks.rs
@@ -84,29 +84,29 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result
match shape {
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
- s.segmenter.model_dir().map_err(ai_err)?;
+ s.segmenter.require_model()?;
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
}
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
- *seg = Some(s.segment_clicks(id, &clicks_of(hint, exclude)).map_err(ai_err)?);
+ *seg = Some(segment(s, |s| s.segment_clicks(id, &clicks_of(hint, exclude)))?);
}
MaskShape::Object { .. } => {
// the clicks come later: refuse an Object mask that could never be computed (no
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
- s.segmenter.model_dir().map_err(ai_err)?;
+ s.segmenter.require_model()?;
if background {
- s.segment_prepare(id).map_err(ai_err)?;
+ segment(s, |s| s.segment_prepare(id))?;
}
}
MaskShape::Prompt { text, .. } if background => {
- s.segmenter.model_dir().map_err(ai_err)?;
+ s.segmenter.require_model()?;
if text.trim().is_empty() {
return Err(ai_err("describe what to select"));
}
return Ok(Later::Text(text.clone()));
}
MaskShape::Prompt { text, seg, .. } => {
- let found = s.segment_text(id, text).map_err(ai_err)?;
+ let found = segment(s, |s| s.segment_text(id, text))?;
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
}
_ => {}
@@ -118,7 +118,7 @@ fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result {
if let Later::Clicks(hint, exclude) = later {
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
- s.segment_clicks_later(id, mask, comp, hint, exclude).map_err(ai_err)?;
+ segment(s, |s| s.segment_clicks_later(id, mask, comp, hint, exclude))?;
out["pending"] = json!(true);
}
Ok(out)
@@ -129,6 +129,13 @@ fn ai_err(msg: impl Into) -> crate::EngineError {
crate::EngineError::Other(msg.into())
}
+/// A segmentation call: the model is checked first, so a missing one is [`crate::EngineError::ModelMissing`]
+/// (the segmenter's own errors are text).
+fn segment(s: &mut Session, f: impl FnOnce(&mut Session) -> std::result::Result) -> Result {
+ s.segmenter.require_model()?;
+ f(s).map_err(ai_err)
+}
+
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec {
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
}
@@ -167,7 +174,7 @@ pub fn specs() -> Vec {
let name = str_param(p, "name").map(str::to_string);
if let Later::Text(text) = &later {
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
- s.segment_text_later(id, None, "add", name, text).map_err(ai_err)?;
+ segment(s, |s| s.segment_text_later(id, None, "add", name, text))?;
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
@@ -205,7 +212,7 @@ pub fn specs() -> Vec {
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
if let Later::Text(text) = &later {
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
- s.segment_text_later(id, Some(mid), op, None, text).map_err(ai_err)?;
+ segment(s, |s| s.segment_text_later(id, Some(mid), op, None, text))?;
return Ok(json!({"pending": true}));
}
let mut comp = 0;
@@ -365,10 +372,10 @@ pub fn specs() -> Vec {
}
if s.segmenter.background && !hint.is_empty() {
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
- s.segment_clicks_later(id, mid, k, hint, ex).map_err(ai_err)?;
+ segment(s, |s| s.segment_clicks_later(id, mid, k, hint, ex))?;
return Ok(out);
}
- let seg = if hint.is_empty() { None } else { Some(s.segment_clicks(id, &clicks_of(&hint, &ex)).map_err(ai_err)?) };
+ let seg = if hint.is_empty() { None } else { Some(segment(s, |s| s.segment_clicks(id, &clicks_of(&hint, &ex)))?) };
let out = json!({"include": hint.len(), "exclude": ex.len()});
masks_edit(s, c, "Object Mask", |masks, _| {
let i = find(masks, mid, c)?;
@@ -401,7 +408,7 @@ pub fn specs() -> Vec {
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
};
- let started = s.segment_detail(id, mid, k).map_err(ai_err)?;
+ let started = segment(s, |s| s.segment_detail(id, mid, k))?;
Ok(json!({"started": started}))
}
),
@@ -415,7 +422,7 @@ pub fn specs() -> Vec {
|s, _| {
let c = "segment.prepare";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
- s.segment_prepare(id).map_err(ai_err)?;
+ segment(s, |s| s.segment_prepare(id))?;
Ok(json!({"busy": s.segmenter.busy()}))
}
),
@@ -639,7 +646,8 @@ pub fn specs() -> Vec {
let i = find(&mut masks, mid, "mask.move")?;
let to = match (p.get("to").and_then(Value::as_i64), p.get("delta").and_then(Value::as_i64)) {
(Some(t), _) => t,
- (None, Some(d)) => i as i64 + d,
+ // (an agent's delta can be anything: saturate rather than overflow)
+ (None, Some(d)) => (i as i64).saturating_add(d),
(None, None) => return Err(bad("mask.move", "give `to` (index) or `delta`")),
}
.clamp(0, masks.len() as i64 - 1) as usize;
@@ -700,7 +708,7 @@ pub fn specs() -> Vec {
|s, p| {
let c = "spot.findBlemishes";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
- let detector = super::face_detect::detector(s).map_err(|e| bad(c, e))?;
+ let detector = super::face_detect::detector(s).map_err(|e| e.into_error(c))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs
index 2e0b6f1579..fbe4608f0a 100644
--- a/crates/engine/src/cmd/query.rs
+++ b/crates/engine/src/cmd/query.rs
@@ -31,14 +31,24 @@ pub fn photo_summary(p: &Photo) -> Value {
})
}
-fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value {
+/// How deep [`album_json`] lists folders inside folders: a catalog that loops (an album inside
+/// itself, which [`lightcraft_catalog::Catalog::repair`] puts right on load) lists no further
+/// instead of overflowing the stack.
+const MAX_ALBUM_DEPTH: usize = 64;
+
+fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth: usize) -> Value {
+ let children: Vec = if depth < MAX_ALBUM_DEPTH {
+ all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat, depth + 1)).collect()
+ } else {
+ Vec::new()
+ };
let mut v = json!({
"id": a.id.0,
"name": a.name,
"folder": a.folder,
"count": cat.album_count(a.id),
"cover": a.cover.map(|c| c.0),
- "children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::>(),
+ "children": children,
});
if let Some(rules) = &a.smart {
v["smart"] = json!(true);
@@ -139,7 +149,7 @@ pub fn specs() -> Vec {
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec = s.catalog.albums().cloned().collect();
- Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
+ Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog, 0)).collect()))
}),
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
let id = photo_arg(s, p, "photo.inspect")?;
diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs
index 6af62637fe..cd89aa8969 100644
--- a/crates/engine/src/cmd/xmp.rs
+++ b/crates/engine/src/cmd/xmp.rs
@@ -12,8 +12,9 @@ fn save(s: &mut Session, p: &Value) -> Result {
let mut merged = Vec::new();
let mut backups = Vec::new();
let mut failed = Vec::new();
- let owners = crate::sidecar::StemOwners::of(&s.catalog);
- for id in s.targets(p) {
+ let ids = s.targets(p);
+ let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
+ for id in ids {
match s.save_sidecar_with(id, &owners) {
Ok(r) => {
let path = r.path.display().to_string();
diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs
index 091bf5d002..b3ded1fbd7 100644
--- a/crates/engine/src/config.rs
+++ b/crates/engine/src/config.rs
@@ -1,16 +1,31 @@
//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera
//! profiles, face and denoise models).
//!
+//! [`config_dir`] picks it, in order:
+//! 1. `$LIGHTCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
+//! 2. **Portable mode** (issue #1200, as in PhotoCraft): a marker file ([`PORTABLE_MARKERS`]) beside the
+//! executable puts everything in `/LightCraftData`, so a copy on a USB stick leaves nothing in
+//! `%APPDATA%`. If that folder can't be written (read-only medium, Program Files), a warning is logged
+//! and the platform folder is used.
+//! 3. The platform convention: macOS `~/Library/Application Support/LightCraft`, Windows
+//! `%APPDATA%\LightCraft`, Linux `$XDG_CONFIG_HOME/lightcraft` or `~/.config/lightcraft`.
+//!
//! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model
//! installed from one is there for the others. Nothing here is applied automatically: a [`Session`] has no
//! face-, denoise- or SAM 3 model folder until a host asks for one ([`Session::with_default_face_models`],
//! [`Session::with_default_denoise_models`], [`Session::with_default_sam3_model`]), so tests stay hermetic.
use std::ffi::OsString;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
+use std::sync::OnceLock;
use crate::Session;
+/// Files beside the executable that switch on portable mode (either one; contents are ignored).
+pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "LightCraft.portable"];
+/// The data folder created beside the executable in portable mode.
+pub const PORTABLE_DATA_DIR: &str = "LightCraftData";
+
#[derive(Clone, Copy)]
enum Platform {
Mac,
@@ -18,9 +33,9 @@ enum Platform {
Other,
}
-/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
-/// The override is the complete directory; no application-name suffix is appended. An explicitly
-/// empty value disables the settings directory instead of falling back to a personal profile.
+/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides portable mode and the
+/// platform default. The override is the complete directory; no application-name suffix is appended. An
+/// explicitly empty value disables the settings directory instead of falling back to a personal profile.
pub fn config_dir() -> Option {
let platform = if cfg!(target_os = "macos") {
Platform::Mac
@@ -29,13 +44,17 @@ pub fn config_dir() -> Option {
} else {
Platform::Other
};
- config_dir_with(platform, |name| std::env::var_os(name))
+ config_dir_with(platform, |name| std::env::var_os(name), portable_dir)
}
-fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option {
+/// `portable` is asked only without an override, so an isolated run never probes beside the executable.
+fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option, portable: impl FnOnce() -> Option) -> Option {
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
+ if let Some(dir) = portable() {
+ return Some(dir);
+ }
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
@@ -45,6 +64,50 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -
}
}
+/// This process's portable data folder, if a marker sits beside its executable and the folder can be
+/// written. Resolved once: the check creates the folder and writes a probe file.
+fn portable_dir() -> Option {
+ static DIR: OnceLock> = OnceLock::new();
+ DIR.get_or_init(|| {
+ let exe = std::env::current_exe().ok()?;
+ match portable_dir_in(exe.parent()?) {
+ Ok(dir) => dir,
+ Err(why) => {
+ log::warn!("{why}");
+ None
+ }
+ }
+ })
+ .clone()
+}
+
+/// `/LightCraftData` when `exe_dir` holds a portable marker; `Err` says why a marked folder
+/// can't be used (the caller falls back to the platform folder).
+fn portable_dir_in(exe_dir: &Path) -> Result, String> {
+ let Some(marker) = PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) else {
+ return Ok(None);
+ };
+ let data = exe_dir.join(PORTABLE_DATA_DIR);
+ match ensure_writable(&data) {
+ Ok(()) => Ok(Some(data)),
+ Err(e) => Err(format!(
+ "portable mode ({} found) but {} isn't writable ({e}); settings are kept in the usual folder instead",
+ marker.display(),
+ data.display()
+ )),
+ }
+}
+
+/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
+fn ensure_writable(dir: &Path) -> std::io::Result<()> {
+ std::fs::create_dir_all(dir)?;
+ let probe = dir.join(format!(".write-test-{}", std::process::id()));
+ std::fs::write(&probe, b"")?;
+ // A leftover probe is harmless; don't fail portable mode over it.
+ let _ = std::fs::remove_file(&probe);
+ Ok(())
+}
+
/// Where face models are kept: `$LIGHTCRAFT_FACE_MODELS` if set, else `/models`.
pub fn default_face_models_dir() -> Option {
std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models")))
@@ -101,16 +164,62 @@ mod config_tests {
use super::*;
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option {
- config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
+ config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), || None)
+ }
+
+ /// An override is used as is: portable mode is not even looked for.
+ fn no_portable_probe() -> Option {
+ panic!("an override must not probe for portable mode")
+ }
+
+ fn temp(tag: &str) -> PathBuf {
+ let d = std::env::temp_dir().join(format!("lc-config-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+ }
+
+ /// A marker beside the executable moves the settings folder next to it, ahead of the platform folder (issue #1200).
+ #[test]
+ fn portable_marker_puts_settings_beside_the_executable() {
+ for marker in PORTABLE_MARKERS {
+ let exe = temp(&format!("portable-{marker}"));
+ assert_eq!(portable_dir_in(&exe), Ok(None), "no marker, no portable folder");
+ assert!(!exe.join(PORTABLE_DATA_DIR).exists());
+ std::fs::write(exe.join(marker), b"").unwrap();
+ let data = exe.join(PORTABLE_DATA_DIR);
+ assert_eq!(portable_dir_in(&exe), Ok(Some(data.clone())));
+ assert!(data.is_dir());
+ assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0, "the write probe is removed");
+ let found = portable_dir_in(&exe).unwrap();
+ let actual = config_dir_with(Platform::Windows, |name| (name == "APPDATA").then(|| OsString::from("/roaming")), || found);
+ assert_eq!(actual, Some(data));
+ let _ = std::fs::remove_dir_all(&exe);
+ }
+ }
+
+ /// A marked folder that can't hold the data folder (here: a file is in its place) is reported, not used.
+ #[test]
+ fn portable_folder_that_cannot_be_written_is_refused() {
+ let exe = temp("portable-blocked");
+ std::fs::write(exe.join("portable.txt"), b"").unwrap();
+ std::fs::write(exe.join(PORTABLE_DATA_DIR), b"").unwrap();
+ let why = portable_dir_in(&exe).unwrap_err();
+ assert!(why.contains("portable.txt") && why.contains(PORTABLE_DATA_DIR), "{why}");
+ let _ = std::fs::remove_dir_all(&exe);
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
- let actual = config_dir_with(platform, |name| {
- assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
- Some(OsString::from("isolated/settings"))
- });
+ let actual = config_dir_with(
+ platform,
+ |name| {
+ assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
+ Some(OsString::from("isolated/settings"))
+ },
+ no_portable_probe,
+ );
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
@@ -119,10 +228,14 @@ mod config_tests {
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
- config_dir_with(platform, |name| {
- assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
- Some(OsString::new())
- }),
+ config_dir_with(
+ platform,
+ |name| {
+ assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
+ Some(OsString::new())
+ },
+ no_portable_probe
+ ),
None
);
}
@@ -145,7 +258,7 @@ mod config_tests {
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
- assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
+ assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), no_portable_probe), Some(PathBuf::from(value)));
}
}
diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs
index b7488a0239..9de8c3d64e 100644
--- a/crates/engine/src/crs.rs
+++ b/crates/engine/src/crs.rs
@@ -173,7 +173,8 @@ fn curve(props: &Props, k: &str) -> Option {
let mut pts = Vec::new();
for it in items {
let (x, y) = it.split_once(',')?;
- let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?);
+ // (`parse` reads "NaN" and "inf" too: a curve with such a point is not a curve)
+ let (x, y) = (x.trim().parse::().ok().filter(|v| v.is_finite())?, y.trim().parse::().ok().filter(|v| v.is_finite())?);
pts.push(json!({"x": (x / 255.0).clamp(0.0, 1.0), "y": (y / 255.0).clamp(0.0, 1.0)}));
}
if pts.len() < 2 {
@@ -617,6 +618,19 @@ mod tests {
assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5));
}
+ /// A sidecar curve with a `NaN` or `inf` point (Rust's `parse` reads both) is not a curve:
+ /// it is left out, rather than stored with a point the curve editor can't place.
+ #[test]
+ fn curves_with_non_finite_points_are_left_out() {
+ for bad in ["NaN, 0", "64, inf", "-inf, 3"] {
+ let x = SIDECAR.replace("64, 52 ", &format!("{bad} "));
+ let partial = to_partial(&props(&x), Target::RawAbsolute);
+ let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
+ assert!(s.curve.master.is_empty(), "{bad}: {:?}", s.curve.master);
+ assert!(s.curve.master.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
+ }
+ }
+
#[test]
fn only_present_fields_are_in_the_partial() {
let x = r#" ErrorKind {
+ match self {
+ Self::UnknownCommand(_) => ErrorKind::UnknownCommand,
+ Self::Disabled(..) => ErrorKind::Disabled,
+ Self::BadParams { .. } => ErrorKind::BadParams,
+ Self::Catalog(_) => ErrorKind::Catalog,
+ Self::NotSaved(_) => ErrorKind::NotSaved,
+ Self::LibraryInUse(_) => ErrorKind::LibraryInUse,
+ Self::LightroomCatalog { .. } => ErrorKind::LightroomCatalog,
+ Self::Cancelled(_) => ErrorKind::Cancelled,
+ Self::ModelMissing { model, .. } => ErrorKind::ModelMissing(*model),
+ Self::Other(_) => ErrorKind::Other,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_kind_does_not_depend_on_the_message() {
+ let cancelled = EngineError::Cancelled("Lightroom-Import abgebrochen".into());
+ assert_eq!(cancelled.kind(), ErrorKind::Cancelled);
+ assert_eq!(cancelled.to_string(), "Lightroom-Import abgebrochen", "the message is shown as is");
+ let missing = EngineError::ModelMissing { model: AiModel::Sam3, msg: "any wording".into() };
+ assert_eq!(missing.kind(), ErrorKind::ModelMissing(AiModel::Sam3));
+ assert_eq!(missing.to_string(), "any wording");
+ // an `Other` whose text reads like a cancellation or a missing model is still `Other`
+ assert_eq!(EngineError::Other("The SAM 3 model is not installed; cancelled".into()).kind(), ErrorKind::Other);
+ }
+}
diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs
index e53dfcbc40..403cb71f69 100644
--- a/crates/engine/src/export.rs
+++ b/crates/engine/src/export.rs
@@ -108,6 +108,10 @@ impl Default for Resize {
}
}
+/// The most pixels an export enlarged past its photo's own size can have: 512 megapixels, also
+/// Lightroom's ceiling. A full-size or smaller export is never held to it.
+pub const MAX_ENLARGED_PIXELS: f64 = 512e6;
+
impl Resize {
pub fn long_edge(px: u32) -> Self {
Self { value: px as f32, ..Default::default() }
@@ -131,8 +135,12 @@ impl Resize {
ResizeMode::Percent => v / 100.0,
};
let k = if self.dont_enlarge { k.min(1.0) } else { k };
- // at least one pixel, at most the encoders' 65535 limit
- k.clamp(1.0 / short, 65_535.0 / long)
+ // an enlargement stops at 512 megapixels (Lightroom's ceiling too): 20000 typed for 2000
+ // with Don't Enlarge off asked for gigapixels, more memory than a computer has
+ let k = k.min((MAX_ENLARGED_PIXELS / (w * h)).sqrt().max(1.0));
+ // at least one pixel, at most the encoders' 65535 limit; the limit wins for a photo more
+ // than 65535 times as long as it is short (where `clamp` would panic: min above max)
+ k.max(1.0 / short).min(65_535.0 / long)
}
/// Output size for a `w × h` (cropped, full-resolution) photo.
@@ -1670,10 +1678,16 @@ fn prepare_guarded(
let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize);
let (work, weight) = if o.format.is_rendered() {
let (w, h) = output_size(p, o);
+ let (nw, nh) = lightcraft_pipeline::native_output_size(p.width.max(1) as usize, p.height.max(1) as usize, &p.develop);
+ let native_px = (nw.round().max(1.0) * nh.round().max(1.0)) as usize;
+ let label = p.file_name.clone();
let meta = export_metadata(p, &session.catalog, o);
let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg;
let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?;
let weight = render_weight(full_px, long, job.level, w.saturating_mul(h));
+ if let Some(why) = too_big_for_memory((w, h), native_px, weight, crate::memory::total_ram()) {
+ return Err(format!("{label}: {why}"));
+ }
let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None };
(Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight)
} else {
@@ -1697,6 +1711,26 @@ fn prepare_guarded(
Ok(PreparedExport { photo: id, file_name, work, guard, weight })
}
+/// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size,
+/// can't be made on a computer with `ram` bytes, given its working memory `weight`: an export
+/// enlarged past the photo's size that needs more memory than the computer has would fail to
+/// allocate, which ends the process rather than the export. Full-size and smaller exports, and
+/// computers whose memory isn't known, are never refused.
+fn too_big_for_memory(out: (usize, usize), native_px: usize, weight: usize, ram: Option) -> Option {
+ let ram = ram?;
+ if out.0.saturating_mul(out.1) <= native_px || weight <= ram {
+ return None;
+ }
+ let gb = |b: usize| b as f64 / (1u64 << 30) as f64;
+ Some(format!(
+ "exporting at {} × {} needs about {:.0} GB of memory, more than this computer has ({:.0} GB); choose a smaller size",
+ out.0,
+ out.1,
+ gb(weight).ceil(),
+ gb(ram)
+ ))
+}
+
/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level
/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the
/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export
@@ -2738,10 +2772,44 @@ mod tests {
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
// never larger than the encoders' limit, never empty
- assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((65535, 43690)));
+ assert_eq!(size(json!({"percent": 10000, "dontEnlarge": false}), 6000.0, 4000.0), Some((27713, 18475)), "512 MP at most");
+ assert_eq!(size(json!({"width": 70000, "dontEnlarge": false}), 60000.0, 100.0), Some((65535, 109)), "65535 px an edge");
assert_eq!(size(json!({"width": 1}), 6000.0, 40.0).map(|s| s.1), Some(1));
}
+ /// Typing 20000 for 2000 (or percent 10000) with Don't Enlarge off asked for gigapixels: the
+ /// render's first plane alone (34 GB at 65535 × 43690) failed to allocate, which aborts the
+ /// process. An enlargement stops at 512 megapixels; full-size and smaller exports of photos
+ /// larger than that are untouched.
+ #[test]
+ fn enlargements_stop_at_512_megapixels() {
+ let size = |p: serde_json::Value, w: f64, h: f64| ExportOptions::from_json(&p).resize.map(|r| r.apply(w, h)).unwrap();
+ for p in [json!({"longEdge": 60000, "dontEnlarge": false}), json!({"megapixels": 5000}), json!({"percent": 10000, "dontEnlarge": false})] {
+ let (w, h) = size(p.clone(), 6000.0, 4000.0);
+ assert!((w * h) as f64 <= MAX_ENLARGED_PIXELS * 1.001, "{p}: {w}×{h}");
+ assert!((w as f64 / h as f64 - 1.5).abs() < 0.001, "{p}: keeps the shape");
+ }
+ // below the ceiling an enlargement is as asked
+ assert_eq!(size(json!({"longEdge": 12000, "dontEnlarge": false}), 6000.0, 4000.0), (12000, 8000));
+ // a 600 MP panorama exports at full size, and smaller, as before; it is never enlarged
+ assert_eq!(size(json!({"percent": 100, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
+ assert_eq!(size(json!({"percent": 50}), 30000.0, 20000.0), (15000, 10000));
+ assert_eq!(size(json!({"percent": 200, "dontEnlarge": false}), 30000.0, 20000.0), (30000, 20000));
+ }
+
+ /// An enlarged export that needs more memory than the computer has is refused with a message,
+ /// before it starts; everything else goes ahead.
+ #[test]
+ fn enlarged_exports_must_fit_the_computers_memory() {
+ let gb = 1usize << 30;
+ let why = too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(16 * gb)).unwrap();
+ assert!(why.contains("27713 × 18475") && why.contains("20 GB") && why.contains("16 GB") && why.contains("smaller size"), "{why}");
+ assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, Some(32 * gb)), None, "fits");
+ assert_eq!(too_big_for_memory((6000, 4000), 24_000_000, 20 * gb, Some(gb)), None, "full size is never refused");
+ assert_eq!(too_big_for_memory((3000, 2000), 24_000_000, 20 * gb, Some(gb)), None, "nor a smaller export");
+ assert_eq!(too_big_for_memory((27713, 18475), 24_000_000, 20 * gb, None), None, "unknown memory");
+ }
+
#[test]
fn resize_size_survives_the_render() {
// the render fits the photo into the size `apply` gives; that must not cost a pixel (#1312)
@@ -2783,6 +2851,33 @@ mod tests {
}
}
+ /// A photo more than 65535 times as long as it is short (a 70000 × 1 strip) made `scale`
+ /// clamp with its lower bound above its upper one, which panics, in the export and in the
+ /// Export dialog's preview on the UI thread. The encoders' limit wins, the short edge keeps a
+ /// pixel.
+ #[test]
+ fn resize_survives_photos_too_long_for_the_encoders() {
+ let modes = [
+ json!({"longEdge": 2000}),
+ json!({"shortEdge": 1}),
+ json!({"width": 500, "height": 500}),
+ json!({"megapixels": 1}),
+ json!({"percent": 50}),
+ json!({"percent": 1000, "dontEnlarge": false}),
+ ];
+ for p in modes {
+ for (w, h) in [(70_000.0, 1.0), (1.0, 70_000.0), (1e9, 3.0), (65_536.0, 1.0)] {
+ let r = ExportOptions::from_json(&p).resize.unwrap();
+ let (ow, oh) = r.apply(w, h);
+ assert!((1..=65_535).contains(&ow) && (1..=65_535).contains(&oh), "{p} on {w}×{h}: {ow}×{oh}");
+ assert!(r.scale(w, h).is_finite());
+ }
+ }
+ // (a short edge under a pixel would be scaled up to one, which the limit then caps)
+ assert_eq!(Resize::long_edge(2000).apply(70_000.0, 1.0), (65_535, 1));
+ assert_eq!(Resize::long_edge(2000).apply(1.0, 70_000.0), (1, 65_535));
+ }
+
/// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`.
fn p3_green(space: OutputSpace) -> Rgba8 {
use lightcraft_color::{DISPLAY_P3, REC2020};
diff --git a/crates/engine/src/face_tiles.rs b/crates/engine/src/face_tiles.rs
new file mode 100644
index 0000000000..c5903ee5f4
--- /dev/null
+++ b/crates/engine/src/face_tiles.rs
@@ -0,0 +1,143 @@
+//! Face tiles cut from a raw's embedded preview (issue #1440).
+//!
+//! A People card's face picture ([`crate::Session::face_job`]) is a small square around the face.
+//! Developing the raw for it reads and demosaics most of a 24–50 MB file for a ~170 px picture; the
+//! camera's own JPEG inside the file (read through the planned preview path,
+//! [`crate::files::preview_window`], which reads the header and the preview and not the sensor
+//! data) usually holds that square at the tile's size already. A [`PreviewCut`] says which preview
+//! to read and how large it must be; [`crate::media::RenderJob::run`] tries it before the job's own
+//! source and falls back to that source when the preview can't serve.
+//!
+//! Only a raw that still looks the way it was imported gets one: that is the photo whose grid
+//! thumbnail is the same embedded JPEG ([`crate::Session::embedded_thumb_is_final`]). An edited raw
+//! needs its edits rendered, which the camera's display-referred JPEG can't take the way the raw
+//! does, so it keeps the full develop render.
+
+use lightcraft_geom::Rect;
+use lightcraft_raster::Rgba8;
+use lightcraft_raster::resample::{Filter, resize};
+
+use crate::media::PreviewLoader;
+
+/// How much smaller than asked an embedded preview may be and still serve a face tile: at least
+/// three quarters of the pixels the tile asks for across the face. The square is then enlarged by
+/// at most 4⁄3 on its way to the tile, which at a 56–180 pt tile is not visible (the camera's JPEG
+/// is sharpened and denoised, so its pixels hold more detail than a binned raw's), and it is the
+/// same bound the raw path already accepts for an X-Trans sensor binned 3× for a preview
+/// ([`crate::files::bin_factor`]). Below it the tile would look soft: the job's own source renders it.
+pub const SOFT_LIMIT: f64 = 0.75;
+
+/// Largest relative difference between the preview's and the photo's aspect ratios: a preview of
+/// another shape (letterboxed, 16:9 from a 3:2 sensor, a 4:3 sensor's 3:2 JPEG) does not show the
+/// same frame, and the face box would land beside the face. Real previews of the same frame differ
+/// by rounding only (Sony A7S III: 1616 × 1080 against 4240 × 2832, 0.06 %).
+const ASPECT_TOLERANCE: f64 = 0.02;
+
+/// A face tile to cut from the photo's embedded preview.
+#[derive(Clone)]
+pub struct PreviewCut {
+ /// The raw file.
+ pub path: String,
+ /// Reads its largest embedded preview, display-ready and upright, no larger than the edge given.
+ pub loader: PreviewLoader,
+ /// The preview's long edge that gives the tile its own size across the face.
+ pub want: usize,
+ /// The smallest long edge accepted ([`SOFT_LIMIT`] of `want`).
+ pub min: usize,
+ /// The square around the face, normalized on the upright photo.
+ pub rect: Rect,
+ /// The photo's width / height, upright.
+ pub aspect: f64,
+ /// The tile's edge, pixels.
+ pub edge: usize,
+}
+
+impl PreviewCut {
+ /// The tile, or `None` when the preview can't give it: none could be read (no preview, an
+ /// offline or damaged file), it is smaller than [`Self::min`], or it shows another frame.
+ pub fn cut(&self) -> Option {
+ let preview = (self.loader)(&self.path, self.want)?;
+ self.cut_from(&preview)
+ }
+
+ /// [`Self::cut`] from a preview already read.
+ pub fn cut_from(&self, img: &Rgba8) -> Option {
+ let (w, h) = (img.width, img.height);
+ if w == 0 || h == 0 || w.max(h) < self.min || img.data.len() != w.checked_mul(h)? {
+ return None;
+ }
+ let aspect = w as f64 / h as f64;
+ if !(self.aspect.is_finite() && self.aspect > 0.0) || ((aspect - self.aspect) / self.aspect).abs() > ASPECT_TOLERANCE {
+ return None;
+ }
+ let r = self.rect;
+ if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
+ return None;
+ }
+ // pixel bounds inside the preview (the clamps keep every value in 0..=w / 0..=h)
+ let px = |v: f64, n: usize| (v.clamp(0.0, 1.0) * n as f64).round() as usize;
+ let (x0, x1, y0, y1) = (px(r.x0, w), px(r.x1, w), px(r.y0, h), px(r.y1, h));
+ if x1 <= x0 || y1 <= y0 {
+ return None;
+ }
+ let edge = self.edge.clamp(1, crate::media::SourceLevel::Thumb.max_edge());
+ let square = img.crop(x0, y0, x1 - x0, y1 - y0).to_linear();
+ Some(resize(&square, edge, edge, Filter::Mitchell).to_srgb8())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ fn cut(rect: Rect, aspect: f64, min: usize) -> PreviewCut {
+ PreviewCut { path: String::new(), loader: Arc::new(|_, _| None), want: 300, min, rect, aspect, edge: 32 }
+ }
+
+ fn picture(w: usize, h: usize) -> Rgba8 {
+ let mut img = Rgba8::new(w, h);
+ for (i, p) in img.data.iter_mut().enumerate() {
+ *p = [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 90, 255];
+ }
+ img
+ }
+
+ /// The square comes out the tile's size, from the place the box names.
+ #[test]
+ fn cuts_the_square_at_the_tiles_size() {
+ let img = picture(300, 200);
+ let out = cut(Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.65 }, 1.5, 200).cut_from(&img).unwrap();
+ assert_eq!((out.width, out.height), (32, 32));
+ // red follows x: the box starts at half the width
+ let red = out.data[16 * 32 + 16][0];
+ assert!((125..=160).contains(&red), "{red}");
+ }
+
+ /// A preview too small, of another shape, empty, or a box that is no box: no tile, no panic.
+ #[test]
+ fn declines_what_it_cannot_cut() {
+ let img = picture(300, 200);
+ let ok = Rect { x0: 0.4, y0: 0.4, x1: 0.5, y1: 0.55 };
+ assert!(cut(ok, 1.5, 301).cut_from(&img).is_none(), "below the soft limit");
+ assert!(cut(ok, 16.0 / 9.0, 200).cut_from(&img).is_none(), "another frame");
+ assert!(cut(ok, f64::NAN, 200).cut_from(&img).is_none());
+ assert!(cut(ok, 0.0, 200).cut_from(&img).is_none());
+ assert!(cut(ok, 1.5, 0).cut_from(&Rgba8::new(0, 0)).is_none(), "empty preview");
+ let short = Rgba8 { width: 300, height: 200, data: vec![[0; 4]; 10] };
+ assert!(cut(ok, 1.5, 0).cut_from(&short).is_none(), "a buffer shorter than its size");
+ for bad in [
+ Rect { x0: f64::NAN, y0: 0.4, x1: 0.5, y1: 0.5 },
+ Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
+ Rect { x0: 0.6, y0: 0.6, x1: 0.4, y1: 0.4 },
+ Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 },
+ ] {
+ assert!(cut(bad, 1.5, 0).cut_from(&img).is_none(), "{bad:?}");
+ }
+ // a box past the edges is clamped to the picture
+ let edge = cut(Rect { x0: -1.0, y0: -1.0, x1: 0.1, y1: 0.15 }, 1.5, 0).cut_from(&img).unwrap();
+ assert_eq!((edge.width, edge.height), (32, 32));
+ // and a loader with nothing to give leaves the job to its source
+ assert!(cut(ok, 1.5, 0).cut().is_none());
+ }
+}
diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs
index a83a8d1a19..4e744dedb7 100644
--- a/crates/engine/src/files.rs
+++ b/crates/engine/src/files.rs
@@ -450,7 +450,8 @@ fn load_bytes_now(
stages.push(("highlights", t0.elapsed()));
colour.apply(&mut img);
stages.push(("colour", t0.elapsed()));
- let img = fit(&img, max_edge, max_edge, Filter::Box);
+ // a full-size render keeps the picture (fitting it to its own size would copy it, ~390 MB at 33 MP)
+ let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img };
stages.push(("fit", t0.elapsed()));
let img = img.into_oriented(raw.orientation);
stages.push(("orient", t0.elapsed()));
@@ -649,11 +650,16 @@ fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
}
-/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
-/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
-/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
-fn stand_in_image(bytes: &[u8]) -> Option> {
- if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
+/// What stands in for a raw file we can't decode: its largest embedded JPEG (with `covering`, the smallest one that is
+/// at least that big, [`lightcraft_raw::embedded_preview_covering`]), else, for a TIFF-based raw, the file's own first
+/// image when the TIFF reader can show it (a reduced RGB copy: the only preview some containers carry). Returns image
+/// bytes for [`lightcraft_codecs::decode`].
+fn stand_in_image(bytes: &[u8], covering: Option) -> Option> {
+ let jpeg = match covering {
+ Some(edge) => lightcraft_raw::embedded_preview_covering(bytes, u32::try_from(edge).unwrap_or(u32::MAX)),
+ None => lightcraft_raw::embedded_preview(bytes),
+ };
+ if let Some(jpeg) = jpeg {
return Some(std::borrow::Cow::Owned(jpeg));
}
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
@@ -661,9 +667,14 @@ fn stand_in_image(bytes: &[u8]) -> Option> {
.then_some(std::borrow::Cow::Borrowed(bytes))
}
-/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
-pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option {
- let jpeg = stand_in_image(bytes)?;
+/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting (`covering`: see
+/// [`stand_in_image`]).
+pub(crate) fn decode_raw_preview(
+ bytes: &[u8],
+ opts: lightcraft_codecs::DecodeOptions,
+ covering: Option,
+) -> Option {
+ let jpeg = stand_in_image(bytes, covering)?;
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
@@ -680,7 +691,7 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp
/// Oriented size of the embedded preview of a raw file.
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
- let jpeg = stand_in_image(bytes)?;
+ let jpeg = stand_in_image(bytes, None)?;
// headers only, as for other images (issue #367): decoding a full-size preview just for its
// size took about a second per file in an import; a decode remains the fallback
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
@@ -698,16 +709,18 @@ fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
- let d = decode_raw_preview(bytes, fit_box(max_edge))?;
+ // always the largest JPEG: a source's geometry must not depend on its size level (previews match exports)
+ let d = decode_raw_preview(bytes, fit_box(max_edge), None)?;
let img = d.to_working();
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
}
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
-/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
+/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]). The smallest
+/// embedded JPEG that covers `max_edge` is decoded, not the largest.
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option {
- let mut d = decode_raw_preview(bytes, fit_box(max_edge))?;
+ let mut d = decode_raw_preview(bytes, fit_box(max_edge), Some(max_edge))?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
@@ -735,7 +748,11 @@ pub fn fs_preview_loader() -> PreviewLoader {
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
let gate = crate::memory::work_gate();
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
- if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
+ if let Some(image) = preview_window(path, max_edge)
+ .ok()
+ .flatten()
+ .and_then(|(header, window, read)| quick_image_srgb(&header, &window, read.adobe_rgb, max_edge))
+ {
return Some(image);
}
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
@@ -756,6 +773,9 @@ pub struct PreviewRead {
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
/// the header path, and on the whole-file fallback).
pub planned: bool,
+ /// Adobe RGB when the container says the preview is in it and the JPEG doesn't say otherwise
+ /// (`decode_raw_preview` applies the same declaration): the quick decode applies it too.
+ pub adobe_rgb: bool,
}
impl PreviewRead {
@@ -770,7 +790,9 @@ impl PreviewRead {
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
-/// which is the caller's cue to read the file whole.
+/// which is the caller's cue to read the file whole. `max_edge` is the size the preview is for: where a file keeps
+/// several JPEGs (the plan route), the window is the one [`embedded_preview_srgb`] decodes for that size, the smallest
+/// that covers it ([`lightcraft_raw::embedded_preview_covering`]).
///
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
@@ -778,7 +800,7 @@ impl PreviewRead {
/// still needs the whole container, so the header comes back with the window; for a preview taken
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
/// whole-file fallback stays in charge.
-pub fn preview_window(path: &str) -> Result, Vec, PreviewRead)>, String> {
+pub fn preview_window(path: &str, max_edge: usize) -> Result, Vec, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
@@ -800,7 +822,7 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
// sparse planner which ranges `embedded_preview` reads before handing the caller the
// whole-file fallback.
- return planned_preview(&mut file, path, total);
+ return planned_preview(&mut file, path, total, max_edge);
};
// A preview at the file's end may have been padded past it: clamp rather than fail.
let end = range.offset.saturating_add(range.window_len).min(total);
@@ -823,7 +845,7 @@ pub fn preview_window(path: &str) -> Result, Vec, PreviewRea
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
return Ok(None);
}
- let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
+ let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false, adobe_rgb: false };
// On a network share the bytes are the wait, so say what this preview actually cost. A window
// taken from the prefix cost nothing beyond the prefix itself.
if lightcraft_pipeline::profiling() {
@@ -865,7 +887,7 @@ const PLAN_PREFIX: u64 = 64 * 1024;
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
/// gives what the whole file gives), so a window that still fails to decode ends in the same
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
-fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result, Vec, PreviewRead)>, String> {
+fn planned_preview(file: &mut std::fs::File, path: &str, total: u64, max_edge: usize) -> Result, Vec, PreviewRead)>, String> {
use std::io::{Read, Seek, SeekFrom};
if total > MAX_PREVIEW_PLAN_BYTES {
@@ -894,19 +916,20 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result {
- let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
+ // the plan loads every preview's strips, so the JPEG picked for `max_edge` is the whole file's pick
+ let edge = u32::try_from(max_edge).unwrap_or(u32::MAX);
+ let Some(window) = lightcraft_raw::embedded_preview_covering(&buf, edge) else { return Ok(None) };
// The quick path hands the JPEG to the decoder alone, so what the *container* says
// about the preview has to be something the bytes it is handed can show. Two container
// facts change the pixels and are not in the JPEG: the preview's colour space (which
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
- // no orientation of its own, the file's EXIF orientation. Where either is present and
- // the header cannot answer for it, the whole-file path keeps the preview — exactly as
- // it already does for a container-declared colour space. A container that says sRGB
- // agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
- // space declines.
- if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
- return Ok(None);
- }
+ // no orientation of its own, the file's EXIF orientation. The colour space is read
+ // here and handed on ([`PreviewRead::adobe_rgb`]: a Sony α1 writes Adobe RGB previews,
+ // which used to send each of them to the whole-file path, issue #1440); a container
+ // that says sRGB agrees with the JPEG-only decode (its own declaration, else sRGB).
+ // Where the orientation is present and the header cannot answer for it, the
+ // whole-file path keeps the preview.
+ let adobe_rgb = lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb);
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
// first: a JPEG that carries its own orientation needs nothing from the container.
if lightcraft_meta::extract(&window).orientation.is_none()
@@ -915,7 +938,7 @@ fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result Option {
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
-/// `None` when the bytes are not a decodable image.
-fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option {
- let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
+/// `adobe_rgb`: the container declares the preview Adobe RGB ([`PreviewRead::adobe_rgb`]), applied
+/// as [`decode_raw_preview`] applies it. `None` when the bytes are not a decodable image.
+fn quick_image_srgb(header: &[u8], window: &[u8], adobe_rgb: bool, max_edge: usize) -> Option {
+ let decoded = if adobe_rgb && window.starts_with(&[0xff, 0xd8]) {
+ lightcraft_codecs::decode_jpeg_with_fallback(window, fit_box(max_edge), lightcraft_codecs::NamedSpace::AdobeRgb)
+ } else {
+ lightcraft_codecs::decode(window, fit_box(max_edge))
+ };
+ let mut d = decoded.ok()?;
if d.image.width.max(d.image.height) > max_edge {
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
d.alpha = None;
@@ -1186,7 +1215,7 @@ mod tests {
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
- let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
+ let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("it has a preview");
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
assert!(read.header_path, "the header path is what answered");
assert!(!read.planned, "a RAF pointer in the header needs no planner");
@@ -1195,7 +1224,7 @@ mod tests {
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
// The quick path and the whole-file path agree byte for byte.
- let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
+ let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
@@ -1203,8 +1232,8 @@ mod tests {
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
let plain = dir.join("plain.jpg");
std::fs::write(&plain, &jpeg).unwrap();
- assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
- assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
+ assert!(preview_window(&plain.to_string_lossy(), max_edge).expect("a plain file reads").is_none());
+ assert!(preview_window(&dir.join("nope.raf").to_string_lossy(), max_edge).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1224,6 +1253,12 @@ mod tests {
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec {
+ tiff_with_preview_strips(strip, &[], tail, what)
+ }
+
+ /// [`tiff_with_preview_strip`] that also keeps the `smaller` JPEGs in sub-IFDs of IFD0, as an ARW keeps its
+ /// 1616 px preview beside the full-size one.
+ fn tiff_with_preview_strips(strip: &[u8], smaller: &[&[u8]], tail: usize, what: Fixture) -> Vec {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
let mut preview = IfdBuilder::new();
if what.dng {
@@ -1250,6 +1285,15 @@ mod tests {
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
+ for jpeg in smaller {
+ let mut sub = IfdBuilder::new();
+ sub.set(t::COMPRESSION, Value::Short(vec![7]));
+ sub.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
+ sub.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
+ sub.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
+ sub.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![jpeg.to_vec()] });
+ preview.add_sub_ifd(sub);
+ }
let mut raw = IfdBuilder::new();
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
@@ -1277,7 +1321,7 @@ mod tests {
let path = path.to_string_lossy().into_owned();
let max_edge = 512;
- let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
+ let (header, window, read) = preview_window(&path, max_edge).expect("the file reads").expect("the plan locates its preview");
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
assert!(read.planned, "the planner is what answered");
assert!(read.header_path, "and it is still a bounded read");
@@ -1286,7 +1330,7 @@ mod tests {
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
// The plan path and the whole-file path agree byte for byte.
- let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
+ let quick = quick_image_srgb(&header, &window, read.adobe_rgb, max_edge).expect("the window holds a decodable preview");
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
assert_eq!(quick.data, whole.data, "byte for byte");
@@ -1295,7 +1339,39 @@ mod tests {
// caller's whole-file read is what answers (no guessing at what a file might hold).
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
std::fs::write(&path, ¬_a_raw).unwrap();
- assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
+ assert!(preview_window(&path, max_edge).expect("the file reads").is_none(), "an unknown container falls back");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// A raw that keeps several JPEGs is shown, on the plan route as on the whole-file path, from the smallest one that
+ /// covers the box (`embedded_preview_covering`). The plan route used to hand on the largest whatever the size: an
+ /// A7R VI ARW's 9984 × 6656 JPEG for a 512 px thumbnail instead of its 1616 × 1080 one.
+ #[test]
+ fn preview_loading_plans_the_smallest_covering_jpeg() {
+ let jpeg = |w: u32, h: u32| {
+ let rgb: Vec = (0..w * h * 3).map(|i| (i * 31 % 247) as u8).collect();
+ encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&rgb)), 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap()
+ };
+ let (big, small) = (jpeg(96, 64), jpeg(48, 32));
+ assert!(big.len() > small.len(), "the full-size JPEG is the largest");
+ let file = tiff_with_preview_strips(&big, &[&small], 400_000, Fixture { dng: true, ..Default::default() });
+ let dir = crate::tests_xmp::temp_dir("preview-plan-covering");
+ let path = dir.join("two-previews.dng");
+ std::fs::write(&path, &file).unwrap();
+ let path = path.to_string_lossy().into_owned();
+
+ for (edge, want, which) in [(48, &small, "small"), (49, &big, "full-size"), (usize::MAX, &big, "full-size")] {
+ let (header, window, read) = preview_window(&path, edge).expect("the file reads").expect("the plan locates its preview");
+ assert!(read.planned, "the planner is what answered");
+ assert!(&window == want, "a {edge} px box takes the {which} JPEG");
+ let quick = quick_image_srgb(&header, &window, read.adobe_rgb, edge).expect("the window holds a decodable preview");
+ let whole = embedded_preview_srgb(&file, edge).expect("the whole file decodes too");
+ assert_eq!(
+ (quick.width, quick.height, quick.data),
+ (whole.width, whole.height, whole.data),
+ "{edge} px: the plan and the whole file agree"
+ );
+ }
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1323,17 +1399,17 @@ mod tests {
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
- assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
+ assert!(preview_window(&path, usize::MAX).expect("the file reads").is_none(), "the whole-file path keeps it");
let _ = std::fs::remove_dir_all(&dir);
}
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
- /// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
- /// a container that declares Adobe RGB must keep the whole-file path — while one that declares
- /// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
- /// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
+ /// declaration (`decode_raw_preview`). The planned path reads it too and the quick decode applies
+ /// it, so a container that declares Adobe RGB (a Sony α1's ARW) is read in part like any other and
+ /// shows the same pixels as the whole-file path (issue #1440; it used to be read whole) — and one
+ /// that declares sRGB (what most bodies write) keeps the fast path as before.
#[test]
- fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
+ fn preview_loading_applies_a_container_colour_space() {
use lightcraft_raw::PreviewColorSpace;
let (w, h) = (48u32, 32u32);
let rgb: Vec = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
@@ -1346,13 +1422,20 @@ mod tests {
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
std::fs::write(&path, &adobe).unwrap();
- assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
+ let (header, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("the plan answers for Adobe RGB too");
+ assert!(read.planned && read.adobe_rgb, "{read:?}");
+ assert!(read.fraction() < 0.5, "the sensor strip is not read: {read:?}");
+ let quick = quick_image_srgb(&header, &window, read.adobe_rgb, 512).expect("the window decodes");
+ let whole = embedded_preview_srgb(&adobe, 512).expect("the whole file decodes too");
+ assert_eq!(quick.data, whole.data, "the space is applied as the whole-file path applies it");
+ let plain = quick_image_srgb(&header, &window, false, 512).expect("the window decodes");
+ assert_ne!(plain.data, whole.data, "and it changes the pixels (the fixture's colours are saturated enough)");
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
std::fs::write(&path, &srgb).unwrap();
- let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
- assert!(read.planned, "the plan still answers");
+ let (_, window, read) = preview_window(&path, usize::MAX).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
+ assert!(read.planned && !read.adobe_rgb, "the plan still answers");
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
}
@@ -2357,4 +2440,25 @@ mod tests {
assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// Issue #1448: the grid renders nothing behind an unedited raw's quick thumbnail, so when the
+ /// embedded preview can't be read (and nothing is cached) the quick job itself must render the
+ /// raw, or the cell stays empty for good ("no quick preview").
+ #[test]
+ fn a_quick_thumbnail_without_an_embedded_preview_renders_the_raw() {
+ use crate::media::QuickSource;
+ let dir = std::env::temp_dir().join(format!("lc-quick-none-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("a.cr3");
+ std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
+ let mut s = crate::Session::new().with_fs();
+ let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
+ let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
+ s.media.preview_loader = Some(std::sync::Arc::new(|_: &str, _| None));
+ let job = s.thumb_job(id, 128).unwrap();
+ let q = s.quick_thumb_job(&job).unwrap().run();
+ assert_eq!((q.quick, q.key), (Some(QuickSource::Small), job.key));
+ assert!(q.rendered.is_ok(), "{:?}", q.rendered.err());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs
index 1f008e7666..ecb80741e1 100644
--- a/crates/engine/src/import.rs
+++ b/crates/engine/src/import.rs
@@ -548,12 +548,62 @@ fn copy_into(dir: &Path, src: &str, name: Option<&str>, probe_hash: Option<&str>
pub struct ScanInput {
probe: Option,
skip: Option,
- /// path → (photo, its summary) for files already in the library.
- by_path: HashMap,
- by_hash: HashMap,
+ /// The library as it is now (photos are shared with the session, not copied): looked up for
+ /// the files the scan finds ([`library_files`]) and the contents it probes
+ /// ([`library_hashes`]), on the scan's thread.
+ library: std::sync::Arc,
cache: HashMap,
}
+/// The library's photos of `files`: path → (photo, its summary as a candidate that is there
+/// already). One pass over the library, and a record only for a file that was found: a folder of
+/// a hundred files in a library of millions costs a hundred records.
+fn library_files(library: &lightcraft_catalog::Catalog, files: &[String]) -> HashMap {
+ let found: std::collections::HashSet<&Path> = files.iter().map(Path::new).collect();
+ let mut by_path = HashMap::new();
+ if found.is_empty() {
+ return by_path;
+ }
+ for p in library.photos() {
+ if let Source::File { path } = &p.source
+ && found.contains(Path::new(path))
+ {
+ let c = ImportCandidate {
+ path: path.clone(),
+ format: p.format.clone(),
+ kind: p.kind,
+ width: p.width,
+ height: p.height,
+ file_size: p.file_size,
+ captured: p.captured.clone(),
+ duplicate: Some("path".into()),
+ existing: Some(p.id.0),
+ ..Default::default()
+ };
+ by_path.insert(PathBuf::from(path), (p.id, c));
+ }
+ }
+ by_path
+}
+
+/// Which photo of the library has each of the contents `hashes` (the later one where several
+/// have it). One pass over the library, and an entry only for a content that was asked about.
+fn library_hashes<'a>(library: &lightcraft_catalog::Catalog, hashes: impl Iterator- ) -> HashMap
{
+ let wanted: std::collections::HashSet<&str> = hashes.collect();
+ let mut by_hash = HashMap::new();
+ if wanted.is_empty() {
+ return by_hash;
+ }
+ for p in library.photos() {
+ if let Some(h) = &p.content_hash
+ && wanted.contains(h.as_str())
+ {
+ by_hash.insert(h.clone(), p.id);
+ }
+ }
+ by_hash
+}
+
/// Progress and cancellation of a running [`scan_with`].
#[derive(Default)]
pub struct ScanProgress {
@@ -573,31 +623,17 @@ pub struct ScanOutput {
impl ScanInput {
pub fn new(s: &mut Session, paths: &[String]) -> (Self, Vec) {
let skip = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone());
- let mut by_path = HashMap::new();
- let mut by_hash = HashMap::new();
- for p in s.catalog.photos() {
- if let Source::File { path } = &p.source {
- let c = ImportCandidate {
- path: path.clone(),
- format: p.format.clone(),
- kind: p.kind,
- width: p.width,
- height: p.height,
- file_size: p.file_size,
- captured: p.captured.clone(),
- duplicate: Some("path".into()),
- existing: Some(p.id.0),
- ..Default::default()
- };
- by_path.insert(PathBuf::from(path), (p.id, c));
- }
- if let Some(h) = &p.content_hash {
- by_hash.insert(h.clone(), p.id);
- }
- }
- let input = ScanInput { probe: s.media.file_probe.clone(), skip, by_path, by_hash, cache: std::mem::take(&mut s.import_probes) };
+ // (no table of the library here: this is the session's thread, and the library may hold
+ // millions of photos. The scan looks up what it finds)
+ let library = std::sync::Arc::new(s.catalog.clone());
+ let input = ScanInput { probe: s.media.file_probe.clone(), skip, library, cache: std::mem::take(&mut s.import_probes) };
(input, paths.to_vec())
}
+
+ /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one).
+ pub(crate) fn library(&self) -> std::sync::Arc {
+ std::sync::Arc::clone(&self.library)
+ }
}
/// Find and probe what importing `paths` would add, marking duplicates (by path, or by content
@@ -615,7 +651,8 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec {
pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput {
use std::sync::atomic::Ordering::Relaxed;
let files = expand(paths, input.skip.as_deref());
- let todo: Vec = files.iter().filter(|f| !input.by_path.contains_key(Path::new(f))).cloned().collect();
+ let by_path = library_files(&input.library, &files);
+ let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect();
progress.total.store(todo.len(), Relaxed);
// probes from a preceding `scan` are reused when the file is unchanged (same size)
let cached: Vec> = todo
@@ -633,11 +670,14 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
let mut probes: HashMap> = todo.into_iter().zip(probed).collect();
- let mut seen_hash: HashMap> = input.by_hash.into_iter().map(|(h, id)| (h, Some(id.0))).collect();
+ // (who in the library has a content that was probed: asked once the probes are in)
+ let probed_hashes = probes.values().filter_map(|r| r.as_ref().ok()).filter_map(|info| info.content_hash.as_deref());
+ let mut seen_hash: HashMap> =
+ library_hashes(&input.library, probed_hashes).into_iter().map(|(h, id)| (h, Some(id.0))).collect();
let mut out = Vec::with_capacity(files.len());
let mut kept = HashMap::new();
for f in files {
- if let Some((_, c)) = input.by_path.get(Path::new(&f)) {
+ if let Some((_, c)) = by_path.get(Path::new(&f)) {
let mut c = c.clone();
c.name = Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone());
out.push(c);
@@ -701,6 +741,12 @@ pub struct ImportJob {
mode: ImportMode,
lib_dir: Option,
copy_root: Option,
+ /// The library as it was when the import was set up, until it is looked up
+ /// ([`ImportJob::look_up_library`], with the first files): setting an import up is the
+ /// session's thread, and a table of a library of millions of photos takes seconds. With it,
+ /// whether a photo in Recently Deleted is passed over (an import that replaces such photos,
+ /// as the options said when the import was set up).
+ library: Option<(lightcraft_catalog::Catalog, bool)>,
/// path → (photo, a Local browse record?)
by_path: HashMap,
/// content hash → the photo that has it (`None`: a file earlier in this import)
@@ -842,28 +888,16 @@ impl ImportJob {
if matches!(mode, ImportMode::Copy | ImportMode::Move) && copy_root.is_none() {
return Err(crate::EngineError::Other("copying or moving needs a destination folder or an open library".into()));
}
- let mut by_path = HashMap::new();
- let mut by_hash = HashMap::new();
- for p in s.catalog.photos() {
- // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
- if p.deleted && opts.on_deleted == OnDeleted::Fresh {
- continue;
- }
- if let Source::File { path } = &p.source {
- by_path.insert(path.clone(), (p.id, p.local));
- }
- if let Some(h) = &p.content_hash {
- by_hash.insert(h.clone(), Some(p.id));
- }
- }
+ let fresh = opts.on_deleted == OnDeleted::Fresh;
Ok(ImportJob {
seq: opts.rename_start.max(1),
opts,
mode,
lib_dir,
copy_root,
- by_path,
- by_hash,
+ library: Some((s.catalog.clone(), fresh)),
+ by_path: HashMap::new(),
+ by_hash: HashMap::new(),
probe: s.media.file_probe.clone(),
file_bytes: s.media.file_bytes.clone(),
cache: std::mem::take(&mut s.import_probes),
@@ -898,9 +932,28 @@ impl ImportJob {
&self.now
}
+ /// What the library held when the import was set up, by path and by content: made once, with
+ /// the first files (on the thread that prepares them), from the library as it was then.
+ fn look_up_library(&mut self) {
+ let Some((library, fresh)) = self.library.take() else { return };
+ for p in library.photos() {
+ // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
+ if p.deleted && fresh {
+ continue;
+ }
+ if let Source::File { path } = &p.source {
+ self.by_path.insert(path.clone(), (p.id, p.local));
+ }
+ if let Some(h) = &p.content_hash {
+ self.by_hash.insert(h.clone(), Some(p.id));
+ }
+ }
+ }
+
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
use std::sync::atomic::Ordering::Relaxed;
+ self.look_up_library();
let opts = self.opts.clone();
let mode = self.mode;
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
@@ -1479,6 +1532,7 @@ impl Session {
stages: None,
view_cache: None,
display: None,
+ preview_cut: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
@@ -1494,6 +1548,10 @@ impl Session {
}
}
+#[cfg(test)]
+#[path = "import/tests_known.rs"]
+mod tests_known;
+
#[cfg(test)]
mod prepared_tests {
use super::*;
diff --git a/crates/engine/src/import/tests_known.rs b/crates/engine/src/import/tests_known.rs
new file mode 100644
index 0000000000..9adadca8a3
--- /dev/null
+++ b/crates/engine/src/import/tests_known.rs
@@ -0,0 +1,134 @@
+//! What a scan or an import knows of the library: worked out for the files it meets, and not on
+//! the session's thread. A library can hold millions of photos; looking at one folder must not
+//! cost a table of them all before the first file is read.
+use super::*;
+use lightcraft_catalog::{Catalog, Op};
+
+/// A library of `folders` folders of three photos each, the third a copy (by content) of a
+/// photo in the folder before.
+fn library(folders: usize) -> Catalog {
+ let mut c = Catalog::new();
+ for folder in 0..folders {
+ for (n, name) in ["a.jpg", "b.jpg", "c.jpg"].into_iter().enumerate() {
+ let id = c.alloc_photo_id();
+ let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "JPEG", 6, 4, "2026-01-01");
+ p.content_hash = Some(format!("{:032x}", if n == 2 { folder.saturating_sub(1) * 3 } else { folder * 3 + n }));
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ }
+ }
+ c
+}
+
+/// Given a library of many folders, when a scan meets the files of one of them (and one the
+/// library does not have), then what it knows of the library is those files' photos, each as the
+/// library has it, and nothing of the other folders.
+#[test]
+fn a_scan_knows_the_library_photos_of_the_files_it_found() {
+ let c = library(100);
+ let files: Vec = ["/pics/007/a.jpg", "/pics/007/b.jpg", "/pics/007/new.jpg", "/pics/007//c.jpg"].map(String::from).to_vec();
+ let known = library_files(&c, &files);
+ assert_eq!(known.len(), 3, "the three the library has (a path is compared as a path, not as text)");
+ let (id, as_found) = known.get(Path::new("/pics/007/b.jpg")).expect("b");
+ let p = c.photo(*id).unwrap();
+ assert_eq!((as_found.path.as_str(), as_found.existing, as_found.duplicate.as_deref()), ("/pics/007/b.jpg", Some(id.0), Some("path")));
+ assert_eq!((as_found.format.as_str(), as_found.width, as_found.height, as_found.file_size), (p.format.as_str(), p.width, p.height, p.file_size));
+ assert!(library_files(&c, &[]).is_empty());
+ // two photos of one file (a virtual copy): the later one is the one named, as a table of the
+ // whole library named it
+ let mut c = c;
+ let copy = c.alloc_photo_id();
+ c.apply(Op::AddPhoto { photo: Box::new(Photo::new(copy, Source::File { path: "/pics/007/a.jpg".into() }, "a.jpg", "JPEG", 6, 4, "2026-01-02")) })
+ .unwrap();
+ assert_eq!(library_files(&c, &files).get(Path::new("/pics/007/a.jpg")).map(|(id, _)| *id), Some(copy));
+}
+
+/// Given a library and a folder on disk that holds a file the library has by its path, one it
+/// has by its content under another name, and a new one, when the folder is scanned (the whole
+/// scan, as the import review runs it), then each is found for what it is.
+#[test]
+fn a_scan_finds_what_the_library_has_by_path_and_by_content() {
+ let dir = std::env::temp_dir().join(format!("lc-known-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let at = |name: &str| dir.join(name).to_string_lossy().to_string();
+ for name in ["here.jpg", "copy.jpg", "new.jpg"] {
+ std::fs::write(dir.join(name), name.as_bytes()).unwrap();
+ }
+ let mut s = Session::new();
+ s.catalog = library(20);
+ let here = s.catalog.alloc_photo_id();
+ s.catalog
+ .apply(Op::AddPhoto { photo: Box::new(Photo::new(here, Source::File { path: at("here.jpg") }, "here.jpg", "JPEG", 6, 4, "2026-01-01")) })
+ .unwrap();
+ // (the probe: every file is a picture whose content is its name; `copy.jpg` has the content of a library photo)
+ let had = s.catalog.photos().find(|p| p.file_name == "b.jpg").map(|p| (p.id, p.content_hash.clone().unwrap())).unwrap();
+ let shared = had.1.clone();
+ s.media.file_probe = Some(std::sync::Arc::new(move |path: &str| {
+ let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+ let content_hash = Some(if name == "copy.jpg" { shared.clone() } else { format!("hash-of-{name}") });
+ Ok(ProbeInfo { format: "JPEG".into(), width: 6, height: 4, file_size: 8, content_hash, ..Default::default() })
+ }));
+ let found = scan(&mut s, &[dir.to_string_lossy().to_string()]);
+ let of = |name: &str| found.iter().find(|c| c.name == name).map(|c| (c.duplicate.as_deref(), c.existing)).unwrap();
+ assert_eq!(of("here.jpg"), (Some("path"), Some(here.0)));
+ assert_eq!(of("copy.jpg"), (Some("content"), Some(had.0.0)));
+ assert_eq!(of("new.jpg"), (None, None));
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+/// Given the hashes of the files a scan probed, when the library is asked who has them, then it
+/// answers for those hashes only; and where two photos have one content, the same one of them
+/// as a table of the whole library would name (the later one).
+#[test]
+fn a_scan_knows_who_has_the_contents_it_probed() {
+ let c = library(100);
+ // (the content of /pics/006/a.jpg, which /pics/007/c.jpg has too; and one nobody has)
+ let shared = format!("{:032x}", 6 * 3);
+ let nobody = format!("{:032x}", 999_999);
+ let known = library_hashes(&c, [shared.as_str(), nobody.as_str()].into_iter());
+ assert_eq!(known.len(), 1);
+ let whole: HashMap = c.photos().filter_map(|p| p.content_hash.clone().map(|h| (h, p.id))).collect();
+ assert_eq!(known.get(&shared), whole.get(&shared));
+ assert!(matches!(&c.photo(known[&shared]).unwrap().source, Source::File { path } if path == "/pics/007/c.jpg"));
+}
+
+/// Given a session with a library, when an import is set up, then no table of the library is
+/// made on the spot (that is the session's thread); it is made when the first files are
+/// prepared, and then finds a file the library has by its path and one it has by its content.
+#[test]
+fn an_import_looks_the_library_up_when_it_prepares_files() {
+ let mut s = Session::new();
+ s.catalog = library(50);
+ let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap();
+ assert!(job.by_path.is_empty() && job.by_hash.is_empty() && job.library.is_some(), "nothing is looked up yet");
+ let out = job.prepare_files(vec!["/pics/007/a.jpg".into()], &std::sync::atomic::AtomicBool::new(false));
+ assert!(job.library.is_none(), "looked up once");
+ assert_eq!((job.by_path.len(), job.by_hash.len()), (150, 100));
+ assert!(matches!(out.items.as_slice(), [PreparedItem::Duplicate { reason: "path", existing: Some(_), .. }]), "found by its path");
+ // what the options said when the import was set up decides, whatever is made of them after
+ let id = s.catalog.photos().next().unwrap().id;
+ s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
+ let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted: OnDeleted::Fresh, ..ImportOptions::default() }).unwrap();
+ job.opts.on_deleted = OnDeleted::Skip;
+ job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
+ assert_eq!(job.by_path.len(), 149, "the trashed photo was passed over, as set up");
+}
+
+/// Given a photo in Recently Deleted and an import that replaces such photos with a fresh
+/// import, when the library is looked up, then that photo is no duplicate (as before the lookup
+/// moved to the first files).
+#[test]
+fn a_trashed_photo_is_not_looked_up_when_imports_replace_it() {
+ let mut s = Session::new();
+ s.catalog = library(2);
+ // (a photo whose content is its own: nobody else in this library has it)
+ let id = s.catalog.photos().find(|p| p.file_name == "b.jpg").unwrap().id;
+ s.catalog.apply(Op::SetDeleted { id, deleted: true }).unwrap();
+ for (on_deleted, expected) in [(OnDeleted::Fresh, 5), (OnDeleted::Skip, 6)] {
+ let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, on_deleted, ..ImportOptions::default() }).unwrap();
+ job.prepare_files(Vec::new(), &std::sync::atomic::AtomicBool::new(false));
+ assert_eq!(job.by_path.len(), expected, "{on_deleted:?}");
+ let its_content = s.catalog.photo(id).and_then(|p| p.content_hash.clone()).unwrap();
+ assert_eq!(job.by_hash.contains_key(&its_content), on_deleted == OnDeleted::Skip, "{on_deleted:?}");
+ }
+}
diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs
index db44213694..c92ee27962 100644
--- a/crates/engine/src/import_move.rs
+++ b/crates/engine/src/import_move.rs
@@ -223,11 +223,12 @@ pub(crate) fn rollback(p: &Placed) {
}
/// Remove a placed photo's source (its catalog record is committed and saved): only when the
-/// destination is still there with the same size. Then its sidecars, unless another photo still
+/// destination is still there with the same bytes. Then its sidecars, unless another photo still
/// uses a shared one. `Err` = the source was kept (why); `Ok` lists sidecars kept (path, why).
pub(crate) fn finish(p: &Placed) -> Result, String> {
+ // compare the bytes now: either file may have changed since the copy was verified
let same = match (fs::metadata(&p.src), fs::metadata(&p.dst)) {
- (Ok(s), Ok(d)) => s.is_file() && d.is_file() && s.len() == d.len(),
+ (Ok(s), Ok(d)) => s.is_file() && d.is_file() && same_bytes(&p.src, &p.dst).unwrap_or(false),
_ => false,
};
if !same {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 04cc513568..7e05fcffd1 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -29,8 +29,10 @@ pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
+mod errors;
pub mod export;
pub mod face_download;
+pub mod face_tiles;
#[cfg(not(target_arch = "wasm32"))]
mod faces_index;
#[cfg(not(target_arch = "wasm32"))]
@@ -68,6 +70,7 @@ pub mod walk;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
+pub use errors::{AiModel, ErrorKind};
pub use fonts::{CRAFT_FONTS, CraftFont};
use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
@@ -98,6 +101,13 @@ pub enum EngineError {
/// to import with `library.importLightroom`, when one was found.
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
LightroomCatalog { path: std::path::PathBuf, catalog: Option },
+ /// The work was cancelled (by the person, an agent or a newer request): not a failure.
+ #[error("{0}")]
+ Cancelled(String),
+ /// The feature needs `model`, which is not installed (or still downloading): a frontend
+ /// offers its download (terms first) on [`ErrorKind::ModelMissing`], not on the message.
+ #[error("{msg}")]
+ ModelMissing { model: AiModel, msg: String },
#[error("{0}")]
Other(String),
}
@@ -222,7 +232,8 @@ pub struct Session {
pub previous_active: Option,
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec,
- pub presets: Vec,
+ /// Built-in then user presets; its generation tells savers and caches when it changed.
+ pub presets: presets::PresetList,
/// Favourite profile ids (persisted with the library, like preset favourites).
pub profile_favorites: Vec,
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
@@ -355,7 +366,7 @@ impl Session {
library_folder: None,
previous_active: None,
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
- presets: presets::builtin(),
+ presets: presets::PresetList::new(presets::builtin()),
profile_favorites: Vec::new(),
profile_recent: Vec::new(),
journal: Vec::new(),
@@ -1000,6 +1011,9 @@ mod tests_face_models;
#[cfg(not(target_arch = "wasm32"))]
mod tests_face_recognize;
#[cfg(test)]
+#[cfg(not(target_arch = "wasm32"))]
+mod tests_face_tiles;
+#[cfg(test)]
mod tests_filesystem_history;
#[cfg(test)]
mod tests_folders;
@@ -1028,6 +1042,8 @@ mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
+mod tests_presets_scale;
+#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_segment;
diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs
index 2bb5511c2d..0305623f2a 100644
--- a/crates/engine/src/library.rs
+++ b/crates/engine/src/library.rs
@@ -88,7 +88,8 @@ pub struct Library {
retry_at: Option,
/// What forgetting untouched Local records did when the library opened.
pub forgot_local: Option,
- presets_written: String,
+ /// What presets.json holds (see [`PresetsSaved`]).
+ presets_saved: PresetsSaved,
view_written: Vec,
/// Settings files that were unreadable or damaged when the library opened (`library.info` →
/// `settingsWarnings`; shown by the UI once, see [`Session::take_library_warnings`]).
@@ -123,6 +124,53 @@ struct PresetsFile {
profile_recent: Vec,
}
+/// The state presets.json was last written (or read) from. Saving checks the preset list's
+/// generation and the small profile lists before serializing anything: with a 10 000-preset
+/// collection presets.json is ~40 MB, and serializing it after every command made each slider
+/// tick take seconds.
+#[derive(Default)]
+struct PresetsSaved {
+ generation: u64,
+ profile_favorites: Vec,
+ profile_recent: Vec,
+ /// Hash of the bytes written: a list borrowed mutably but left as it was isn't rewritten.
+ hash: u64,
+}
+
+impl PresetsSaved {
+ fn state_of(s: &Session) -> PresetsSaved {
+ PresetsSaved {
+ generation: s.presets.generation(),
+ profile_favorites: s.profile_favorites.clone(),
+ profile_recent: s.profile_recent.clone(),
+ hash: 0,
+ }
+ }
+
+ /// `s` changed its presets or profile lists since this state.
+ fn differs(&self, s: &Session) -> bool {
+ self.generation != s.presets.generation() || self.profile_favorites != s.profile_favorites || self.profile_recent != s.profile_recent
+ }
+}
+
+fn bytes_hash(b: &[u8]) -> u64 {
+ use std::hash::{Hash, Hasher};
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ b.hash(&mut h);
+ h.finish()
+}
+
+#[cfg(test)]
+thread_local! {
+ static PRESETS_SERIALIZED: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// How many times this thread serialized the preset list (tests).
+#[cfg(test)]
+pub(crate) fn presets_serialized() -> usize {
+ PRESETS_SERIALIZED.with(|c| c.get())
+}
+
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct ViewFile {
@@ -187,6 +235,8 @@ struct PrefsFile {
}
fn presets_json(s: &Session) -> String {
+ #[cfg(test)]
+ PRESETS_SERIALIZED.with(|c| c.set(c.get() + 1));
let presets = &s.presets;
let f = PresetsFile {
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
@@ -414,8 +464,9 @@ impl Session {
for p in &mut self.presets {
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
}
+ let mut ids: std::collections::HashSet = self.presets.iter().map(|p| p.id.clone()).collect();
for u in f.user {
- if !self.presets.iter().any(|p| p.id == u.id) {
+ if ids.insert(u.id.clone()) {
self.presets.push(u);
}
}
@@ -447,7 +498,8 @@ impl Session {
{
self.selection = Selection::single(*first);
}
- let presets_written = presets_json(self);
+ // presets.json is as read: saved again only after the list changes (hash 0 = unknown bytes)
+ let presets_saved = PresetsSaved::state_of(self);
// photo ids are per library: drop decoded sources of the previous one
self.media.clear_sources();
if on_disk {
@@ -464,7 +516,7 @@ impl Session {
unsaved_error: None,
retry_at: None,
forgot_local: None,
- presets_written,
+ presets_saved,
view_written,
settings_warnings: unlocked_warning(lock.as_ref()).into_iter().chain(settings.warnings).collect(),
warnings_reported: 0,
@@ -526,14 +578,21 @@ impl Session {
log::error!("library: compaction: {e}");
lib.last_error = Some(format!("compaction: {e}"));
}
+ let due = self.library.as_ref().is_some_and(|l| l.presets_saved.differs(self) && !l.blocked.contains(&"presets.json"));
+ if !due {
+ return Ok(());
+ }
let presets = presets_json(self);
+ let mut state = PresetsSaved::state_of(self);
+ state.hash = bytes_hash(presets.as_bytes());
let Some(lib) = self.library.as_mut() else { return Ok(()) };
- if presets != lib.presets_written && !lib.blocked.contains(&"presets.json") {
- if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
- log::error!("library: presets: {e}");
- } else {
- lib.presets_written = presets;
- }
+ if state.hash == lib.presets_saved.hash {
+ lib.presets_saved = state;
+ } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
+ // the state stays stale: the next save tries again
+ log::error!("library: presets: {e}");
+ } else {
+ lib.presets_saved = state;
}
Ok(())
}
diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs
index ffd6fc183b..5e7cb02d4d 100644
--- a/crates/engine/src/lightroom_job.rs
+++ b/crates/engine/src/lightroom_job.rs
@@ -24,6 +24,12 @@ fn error(message: impl Into) -> crate::EngineError {
crate::EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
}
+/// The import was cancelled ([`crate::ErrorKind::Cancelled`]: not a failure). The message is the
+/// one it always had, so control-channel and MCP clients see the same `error` text.
+fn cancelled() -> crate::EngineError {
+ crate::EngineError::Cancelled(error("Lightroom import cancelled").to_string())
+}
+
fn stage_archive(archive_dir: Option<&Path>, data: &mut CatalogImport) -> crate::Result> {
let archive_path = archive_dir.map(|dir| crate::lightroom_archive::store_best_effort(dir, data).map_err(error)).transpose()?.flatten();
if archive_dir.is_some() && archive_path.is_none() {
@@ -232,13 +238,14 @@ impl LightroomJob {
/// Read/probe/stage all source data off the owner thread.
pub fn prepare(&mut self, cancel: &AtomicBool) -> crate::Result {
if cancel.load(Ordering::Relaxed) {
- return Err(error("Lightroom import cancelled"));
+ return Err(cancelled());
}
- let mut data =
- lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?;
+ // (reading stops with a message when `cancel` is set: the flag says it was cancelled)
+ let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done)
+ .map_err(|e| if cancel.load(Ordering::Relaxed) { cancelled() } else { error(e) })?;
lightroom_catalog::validate_for_job(&data).map_err(error)?;
if cancel.load(Ordering::Relaxed) {
- return Err(error("Lightroom import cancelled"));
+ return Err(cancelled());
}
let paths: Vec = data
.photos
@@ -259,7 +266,7 @@ impl LightroomJob {
let normal = self.import.prepare(&paths, cancel);
if cancel.load(Ordering::Relaxed) {
normal.rollback();
- return Err(error("Lightroom import cancelled"));
+ return Err(cancelled());
}
let archive_path = stage_archive(self.archive_dir.as_deref(), &mut data)?;
let index_path = self.archive_dir.as_ref().map(|dir| dir.join("lightroom-index.json"));
@@ -282,7 +289,7 @@ impl LightroomJob {
pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) -> crate::Result {
if prepared.cancelled {
prepared.normal.rollback();
- return Err(error("Lightroom import cancelled"));
+ return Err(cancelled());
}
if !prepared.token.matches(s) {
prepared.normal.rollback();
@@ -435,11 +442,26 @@ mod tests {
let mut session = crate::Session::new();
let before = session.catalog.to_snapshot();
let prepared = cancelled_prepared(&session);
- assert!(commit_prepared(&mut session, prepared).is_err());
+ let Err(e) = commit_prepared(&mut session, prepared) else { panic!("a cancelled import commits nothing") };
+ assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
assert_eq!(session.catalog.to_snapshot(), before);
assert!(session.undo.is_empty());
}
+ /// Cancelling while the catalog is read is [`crate::ErrorKind::Cancelled`] whatever the message
+ /// says, and a failure that is not a cancellation is not one, even when its text says "cancelled".
+ #[test]
+ fn a_cancelled_read_is_cancelled_by_kind() {
+ let mut session = crate::Session::new();
+ let mut job = LightroomJob::new(&mut session, PathBuf::from("/nonexistent/cancelled.lrcat"), false).unwrap();
+ let Err(e) = job.prepare(&AtomicBool::new(true)) else { panic!("cancelled before it started") };
+ assert_eq!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
+ // the `error` text clients see is unchanged
+ assert_eq!(e.to_string(), "invalid parameters for `library.importLightroom`: Lightroom import cancelled");
+ let Err(e) = job.prepare(&AtomicBool::new(false)) else { panic!("no such catalog") };
+ assert_ne!(e.kind(), crate::ErrorKind::Cancelled, "{e}");
+ }
+
#[test]
fn library_identity_rejects_late_completion_after_reopen() {
let mut session = crate::Session::new();
diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs
index 900fe4a268..803707c1cf 100644
--- a/crates/engine/src/lightroom_sqlite.rs
+++ b/crates/engine/src/lightroom_sqlite.rs
@@ -395,8 +395,10 @@ fn validate_header_fields(header: &[u8], page_size: usize) -> Result usize {
+ edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge())
+}
+
pub fn thumb_bucket(long_edge: usize) -> usize {
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
}
@@ -741,6 +752,9 @@ pub struct RenderJob {
pub view_cache: Option<(Arc, Hash128)>,
/// Render for this monitor profile ([`Self::with_display`]).
pub display: Option>,
+ /// A face tile that can be cut from the raw's embedded preview, tried before `source`
+ /// ([`crate::Session::face_job`]; `None` for every other job).
+ pub preview_cut: Option,
}
pub struct RenderResult {
@@ -820,6 +834,7 @@ impl RenderJob {
self.settings = Arc::new(s);
self.cache = None;
self.view_cache = None;
+ self.preview_cut = None;
self
}
@@ -887,6 +902,26 @@ impl RenderJob {
display: None,
};
}
+ // a face tile of an unedited raw: cut from the camera's embedded preview when it is big enough
+ // (the sensor data is never read for it), else rendered from `source` below
+ if let Some(image) = self.preview_cut.as_ref().and_then(|c| c.cut()) {
+ if let Some((cache, key)) = &self.cache {
+ cache.put_at(self.cache_generation, *key, Arc::new(image.clone()));
+ }
+ let histogram = Histogram::of_srgb8(&image);
+ return RenderResult {
+ request_id: self.request_id,
+ source_key: self.source_key,
+ photo: self.photo,
+ level: self.level,
+ key: self.key,
+ rendered: Ok(Rendered { image, histogram, deep: None }),
+ gpu_fallback: None,
+ loaded: None,
+ quick: None,
+ display: None,
+ };
+ }
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
// The app is closing (issue #620): nobody is left to show this, and quitting waits for the
// jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU.
@@ -1192,6 +1227,7 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
+ preview_cut: None,
})
}
@@ -1243,13 +1279,19 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
+ preview_cut: None,
})
}
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
- /// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
- /// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
+ /// at least `edge` pixels across (rounded up to [`FACE_EDGE_STEP`], ≤ 512, so a tile size
+ /// between two steps reuses the cached one). An unedited raw's tile is cut from the camera's
+ /// embedded preview when that holds the square at [`crate::face_tiles::SOFT_LIMIT`] of the
+ /// size asked or more ([`crate::face_tiles`]); else, and for every other photo, the source
+ /// level is the smallest that keeps the face sharp, up to the preview. Cached (memory and the
+ /// library's thumbnail cache on disk) like [`Self::variant_job`], and `key` likewise follows
+ /// the content: a later launch shows the tile without reading the original.
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option {
let p = self.catalog.photo(id)?.clone();
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
@@ -1267,12 +1309,13 @@ impl crate::Session {
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
let mut settings = (*p.develop).clone();
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
- let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
+ let edge = face_edge(edge);
// pixels along the long edge that leave `edge` across the crop
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
let level = SourceLevel::for_size(needed as usize);
let source = self.media.source_ref(&p, level);
let ck = Self::variant_key(&p, &settings, edge);
+ let preview_cut = self.face_preview_cut(&p, rect, edge, needed as usize);
Some(RenderJob {
request_id: 0,
cache_generation: self.media.rendered.generation(),
@@ -1289,9 +1332,26 @@ impl crate::Session {
stages: None,
view_cache: None,
display: None,
+ preview_cut,
})
}
+ /// How a face tile of `p` can come from its embedded preview ([`crate::face_tiles`]): only an
+ /// unedited raw (whose grid thumbnail is that preview too) not known to be offline, upright as
+ /// imported, with a preview loader installed. `rect` is the square around the face, `edge` the
+ /// tile, `needed` the source's long edge the develop render would ask for.
+ fn face_preview_cut(&self, p: &Photo, rect: lightcraft_geom::Rect, edge: usize, needed: usize) -> Option {
+ let (path, loader) = self.embedded_of(p)?;
+ if p.develop.orientation != lightcraft_geom::Orientation::Normal || self.media.availability.is_offline(&path) || p.width == 0 || p.height == 0
+ {
+ return None;
+ }
+ let want = needed.max(edge).max(1);
+ let min = (want as f64 * crate::face_tiles::SOFT_LIMIT).ceil() as usize;
+ let aspect = f64::from(p.width) / f64::from(p.height);
+ Some(crate::face_tiles::PreviewCut { path, loader, want, min, rect, aspect, edge })
+ }
+
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
Hasher128::new()
@@ -1427,8 +1487,10 @@ impl crate::Session {
/// A first grid thumbnail for an unedited raw: the cached render for `job` (the real
/// thumbnail job, from [`Self::thumb_job`]) if there is one — then the result carries
- /// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview. `None` for
- /// other photos (render them directly).
+ /// [`QuickSource::Cached`] and `job.key` and is final — else its embedded preview, else `job`
+ /// itself: the grid queues no render behind an unedited raw's quick thumbnail
+ /// ([`Self::embedded_thumb_is_final`]), so a preview that can't be read must not leave the
+ /// cell empty (issue #1448). `None` for other photos (render them directly).
pub fn quick_thumb_job(&mut self, job: &RenderJob) -> Option {
let p = self.catalog.photo(job.photo)?.clone();
let (path, l) = self.embedded_of(&p)?;
@@ -1439,7 +1501,7 @@ impl crate::Session {
key: job.key,
cached: job.cache.clone().into_iter().collect(),
embedded: Some((path, l, edge)),
- small: None,
+ small: Some(Box::new(job.clone())),
})
}
diff --git a/crates/engine/src/memory.rs b/crates/engine/src/memory.rs
index 8c421668d5..49094a1f3c 100644
--- a/crates/engine/src/memory.rs
+++ b/crates/engine/src/memory.rs
@@ -34,7 +34,7 @@ pub fn default_budget() -> usize {
}
/// Physical memory (bytes), when the platform tells us without native calls.
-fn total_ram() -> Option {
+pub(crate) fn total_ram() -> Option {
#[cfg(target_os = "linux")]
{
let s = std::fs::read_to_string("/proc/meminfo").ok()?;
diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs
index 6a51dc316f..ef9fd00f9f 100644
--- a/crates/engine/src/merge.rs
+++ b/crates/engine/src/merge.rs
@@ -130,11 +130,14 @@ impl MergeJob {
return Err("cancelled".into());
}
let sub = |a: f32, b: f32| move |f: f32, s: &str| progress(a + (b - a) * f, s);
+ // the reference frame's lens corrections (HDR); panoramas carry none
+ let mut lens = Vec::new();
let (image, color, orientation, metadata, baseline, samples, crop, mut info, ghost) = match &self.kind {
MergeKind::Hdr { hdr } => {
let frames = self.load(false, progress, (0.0, 0.3))?;
let p = sub(0.3, 0.9);
- let r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
+ let mut r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
+ lens = std::mem::take(&mut r.lens);
let info = json!({
"reference": self.sources[r.reference].0.0,
"ev": r.ev, "evExif": r.ev_exif,
@@ -183,6 +186,7 @@ impl MergeJob {
orientation: r.orientation,
metadata: r.metadata,
baseline_exposure: r.baseline_exposure,
+ lens: Vec::new(),
};
f.orient();
merged.push(f);
@@ -214,7 +218,7 @@ impl MergeJob {
metadata.caption = Some(format!("Merged from {}", names.join(", ")));
}
metadata.software = Some("LightCraft Photo Merge".into());
- let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?;
+ let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples, &lens).map_err(err)?;
info["width"] = json!(image.width);
info["height"] = json!(image.height);
let preview = match self.preview {
diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs
index 044139ff90..3b36316fb5 100644
--- a/crates/engine/src/preset_import.rs
+++ b/crates/engine/src/preset_import.rs
@@ -28,28 +28,45 @@ pub const PRESET_EXTS: &[&str] = &["xmp", "lrtemplate", "zip", "dng", "lmp", "mp
// ------------------------------------------------------------------------------------- zip
+/// The most a preset zip entry may hold unpacked (a "DNG preset" photo is the largest kind, tens
+/// of MB): its stated size is the file's word, so a small archive could otherwise ask for 8 GB.
+pub const MAX_ZIP_ENTRY: usize = 256 << 20;
+/// The most a whole preset zip may hold unpacked.
+pub const MAX_ZIP_TOTAL: usize = 1 << 30;
+
/// The files of a zip archive: (path inside the archive, bytes). Stored and deflated entries;
-/// directories, macOS resource forks and hidden files are skipped.
+/// directories, macOS resource forks and hidden files are skipped. An entry that unpacks past
+/// [`MAX_ZIP_ENTRY`], or an archive past [`MAX_ZIP_TOTAL`], is an error (a zip bomb would make
+/// the allocation fail, which ends the app).
pub fn read_zip(bytes: &[u8]) -> Result)>, String> {
- let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
- let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
+ read_zip_limited(bytes, MAX_ZIP_ENTRY, MAX_ZIP_TOTAL)
+}
+
+fn read_zip_limited(bytes: &[u8], max_entry: usize, max_total: usize) -> Result)>, String> {
+ let u16_at = |o: usize| bytes.get(o..o.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
+ let u32_at = |o: usize| bytes.get(o..o.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
+ // offsets from the file, added up without overflowing (a 32-bit build reads them too)
+ let add = |a: usize, b: usize| a.checked_add(b).ok_or("bad zip");
// end of central directory: the last "PK\x05\x06" within the trailing 64 KiB + 22 bytes
let tail = bytes.len().saturating_sub(65_557);
let eocd = (tail..bytes.len().saturating_sub(21)).rev().find(|&i| bytes[i..].starts_with(b"PK\x05\x06")).ok_or("not a zip file")?;
let count = u16_at(eocd + 10).ok_or("bad zip")?;
let mut at = u32_at(eocd + 16).ok_or("bad zip")?;
let mut out = Vec::new();
+ let mut total = 0usize;
for _ in 0..count {
if !bytes.get(at..).is_some_and(|b| b.starts_with(b"PK\x01\x02")) {
return Err("bad zip central directory".into());
}
- let method = u16_at(at + 10).ok_or("bad zip")?;
- let csize = u32_at(at + 20).ok_or("bad zip")?;
- let usize_ = u32_at(at + 24).ok_or("bad zip")?;
- let (nlen, xlen, clen) = (u16_at(at + 28).ok_or("bad zip")?, u16_at(at + 30).ok_or("bad zip")?, u16_at(at + 32).ok_or("bad zip")?);
- let local = u32_at(at + 42).ok_or("bad zip")?;
- let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + nlen).ok_or("bad zip")?).replace('\\', "/");
- at += 46 + nlen + xlen + clen;
+ let method = u16_at(add(at, 10)?).ok_or("bad zip")?;
+ let csize = u32_at(add(at, 20)?).ok_or("bad zip")?;
+ let usize_ = u32_at(add(at, 24)?).ok_or("bad zip")?;
+ let (nlen, xlen, clen) =
+ (u16_at(add(at, 28)?).ok_or("bad zip")?, u16_at(add(at, 30)?).ok_or("bad zip")?, u16_at(add(at, 32)?).ok_or("bad zip")?);
+ let local = u32_at(add(at, 42)?).ok_or("bad zip")?;
+ let name_at = add(at, 46)?;
+ let name = String::from_utf8_lossy(bytes.get(name_at..add(name_at, nlen)?).ok_or("bad zip")?).replace('\\', "/");
+ at = add(add(add(name_at, nlen)?, xlen)?, clen)?;
let skip = name.ends_with('/') || name.split('/').any(|p| p.starts_with('.') || p == "__MACOSX");
if skip {
continue;
@@ -57,13 +74,20 @@ pub fn read_zip(bytes: &[u8]) -> Result)>, String> {
if !bytes.get(local..).is_some_and(|b| b.starts_with(b"PK\x03\x04")) {
return Err(format!("{name}: bad local header"));
}
- let data_at = local + 30 + u16_at(local + 26).ok_or("bad zip")? + u16_at(local + 28).ok_or("bad zip")?;
- let data = bytes.get(data_at..data_at + csize).ok_or_else(|| format!("{name}: truncated"))?;
+ let data_at = add(add(add(local, 30)?, u16_at(add(local, 26)?).ok_or("bad zip")?)?, u16_at(add(local, 28)?).ok_or("bad zip")?)?;
+ let data = bytes.get(data_at..add(data_at, csize)?).ok_or_else(|| format!("{name}: truncated"))?;
+ let left = max_total.saturating_sub(total);
+ let limit = max_entry.min(left);
+ let too_big = || format!("{name}: unpacks to more than {} MB, too much for a preset", limit >> 20);
let content = match method {
+ 0 if data.len() > limit => return Err(too_big()),
0 => data.to_vec(),
- 8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1) * 2 + 1024).map_err(|e| format!("{name}: {e:?}"))?,
+ 8 => miniz_oxide::inflate::decompress_to_vec_with_limit(data, usize_.max(1).saturating_mul(2).saturating_add(1024).min(limit)).map_err(
+ |e| if matches!(e.status, miniz_oxide::inflate::TINFLStatus::HasMoreOutput) { too_big() } else { format!("{name}: {e:?}") },
+ )?,
m => return Err(format!("{name}: unsupported compression method {m}")),
};
+ total = total.saturating_add(content.len());
out.push((name, content));
}
Ok(out)
@@ -695,6 +719,33 @@ text]], z = ZSTR "loc" }"#,
assert!(read_presets("plain.jpg", b"\xff\xd8 no xmp", None).is_err());
}
+ #[test]
+ fn zip_bombs_are_refused() {
+ // an entry unpacking past the limit (3 MB of zeros pack into a few kB): refused, named
+ let zeros = vec![0u8; 3 << 20];
+ let z = zip(&[("Pack/huge.xmp", &zeros)], true);
+ assert!(z.len() < 64 << 10, "a small archive: {} bytes", z.len());
+ let e = read_zip_limited(&z, 1 << 20, 1 << 30).unwrap_err();
+ assert!(e.contains("Pack/huge.xmp") && e.contains("more than 1 MB"), "{e}");
+ // the whole archive counts too
+ let two = zip(&[("a.xmp", &zeros[..1 << 20]), ("b.xmp", &zeros[..1 << 20])], true);
+ assert!(read_zip_limited(&two, 2 << 20, (3 << 20) / 2).is_err());
+ assert_eq!(read_zip_limited(&two, 2 << 20, 3 << 20).unwrap().len(), 2);
+ // and stored entries
+ let stored = zip(&[("s.xmp", &zeros[..2 << 20])], false);
+ assert!(read_zip_limited(&stored, 1 << 20, 1 << 30).is_err());
+ // the size an entry states (here 4 GB) no longer sets how much may be unpacked
+ let mut lying = zip(&[("a.xmp", b" ")], true);
+ let cd = lying.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
+ lying[cd + 24..cd + 28].copy_from_slice(&u32::MAX.to_le_bytes());
+ assert_eq!(read_zip(&lying).unwrap()[0].1, b" ");
+ // offsets near the top of a u32 are an error, not an overflow
+ let mut far = zip(&[("a.xmp", b"x")], false);
+ let cd = far.windows(4).position(|w| w == b"PK\x01\x02").unwrap();
+ far[cd + 42..cd + 46].copy_from_slice(&u32::MAX.to_le_bytes());
+ assert!(read_zip(&far).is_err());
+ }
+
#[test]
fn zip_bundles_with_folders_as_groups() {
for deflate in [false, true] {
diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs
index 8c36aa7429..c3675e2275 100644
--- a/crates/engine/src/presets.rs
+++ b/crates/engine/src/presets.rs
@@ -23,6 +23,73 @@ pub struct PresetFile {
pub presets: Vec,
}
+/// The session's presets (built-in, then the user's) with a generation that changes on every
+/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is
+/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON.
+///
+/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`,
+/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed.
+/// Generations are unique across all lists in the process.
+#[derive(Clone, Debug)]
+pub struct PresetList {
+ items: Vec,
+ generation: u64,
+}
+
+static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
+
+fn next_generation() -> u64 {
+ PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+}
+
+impl PresetList {
+ pub fn new(items: Vec) -> PresetList {
+ PresetList { items, generation: next_generation() }
+ }
+
+ /// Changes whenever the list may have changed.
+ pub fn generation(&self) -> u64 {
+ self.generation
+ }
+}
+
+impl Default for PresetList {
+ fn default() -> PresetList {
+ PresetList::new(Vec::new())
+ }
+}
+
+impl std::ops::Deref for PresetList {
+ type Target = Vec;
+ fn deref(&self) -> &Vec {
+ &self.items
+ }
+}
+
+impl std::ops::DerefMut for PresetList {
+ fn deref_mut(&mut self) -> &mut Vec {
+ self.generation = next_generation();
+ &mut self.items
+ }
+}
+
+impl<'a> IntoIterator for &'a PresetList {
+ type Item = &'a Preset;
+ type IntoIter = std::slice::Iter<'a, Preset>;
+ fn into_iter(self) -> Self::IntoIter {
+ self.items.iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a mut PresetList {
+ type Item = &'a mut Preset;
+ type IntoIter = std::slice::IterMut<'a, Preset>;
+ fn into_iter(self) -> Self::IntoIter {
+ self.generation = next_generation();
+ self.items.iter_mut()
+ }
+}
+
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
pub fn slug(s: &str) -> String {
let mut out = String::new();
@@ -107,22 +174,33 @@ impl Session {
/// Add imported presets. Identical presets (same name, group and settings) already present are
/// skipped; id clashes get a fresh id. Returns the ids added.
pub fn add_presets(&mut self, presets: Vec) -> Vec {
+ use std::collections::{HashMap, HashSet};
+ // indexed: a collection of 10 000+ presets was quadratic (minutes) to import
+ let mut ids: HashSet = self.presets.iter().map(|q| q.id.clone()).collect();
+ let mut by_name: HashMap<(String, String), Vec> = HashMap::new();
+ for (i, q) in self.presets.iter().enumerate() {
+ by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i);
+ }
let mut added = Vec::new();
for mut p in presets {
- if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
+ let key = (p.name.clone(), p.group.clone());
+ let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings)));
+ if same {
continue;
}
- if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
+ if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) {
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
let mut id = base.clone();
let mut n = 2;
- while self.presets.iter().any(|q| q.id == id) {
+ while ids.contains(&id) {
id = format!("{base}-{n}");
n += 1;
}
p.id = id;
}
p.builtin = false;
+ ids.insert(p.id.clone());
+ by_name.entry(key).or_default().push(self.presets.len());
added.push(p.id.clone());
self.presets.push(p);
}
diff --git a/crates/engine/src/segment/download.rs b/crates/engine/src/segment/download.rs
index 7147db09d0..57ba4f341f 100644
--- a/crates/engine/src/segment/download.rs
+++ b/crates/engine/src/segment/download.rs
@@ -20,6 +20,8 @@ pub struct Status {
pub file: String,
/// The last download's error (cleared when a new one starts).
pub error: Option,
+ /// The last download was cancelled (its `error` says so in words): decide on this, not on the text.
+ pub cancelled: bool,
/// The last download finished and the files are in place.
pub finished: bool,
}
@@ -28,6 +30,7 @@ pub struct Status {
struct Text {
file: String,
error: Option,
+ cancelled: bool,
}
#[derive(Default)]
@@ -68,15 +71,17 @@ impl Downloader {
{
last.file.clone_from(&t.file);
last.error.clone_from(&t.error);
+ last.cancelled = t.cancelled;
}
// (only the session's thread locks `last`)
- let (file, error) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone())).unwrap_or_default();
+ let (file, error, cancelled) = self.last.try_lock().map(|l| (l.file.clone(), l.error.clone(), l.cancelled)).unwrap_or_default();
Status {
running: s.running.load(Ordering::SeqCst),
done: s.done.load(Ordering::SeqCst),
total: s.total.load(Ordering::SeqCst),
file,
error,
+ cancelled,
finished: s.finished.load(Ordering::SeqCst),
}
}
@@ -135,6 +140,7 @@ impl Downloader {
};
let r =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));
+ let cancelled = matches!(r, Ok(Err(fetch::DownloadError::Cancelled)));
let error = match r {
Ok(Ok(())) => {
s.finished.store(true, Ordering::SeqCst);
@@ -147,7 +153,10 @@ impl Downloader {
if let Some(e) = &error {
log::warn!("SAM 3 download: {e}");
}
- lock(&s.text).error = error;
+ let mut text = lock(&s.text);
+ text.cancelled = cancelled;
+ text.error = error;
+ drop(text);
drop(guard);
});
match spawned {
diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs
index ec2c4ba853..c955dcc01d 100644
--- a/crates/engine/src/segment/mod.rs
+++ b/crates/engine/src/segment/mod.rs
@@ -35,7 +35,7 @@ pub const MODEL_BYTES: u64 = 3_439_938_512;
/// The model's licence (not LightCraft's): shown before downloading.
pub const LICENSE_NAME: &str = "SAM License (Meta)";
pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE";
-/// How errors about a missing model start (the UI offers the download on it).
+/// How errors about a missing model start (frontends decide on [`crate::ErrorKind::ModelMissing`], not on this text).
pub const NOT_INSTALLED: &str = "The SAM 3 model is not installed";
/// Longest a waiting command (CLI, MCP) waits for the model (loading on a CPU can take minutes).
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
@@ -111,6 +111,8 @@ pub struct DownloadStatus {
pub total: u64,
pub file: String,
pub error: Option,
+ /// The last download was cancelled (it resumes next time): decide on this, not on `error`'s text.
+ pub cancelled: bool,
pub finished: bool,
}
@@ -215,7 +217,15 @@ impl Segmenter {
#[cfg(feature = "sam")]
{
let s = self.download.status();
- DownloadStatus { running: s.running, done: s.done, total: s.total, file: s.file, error: s.error, finished: s.finished }
+ DownloadStatus {
+ running: s.running,
+ done: s.done,
+ total: s.total,
+ file: s.file,
+ error: s.error,
+ cancelled: s.cancelled,
+ finished: s.finished,
+ }
}
#[cfg(not(feature = "sam"))]
DownloadStatus::default()
@@ -234,27 +244,36 @@ impl Segmenter {
/// The model folder, if the model is there; otherwise an error that says what to do.
pub fn model_dir(&self) -> Result {
+ self.require_model().map_err(|e| e.to_string())
+ }
+
+ /// [`Segmenter::model_dir`] for commands: a model that is not installed (or still downloading) is
+ /// [`crate::EngineError::ModelMissing`], so a frontend offers the download without reading the message.
+ pub fn require_model(&self) -> crate::Result {
+ let other = |why: &str| crate::EngineError::Other(why.into());
if !Self::AVAILABLE {
- return Err("AI masks are not available in this build".into());
+ return Err(other("AI masks are not available in this build"));
}
#[cfg(feature = "sam")]
if lightcraft_segment::remote::configured().is_some() {
return Ok(PathBuf::new());
}
- let dir = self.dir.clone().ok_or("no folder is set for the SAM 3 model")?;
+ let dir = self.dir.clone().ok_or_else(|| other("no folder is set for the SAM 3 model"))?;
if self.installed() {
return Ok(dir);
}
let d = self.download_status();
- if d.running {
+ let msg = if d.running {
let pct = d.done.saturating_mul(100).checked_div(d.total).unwrap_or(0);
- return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %)."));
- }
- Err(format!(
- "{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
- MODEL_BYTES as f64 / 1e9,
- dir.display()
- ))
+ format!("{NOT_INSTALLED} yet: it is downloading ({pct} %).")
+ } else {
+ format!(
+ "{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.",
+ MODEL_BYTES as f64 / 1e9,
+ dir.display()
+ )
+ };
+ Err(crate::EngineError::ModelMissing { model: crate::AiModel::Sam3, msg })
}
/// Start downloading the model (on a background thread; see `segment.model.download`).
diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs
index 9aaf7e937a..c67a2c84f5 100644
--- a/crates/engine/src/sidecar.rs
+++ b/crates/engine/src/sidecar.rs
@@ -450,6 +450,33 @@ fn stem_key(original: &str) -> String {
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
}
+/// Where a plain path is cut to make its [`stem_key`]: at the dot of its extension, which is
+/// what `with_extension` replaces. A plain path is ASCII and ends in its file's name with an
+/// extension, which is what a library's paths are; `None` for any other shape.
+fn plain_cut(original: &str) -> Option {
+ let path = Path::new(original);
+ let (name, stem, ext) = (path.file_name()?.to_str()?, path.file_stem()?.to_str()?, path.extension()?.to_str()?);
+ // (the path ends in its file's name, as it stands: no `/.` or separator after it; and the
+ // name has a stem and an extension of its own, which `..jpg` and `a.` have not)
+ let plain = original.is_ascii() && original.ends_with(name) && !ext.is_empty() && !stem.is_empty() && stem != "." && stem != "..";
+ let cut = original.len().checked_sub(ext.len().saturating_add(1))?;
+ (plain && original.as_bytes().get(cut) == Some(&b'.')).then_some(cut)
+}
+
+/// [`stem_key`] written into `key`, which is reused: nothing is allocated for a plain path
+/// ([`plain_cut`]). Any other shape goes the long way.
+fn stem_key_into(key: &mut String, original: &str) {
+ key.clear();
+ match plain_cut(original) {
+ Some(cut) => {
+ key.push_str(original.get(..cut).unwrap_or_default());
+ key.make_ascii_lowercase();
+ key.push_str(".xmp");
+ }
+ None => key.push_str(&stem_key(original)),
+ }
+}
+
fn file_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } => Some(path),
@@ -490,8 +517,11 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec) {
pub(crate) struct StemOwners(HashMap);
impl StemOwners {
+ /// The owners of every shared stem in the library, each file's key made the long way: what
+ /// [`StemOwners::among`] must agree with for the photos it is asked about (this is how the
+ /// owners were worked out before, for every write).
+ #[cfg(test)]
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
- // per stem: (raw first, then file name, then id) of the best candidate; and how many
let mut best: HashMap = HashMap::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
let Some(path) = file_path(p) else { continue };
@@ -505,6 +535,39 @@ impl StemOwners {
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
}
+ /// The owners of the stems photos `ids` map to: all that naming those photos needs. The
+ /// library is passed over once, and only files of those stems are looked at closer, so a
+ /// sidecar written for one photo does not cost a table of the whole library.
+ pub(crate) fn among(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> StemOwners {
+ let wanted: std::collections::HashSet =
+ ids.iter().filter_map(|id| cat.photo(*id)).filter_map(|p| file_path(p)).map(stem_key).collect();
+ if wanted.is_empty() {
+ return StemOwners(HashMap::new());
+ }
+ Self::where_wanted(cat, &wanted)
+ }
+
+ /// The owners of the shared stems in `wanted`.
+ fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet) -> StemOwners {
+ // per stem: (raw first, then file name, then id) of the best candidate; and how many
+ let mut best: HashMap = HashMap::new();
+ let mut key = String::new();
+ for p in cat.photos().filter(|p| p.copy_of.is_none()) {
+ let Some(path) = file_path(p) else { continue };
+ stem_key_into(&mut key, path);
+ if !wanted.contains(key.as_str()) {
+ continue;
+ }
+ let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
+ let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
+ e.2 += 1;
+ if rank < e.0 {
+ (e.0, e.1) = (rank, p.id);
+ }
+ }
+ StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
+ }
+
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
match (naming, file_path(p)) {
@@ -514,33 +577,39 @@ impl StemOwners {
}
}
+/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
+/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
+pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec {
+ let owners = (naming == SidecarNaming::Stem).then(|| StemOwners::among(cat, ids));
+ ids.iter()
+ .map(|id| match (cat.photo(*id), &owners) {
+ (Some(p), Some(o)) => o.naming(p, naming),
+ _ => naming,
+ })
+ .collect()
+}
+
impl Session {
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
/// sidecar — a raw first, else the first by file name. The others use Full naming
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
- /// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once
- /// (per photo that is a pass over the whole catalog).
+ /// [`Session::sidecar_naming`] for many photos, their stems' owners worked out once (per
+ /// photo that is a pass over the whole catalog).
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec {
- let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog));
- ids.iter()
- .map(|id| match (self.catalog.photo(*id), &owners) {
- (Some(p), Some(o)) => o.naming(p, self.xmp.naming),
- _ => self.xmp.naming,
- })
- .collect()
+ namings_in(&self.catalog, self.xmp.naming, ids)
}
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
match self.catalog.photo(id) {
- Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming),
+ Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::among(&self.catalog, &[id]).naming(p, self.xmp.naming),
_ => self.xmp.naming,
}
}
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
pub fn save_sidecar(&mut self, id: PhotoId) -> Result {
- self.save_sidecar_with(id, &StemOwners::of(&self.catalog))
+ self.save_sidecar_with(id, &StemOwners::among(&self.catalog, &[id]))
}
pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result {
@@ -642,7 +711,7 @@ impl Session {
if ids.is_empty() {
return;
}
- let owners = StemOwners::of(&self.catalog);
+ let owners = StemOwners::among(&self.catalog, &ids);
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none())
&& let Err(e) = self.save_sidecar_with(id, &owners)
@@ -764,3 +833,7 @@ mod tests_repair_companions;
#[cfg(test)]
#[path = "sidecar/tests_repair_provenance.rs"]
mod tests_repair_provenance;
+
+#[cfg(test)]
+#[path = "sidecar/tests_stem_owners.rs"]
+mod tests_stem_owners;
diff --git a/crates/engine/src/sidecar/tests_stem_owners.rs b/crates/engine/src/sidecar/tests_stem_owners.rs
new file mode 100644
index 0000000000..43170f4f68
--- /dev/null
+++ b/crates/engine/src/sidecar/tests_stem_owners.rs
@@ -0,0 +1,163 @@
+//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
+//!
+//! A library can hold millions of photos; a sidecar that is written for one of them, or a folder
+//! that is synchronized, must not cost a table of them all.
+use super::*;
+use lightcraft_catalog::{Catalog, Op, Photo, Source};
+
+/// Paths that try the key: case in folders, names and endings, no ending, a dot file, endings
+/// inside folder names, non-ASCII letters (one that lower-cases to ASCII: the Kelvin sign).
+const PATHS: [&str; 26] = [
+ "/pics/trip/IMG_1.CR3",
+ "/pics/trip/IMG_1.JPG",
+ "/pics/trip/img_1.jpeg",
+ "/Pics/Trip/IMG_1.tif",
+ "/pics/trip/IMG_2.JPG",
+ "/pics/trip/IMG_3.CR3",
+ "/pics/trip/IMG_3.cr3.JPG",
+ "/pics/trip/IMG_3.cr3",
+ "/pics/other/IMG_1.JPG",
+ "/pics/other/IMG_1",
+ "/pics/other/.hidden",
+ "/pics/other/.hidden.jpg",
+ "/pics/v1.2/a.b.JPG",
+ "/pics/v1.2/a.b.cr3",
+ "/pics/v1.2/a",
+ "/pics/straße/Größe.JPG",
+ "/pics/STRASSE/GRÖSSE.cr3",
+ "/pics/straße/größe.CR3",
+ "/pics/kelvin/\u{212a}1.JPG",
+ "/pics/kelvin/k1.cr3",
+ "/pics/ΟΔΟΣ/ΟΔΟΣ.JPG",
+ "/pics/οδος/οδος.CR3",
+ // (names that are all dots before their ending: the ending is no extension to replace)
+ "/pics/dots/..cr3",
+ "/pics/dots/..jpg",
+ "/pics/dots/a./.",
+ "/pics/dots/a.jpg",
+];
+
+fn library() -> (Catalog, Vec) {
+ let mut c = Catalog::new();
+ let mut ids = Vec::new();
+ for path in PATHS {
+ let id = c.alloc_photo_id();
+ let name = path.rsplit('/').next().unwrap_or(path);
+ let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01");
+ if path.to_lowercase().ends_with("cr3") {
+ p.kind = MediaKind::Raw;
+ }
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ ids.push(id);
+ }
+ // a virtual copy of the first (it owns nothing) and a photo that is no file
+ let copy = c.alloc_photo_id();
+ let mut p = Photo::new(copy, Source::File { path: PATHS[0].to_string() }, "IMG_1.CR3", "X", 6, 4, "2026-01-01");
+ p.copy_of = Some(ids[0]);
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ ids.push(copy);
+ let demo = c.alloc_photo_id();
+ c.apply(Op::AddPhoto { photo: Box::new(Photo::new(demo, Source::Demo { scene: 1 }, "demo", "X", 6, 4, "2026-01-01")) }).unwrap();
+ ids.push(demo);
+ (c, ids)
+}
+
+fn namings(owners: &StemOwners, c: &Catalog, ids: &[PhotoId]) -> Vec {
+ ids.iter().map(|id| owners.naming(c.photo(*id).unwrap(), SidecarNaming::Stem)).collect()
+}
+
+/// Given a library where files share stems in every way a name can, when the owners are worked
+/// out for some of its photos only, then each of those photos is named exactly as the table of
+/// the whole library names it: whichever photos are asked about, alone or together.
+#[test]
+fn owners_among_some_photos_name_them_as_the_whole_library_does() {
+ let (c, ids) = library();
+ let whole = StemOwners::of(&c);
+ let expected = namings(&whole, &c, &ids);
+ assert!(expected.contains(&SidecarNaming::Full) && expected.contains(&SidecarNaming::Stem), "both cases are there: {expected:?}");
+ // all at once
+ assert_eq!(namings(&StemOwners::among(&c, &ids), &c, &ids), expected);
+ // one at a time, and every pair
+ for (i, a) in ids.iter().enumerate() {
+ assert_eq!(namings(&StemOwners::among(&c, &[*a]), &c, &[*a]), [expected[i]], "{:?}", PATHS.get(i));
+ for (j, b) in ids.iter().enumerate() {
+ assert_eq!(namings(&StemOwners::among(&c, &[*a, *b]), &c, &[*a, *b]), [expected[i], expected[j]]);
+ }
+ }
+ // an id that is not in the library is passed over
+ assert_eq!(StemOwners::among(&c, &[PhotoId(9_999)]).0.len(), 0);
+}
+
+/// Given a library of many folders, when the owners are worked out for the photos of one, then
+/// the table holds that folder's shared stems and nothing of the others: what it costs follows
+/// the photos asked about, not the library.
+#[test]
+fn owners_among_some_photos_hold_nothing_of_the_rest() {
+ let mut c = Catalog::new();
+ let mut first = Vec::new();
+ for folder in 0..200 {
+ for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] {
+ let id = c.alloc_photo_id();
+ let mut p = Photo::new(id, Source::File { path: format!("/pics/{folder:03}/{name}") }, name, "X", 6, 4, "2026-01-01");
+ if name.ends_with("CR3") {
+ p.kind = MediaKind::Raw;
+ }
+ c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ if folder == 0 {
+ first.push(id);
+ }
+ }
+ }
+ assert_eq!(StemOwners::of(&c).0.len(), 200, "one shared stem a folder");
+ let some = StemOwners::among(&c, &first);
+ assert_eq!(some.0.len(), 1, "the one shared stem of the folder asked about");
+ assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
+}
+
+/// The key written into a buffer (no allocation for a plain path) is the key: for every path
+/// above, for shapes a path from a file can have, and for every string of up to six of the
+/// characters that decide a key (a letter in each case, a dot, both separators, a letter that is
+/// not ASCII).
+#[test]
+fn the_key_in_a_buffer_is_the_key() {
+ let odd = [
+ "",
+ "/",
+ "a",
+ "a.",
+ ".a",
+ "..a",
+ "...a",
+ "a.b",
+ "a/b.c/",
+ "a/b.c//",
+ "a/.b",
+ "a/..b",
+ "a/b.",
+ "a/b..c",
+ "a./.",
+ "a\\b.c",
+ "C:\\x\\y.JPG",
+ "a/b.c/.",
+ "a/b.c/..",
+ "x.JPG ",
+ "É.JPG",
+ ];
+ let mut buf = String::new();
+ for path in PATHS.iter().chain(&odd) {
+ stem_key_into(&mut buf, path);
+ assert_eq!(buf, stem_key(path), "{path:?}");
+ }
+ let letters = ['a', 'B', '.', '/', '\\', 'é'];
+ let mut tried = 0usize;
+ let mut todo: Vec = vec![String::new()];
+ while let Some(path) = todo.pop() {
+ stem_key_into(&mut buf, &path);
+ assert_eq!(buf, stem_key(&path), "{path:?}");
+ tried += 1;
+ if path.chars().count() < 6 {
+ todo.extend(letters.iter().map(|c| format!("{path}{c}")));
+ }
+ }
+ assert_eq!(tried, (0..=6).map(|n| 6usize.pow(n)).sum::());
+}
diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs
index 3628f188ce..46b18e0c9d 100644
--- a/crates/engine/src/smart.rs
+++ b/crates/engine/src/smart.rs
@@ -55,6 +55,28 @@ const HEADER_MAX: u64 = 4096;
/// wrote the proxy's pixels (colour matrix and profile are baked into them) and stored tone curve.
const LOOK_FIELD: &str = "look_version";
+/// The JPEG quality a smart preview's pixels are stored at. The original 92 left the 8×8 DCT's
+/// quantised DC — each block's own mean, luma and chroma — visible as block steps across the frame
+/// and as a small constant colour shift; that is the largest part of what a proxy gets wrong
+/// against the original, and the only part a parameter can reach. 97 shrinks those quantisers to
+/// roughly 1/2.6 of their steps, which is what removes the steps, at about twice the file size
+/// (~1 MB per preview at 2560 px). What is left is the 8-bit sRGB quantisation itself, which only
+/// a lossless proxy format can close. (ICED: the measured change is printed by the fidelity test
+/// below; drop this to 95 for ~1.4× the size and roughly half the gain.)
+const JPEG_QUALITY: u8 = 97;
+
+/// The header field that records which version of this module's own encoding wrote a proxy's pixels
+/// (the source mapping above and [`JPEG_QUALITY`]). Not to be confused with [`LOOK_FIELD`], which
+/// tracks the camera-look fit that is baked into those pixels.
+const ENC_FIELD: &str = "enc";
+
+/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG
+/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which
+/// makes every proxy from before this version stale. Version 2 is the first to record the field at
+/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]).
+/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal.
+pub(crate) const ENCODE_VERSION: u64 = 2;
+
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String> {
// scale so all but the brightest 0.05 % fit into 0..1
@@ -78,13 +100,19 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String
let rgba = Rgba8 { width: img.width, height: img.height, data };
let jpg = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&rgba),
- 92,
+ JPEG_QUALITY,
lightcraft_codecs::ChromaSubsampling::S444,
&Default::default(),
)
.map_err(|e| e.to_string())?;
let mut out = MAGIC.to_vec();
- let mut head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
+ let mut head = serde_json::json!({
+ "w": img.width,
+ "h": img.height,
+ "scale": scale,
+ LOOK_FIELD: crate::camera_preview::LOOK_VERSION,
+ ENC_FIELD: ENCODE_VERSION,
+ });
if let Some(t) = tone {
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
}
@@ -265,10 +293,17 @@ fn header_look_version(head: &serde_json::Value) -> u64 {
head.get(LOOK_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
}
-/// Whether the complete proxy at `path` was written by an older camera-look fit than this build's
-/// (so its stored curve, and the colour baked into its pixels, are out of date). A proxy from a
-/// newer build is left alone (rebuilding it here would only undo that build's work), as is one
-/// that can't be read: those are [`is_valid`]'s business.
+/// The encode version a header records: a missing, negative, fractional or otherwise malformed
+/// value counts as 0 (older than any real version), like [`header_look_version`].
+fn header_encode_version(head: &serde_json::Value) -> u64 {
+ head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
+}
+
+/// Whether the complete proxy at `path` is out of date for this build: written by an older
+/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are
+/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG
+/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that
+/// build's work), as is one that can't be read: those are [`is_valid`]'s business.
pub fn is_stale(path: &Path) -> bool {
use std::io::Read;
if !is_valid(path) {
@@ -283,15 +318,18 @@ pub fn is_stale(path: &Path) -> bool {
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
rest.get(..nl)
.and_then(|h| serde_json::from_slice::(h).ok())
- .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION))
+ .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION)
}
-/// The marker file in a smart previews folder: the `LOOK_VERSION` its proxies were last checked
-/// at ([`look_checked`]).
+/// The marker file in a smart previews folder: the camera-look fit version and encode version its
+/// proxies were last checked at ([`look_checked`]).
const LOOK_MARKER: &str = "look-version";
-/// Whether the smart previews folder `dir` has been checked for stale proxies at this build's
-/// camera-look fit version (or a newer one). A missing or unreadable marker counts as not.
+/// Whether the smart previews folder `dir` has been checked for out-of-date proxies at this build's
+/// camera-look fit version and encode version (or newer ones). A missing or unreadable marker counts
+/// as not, and so does one written before the encode version was recorded — it only says the look
+/// was checked, so this build scans the folder once more and rebuilds the proxies [`is_stale`]
+/// finds.
pub fn look_checked(dir: &Path) -> bool {
use std::io::Read;
let Ok(f) = std::fs::File::open(dir.join(LOOK_MARKER)) else { return false };
@@ -299,14 +337,20 @@ pub fn look_checked(dir: &Path) -> bool {
if f.take(64).read_to_string(&mut text).is_err() {
return false;
}
- text.trim().parse::().is_ok_and(|v| v >= u64::from(crate::camera_preview::LOOK_VERSION))
+ let mut versions = text.split_whitespace();
+ let (Some(look), Some(enc)) = (versions.next(), versions.next()) else { return false };
+ match (look.parse::(), enc.parse::()) {
+ (Ok(look), Ok(enc)) => look >= u64::from(crate::camera_preview::LOOK_VERSION) && enc >= ENCODE_VERSION,
+ _ => false,
+ }
}
-/// Record that the proxies in `dir` were checked at this build's camera-look fit version.
+/// Record that the proxies in `dir` were checked at this build's camera-look fit version and encode
+/// version.
pub fn mark_look_checked(dir: &Path) -> Result<(), String> {
let path = dir.join(LOOK_MARKER);
- lightcraft_catalog::safe_file::write_atomic(&path, format!("{}\n", crate::camera_preview::LOOK_VERSION).as_bytes())
- .map_err(|e| format!("{}: {e}", path.display()))
+ let text = format!("{} {}\n", crate::camera_preview::LOOK_VERSION, ENCODE_VERSION);
+ lightcraft_catalog::safe_file::write_atomic(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
}
/// Load the proxy at `path`.
@@ -451,4 +495,186 @@ mod tests {
assert!(mean < 0.02, "mean encoded error {mean}");
assert!(decode(b"nope").is_err());
}
+
+ /// A smooth, deterministic stand-in for a photo: a bright, near-neutral "sky" crossing over into
+ /// a saturated colour field, every value inside 0..1 so the proxy's 0.05 % highlight clip never
+ /// enters what is measured. Built from a rendered demo scene so the dimensions and the flat
+ /// row-major layout are the real ones.
+ fn smooth_scene(w: usize, h: usize) -> Rgb32f {
+ let mut img = lightcraft_scenes::demo_library()[0].render(w, h);
+ let (w, h) = (img.width.max(1), img.height.max(1));
+ for (i, px) in img.data.iter_mut().enumerate() {
+ let u = (i % w) as f32 / (w - 1).max(1) as f32;
+ let v = (i / w) as f32 / (h - 1).max(1) as f32;
+ let t = (v * v * (3.0 - 2.0 * v)).clamp(0.0, 1.0); // smoothstep: a fade, not a step
+ let sky = [0.10 + 0.70 * v, 0.14 + 0.72 * v, 0.30 + 0.62 * v];
+ let field = [0.86 - 0.26 * u, 0.18 + 0.62 * u, 0.12 + 0.30 * (1.0 - u)];
+ *px = std::array::from_fn(|k| (sky[k] * (1.0 - t) + field[k] * t).clamp(0.0, 1.0));
+ }
+ img
+ }
+
+ /// CIE L*a*b* (D65) of a scene-linear colour: the perceptual yardstick a proxy is held to. The
+ /// input is clamped into 0..1, the only range a proxy ever stores.
+ fn lab(c: [f32; 3]) -> [f64; 3] {
+ let (r, g, b) = (c[0].clamp(0.0, 1.0) as f64, c[1].clamp(0.0, 1.0) as f64, c[2].clamp(0.0, 1.0) as f64);
+ let (x, y, z) = (
+ (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047,
+ 0.2126 * r + 0.7152 * g + 0.0722 * b,
+ (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883,
+ );
+ let f = |t: f64| if t > 216.0 / 24389.0 { t.cbrt() } else { (841.0 / 108.0) * t + 4.0 / 29.0 };
+ let (fx, fy, fz) = (f(x), f(y), f(z));
+ [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
+ }
+
+ /// The mean CIE76 ΔE between two same-sized scene-linear images: monotone in CIEDE2000 for a
+ /// low-contrast, near-neutral difference like a proxy's, and enough to rank two encoders.
+ fn mean_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
+ let n = a.data.len().min(b.data.len());
+ if n == 0 {
+ return 0.0;
+ }
+ (0..n)
+ .map(|i| {
+ let (p, q) = (lab(a.data[i]), lab(b.data[i]));
+ ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
+ })
+ .sum::()
+ / n as f64
+ }
+
+ /// A deliberately frozen copy of the pixel mapping and container this module used before the
+ /// quality raise: the 99.95 % highlight scale, the sRGB 8-bit mapping, JPEG quality 92, and the
+ /// version-1 header (width, height, scale and camera-look fit — no encode version). A copy on
+ /// purpose: it must not follow `encode` as that changes, so the fidelity test measures what the
+ /// quality raise bought instead of trusting a written-down number.
+ fn encode_at_q92(img: &Rgb32f) -> Vec {
+ let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
+ let scale = if lum.is_empty() {
+ 1.0
+ } else {
+ let k = ((lum.len() as f64) * 0.9995) as usize;
+ let k = k.min(lum.len() - 1);
+ let (_, v, _) = lum.select_nth_unstable_by(k, f32::total_cmp);
+ v.max(1.0)
+ };
+ let data: Vec<[u8; 4]> = img
+ .data
+ .iter()
+ .map(|c| {
+ let e = |v: f32| (linear_to_srgb((v / scale).clamp(0.0, 1.0)) * 255.0).round() as u8;
+ [e(c[0]), e(c[1]), e(c[2]), 255]
+ })
+ .collect();
+ let rgba = Rgba8 { width: img.width, height: img.height, data };
+ let jpg = lightcraft_codecs::encode_jpeg(
+ &lightcraft_codecs::EncodeImage::rgba8(&rgba),
+ 92,
+ lightcraft_codecs::ChromaSubsampling::S444,
+ &Default::default(),
+ )
+ .unwrap();
+ // the version-1 container the decoder needs: magic, header, JPEG — and no `enc` field
+ let mut out = MAGIC.to_vec();
+ let head = serde_json::json!({"w": img.width, "h": img.height, "scale": scale, LOOK_FIELD: crate::camera_preview::LOOK_VERSION});
+ out.extend_from_slice(head.to_string().as_bytes());
+ out.push(b'\n');
+ out.extend_from_slice(&jpg);
+ out
+ }
+
+ /// The mean CIE76 ΔE between two same-sized scene-linear images, each reduced first to one
+ /// sample per 8×8 block — the block's mean colour. JPEG stores a block's DC coefficient, its
+ /// mean, and rebuilds the whole block from it, so a proxy differs from the original by a
+ /// constant per block: the "8×8 block pattern" a preview shows. Averaging a block's 64 pixels
+ /// keeps that constant while shrinking the 8-bit sRGB rounding the two encoders share
+ /// eightfold, which is what a per-pixel ΔE drowns in.
+ fn mean_block_delta_e(a: &Rgb32f, b: &Rgb32f) -> f64 {
+ let w = a.width.min(b.width).max(1);
+ let h = a.height.min(b.height).max(1);
+ let (mut sum, mut n) = (0.0f64, 0usize);
+ for by in (0..h).step_by(8) {
+ for bx in (0..w).step_by(8) {
+ let (mut sa, mut sb, mut count) = ([0.0f64; 3], [0.0f64; 3], 0u32);
+ for y in by..(by + 8).min(h) {
+ for x in bx..(bx + 8).min(w) {
+ if let (Some(ca), Some(cb)) = (a.data.get(y * a.width + x), b.data.get(y * b.width + x)) {
+ for k in 0..3 {
+ sa[k] += f64::from(ca[k]);
+ sb[k] += f64::from(cb[k]);
+ }
+ count += 1;
+ }
+ }
+ }
+ let d = f64::from(count.max(1));
+ let (pa, pb) = (
+ lab([(sa[0] / d) as f32, (sa[1] / d) as f32, (sa[2] / d) as f32]),
+ lab([(sb[0] / d) as f32, (sb[1] / d) as f32, (sb[2] / d) as f32]),
+ );
+ sum += ((pa[0] - pb[0]).powi(2) + (pa[1] - pb[1]).powi(2) + (pa[2] - pb[2]).powi(2)).sqrt();
+ n += 1;
+ }
+ }
+ sum / n.max(1) as f64
+ }
+
+ /// for [`a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality`] to pass. The shared 8-bit
+ /// sRGB quantization floor both encoders share caps the achievable ratio at 0.632 on this scene,
+ /// a no-op encoder scores 1.000, and this is their geometric midpoint: it fails the moment the
+ /// quality raise is reverted and never trips on the honest result (measured 0.683).
+ const PROXY_BLOCK_GAIN: f64 = 0.8;
+
+ #[test]
+ fn a_smooth_proxy_keeps_the_scene_closer_than_the_old_quality() {
+ let img = smooth_scene(256, 160);
+ let ours = decode(&encode(&img, None).unwrap()).unwrap().0;
+ let before = decode(&encode_at_q92(&img)).unwrap().0;
+ let block = (mean_block_delta_e(&img, &ours), mean_block_delta_e(&img, &before));
+ let pixel = (mean_delta_e(&img, &ours), mean_delta_e(&img, &before));
+ println!(
+ "smooth-scene proxy round-trip: block-mean ΔE76 {:.3} at q{JPEG_QUALITY} vs {:.3} at q92 \
+ (per-pixel {:.3} vs {:.3})",
+ block.0, block.1, pixel.0, pixel.1
+ );
+ assert!(block.1 > 0.0, "the old encoder must measure something to compare against");
+ assert!(
+ block.0 <= PROXY_BLOCK_GAIN * block.1,
+ "q{JPEG_QUALITY} leaves a block-mean ΔE of {:.3}, not the ≤{:.3} the quality raise should buy over q92's {:.3} \
+ (the 8-bit sRGB floor caps the achievable ratio at ~0.63 on this scene)",
+ block.0,
+ PROXY_BLOCK_GAIN * block.1,
+ block.1
+ );
+ }
+
+ #[test]
+ fn a_proxy_from_an_older_encode_is_stale() {
+ let dir = temp("encode-version");
+ let img = lightcraft_scenes::demo_library()[0].render(64, 40);
+ let bytes = encode(&img, None).unwrap();
+ let path = dir.join("p.lcsp");
+ std::fs::write(&path, &bytes).unwrap();
+ assert!(!is_stale(&path), "this build's own proxy is up to date");
+ // one written before the encode version was recorded is not
+ let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
+ let head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
+ assert_eq!(head[ENC_FIELD].as_u64(), Some(ENCODE_VERSION));
+ let mut older = head.as_object().cloned().unwrap_or_default();
+ older.remove(ENC_FIELD);
+ let mut old = MAGIC.to_vec();
+ old.extend_from_slice(serde_json::Value::Object(older).to_string().as_bytes());
+ old.extend_from_slice(&bytes[nl..]);
+ std::fs::write(&path, &old).unwrap();
+ assert!(is_valid(&path), "losing the field keeps the file readable");
+ assert!(is_stale(&path), "an older encoding is rebuilt");
+ // the marker records both versions, and one from before the encode version forces a scan
+ assert!(!look_checked(&dir));
+ mark_look_checked(&dir).unwrap();
+ assert!(look_checked(&dir));
+ std::fs::write(dir.join(LOOK_MARKER), "99\n").unwrap();
+ assert!(!look_checked(&dir), "a look-only marker is not enough to skip the rebuild");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs
index 0a00a59abd..4342ec6ad9 100644
--- a/crates/engine/src/sync.rs
+++ b/crates/engine/src/sync.rs
@@ -74,10 +74,14 @@ impl SyncProgress {
pub struct SyncInput {
folder: String,
scan: ScanInput,
- /// The library's photos in the folder, each with how its sidecar is named.
- photos: Vec<(Arc, SidecarNaming)>,
- /// For the colour label names a sidecar may use.
- labels: Catalog,
+ /// The library's photos in the folder.
+ ids: Vec,
+ /// How the library names sidecars; how each photo's is named is worked out by the scan (a
+ /// pass over the library, which the session's thread does not wait for).
+ naming: SidecarNaming,
+ /// The library as it is now (the one the scan looks files up in): for those photos, their
+ /// sidecars' names and the colour label names a sidecar may use.
+ labels: Arc,
}
/// What changed in a folder since it came into the library.
@@ -176,10 +180,9 @@ impl SyncInput {
if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) {
return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it")));
}
- let namings = s.sidecar_namings(&ids);
- let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
let (scan, _) = ScanInput::new(s, &[path.to_string()]);
- Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() })
+ let labels = scan.library();
+ Ok(SyncInput { folder: path.to_string(), scan, ids, naming: s.xmp.naming, labels })
}
}
@@ -190,11 +193,16 @@ impl SyncInput {
/// library's photos, which run on several threads too. Stops early when `progress.files.cancel`
/// is set.
pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
- let SyncInput { folder, scan, photos, labels } = input;
- let seen: Vec> = photos.iter().map(|(p, _)| Arc::clone(p)).collect();
+ let SyncInput { folder, scan, ids, naming, labels } = input;
+ let seen: Vec> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect();
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
}
+ // (how each photo's sidecar is named: a pass over the library, made here and not while the
+ // session waits, and not at all for a folder that is not there)
+ let namings = crate::sidecar::namings_in(&labels, naming, &ids);
+ let photos: Vec<(Arc, SidecarNaming)> =
+ ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect();
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress));
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 6aad89b31a..4e5e738501 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -402,6 +402,48 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() {
assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0);
}
+/// A crop of no size (or a hair wide), as `crop.set` or a sidecar can set it, made the render's
+/// samplers read at infinite coordinates and its blurs ask for sigmas of millions of pixels: with a
+/// healing spot and masks on, render workers panicked (an i32 square in the blur, a bilinear
+/// neighbour past isize::MAX). Such a photo renders, quickly.
+#[test]
+fn degenerate_crops_render_with_spots_and_masks() {
+ for rect in [[0.5, 0.5, 0.5, 0.5], [0.0, 0.0, 1e-9, 1.0]] {
+ let mut s = demo();
+ let id = s.active().unwrap();
+ s.execute("spot.add", &json!({"points": [[0.5, 0.5]], "size": 0.2, "mode": "heal"})).unwrap();
+ s.execute("mask.add", &json!({"kind": "radial", "rx": 0.3, "ry": 0.3})).unwrap();
+ s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
+ s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap();
+ s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap();
+ s.execute("crop.set", &json!({"rect": rect, "angle": 30})).unwrap();
+ let t = std::time::Instant::now();
+ let r = s.render_now(id, 700, 450).unwrap();
+ assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
+ assert!(t.elapsed().as_secs_f64() < 60.0, "{rect:?}: {:?}", t.elapsed());
+ }
+}
+
+/// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow
+/// checks are on); extreme deltas and indices now move the mask to the bottom or the top.
+#[test]
+fn mask_move_with_extreme_deltas() {
+ let mut s = demo();
+ for kind in ["linear", "radial", "brush"] {
+ s.execute("mask.add", &json!({"kind": kind})).unwrap();
+ }
+ let ids = |s: &Session| active_dev(s).masks.iter().map(|m| m.id).collect::>();
+ let [a, b, c] = ids(&s)[..] else { panic!("{:?}", ids(&s)) };
+ s.execute("mask.move", &json!({"id": b, "delta": i64::MAX})).unwrap();
+ assert_eq!(ids(&s), [a, c, b], "to the bottom");
+ s.execute("mask.move", &json!({"id": b, "delta": i64::MIN})).unwrap();
+ assert_eq!(ids(&s), [b, a, c], "to the top");
+ s.execute("mask.move", &json!({"id": c, "to": i64::MIN})).unwrap();
+ assert_eq!(ids(&s), [c, b, a]);
+ s.execute("mask.move", &json!({"id": c, "to": i64::MAX})).unwrap();
+ assert_eq!(ids(&s), [b, a, c]);
+}
+
#[test]
fn masks_reorder_and_duplicate_and_invert() {
let mut s = demo();
@@ -1178,3 +1220,269 @@ fn hdr_edits_render_with_their_hdr_histogram() {
assert!(r.histogram.above_sdr() > 0.0);
assert!(r.deep.is_none(), "the preview itself stays the 8-bit SDR rendition");
}
+
+/// Found by fuzzing catalog snapshots: an album filed under one id but naming its own folder's
+/// listed itself inside itself, and `albums.list` recursed until the stack overflowed (an abort:
+/// no guard catches it). Loading repairs such a catalog (`Catalog::repair`); one that skipped
+/// that still lists, the loop cut off.
+#[test]
+fn albums_list_survives_a_catalog_that_loops() {
+ let mut s = Session::with_demo();
+ let f = s.execute("album.create", &json!({"name": "F", "folder": true})).unwrap()["id"].as_u64().unwrap();
+ let a = s.execute("album.create", &json!({"name": "A", "parent": f})).unwrap()["id"].as_u64().unwrap();
+ let mut v: serde_json::Value = serde_json::from_str(&s.catalog.to_snapshot()).unwrap();
+ v["albums"][a.to_string()]["id"] = f.into();
+ // as read, without the repair a load does
+ s.catalog = serde_json::from_value(v).unwrap();
+ let r = s.execute("albums.list", &json!({})).unwrap();
+ assert!(r.to_string().contains("\"F\""), "{r}");
+}
+
+#[test]
+fn develop_paste_validates_groups_and_rejects_malformed_with_zero_mutations() {
+ let mut s = demo();
+ let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
+ let photo_a = ids[0];
+ let photo_b = ids[1];
+
+ // Photo A has exposure = 1.0, vibrance = 30.0
+ s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
+ s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
+ s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap();
+
+ let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
+ let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
+
+ // 1. Invalid group names are rejected
+ for invalid_group in ["bogusGroup", "LIGHT", "Exposure", "unknown"] {
+ let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": [invalid_group]})).unwrap_err();
+ assert!(matches!(err, crate::EngineError::BadParams { .. }));
+ assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
+ assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
+ }
+
+ // 2. Incorrect JSON types are rejected
+ for invalid_param in [
+ json!("light"), // string instead of sequence
+ json!(42), // number
+ json!(true), // boolean
+ json!({"name": "light"}), // object
+ json!([123]), // array of non-string
+ json!(["light", 456]), // mixed array with invalid type
+ json!([true]), // array of bool
+ ] {
+ let err = s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": invalid_param})).unwrap_err();
+ assert!(matches!(err, crate::EngineError::BadParams { .. }));
+ assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
+ assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
+ }
+
+ // 3. Valid groups remain supported: pasting only "light" does not paste "color"
+ s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": ["light"]})).unwrap();
+ let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
+ assert_eq!(dev_after_light.light.exposure, 1.0);
+ assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
+
+ // Reset photo B
+ s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
+ s.execute("develop.reset", &json!({})).unwrap();
+ assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
+
+ // 4. Omitted groups preserves default behavior (all copied groups pasted)
+ s.execute("develop.paste", &json!({"ids": [photo_b.0]})).unwrap();
+ let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
+ assert_eq!(dev_after_omitted.light.exposure, 1.0);
+ assert_eq!(dev_after_omitted.color.vibrance, 30.0);
+
+ // Reset photo B
+ s.execute("develop.reset", &json!({})).unwrap();
+
+ // 5. Explicit null behaves as omitted
+ s.execute("develop.paste", &json!({"ids": [photo_b.0], "groups": null})).unwrap();
+ let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
+ assert_eq!(dev_after_null.light.exposure, 1.0);
+ assert_eq!(dev_after_null.color.vibrance, 30.0);
+}
+
+#[test]
+fn develop_sync_validates_groups_and_rejects_malformed_with_zero_mutations() {
+ let mut s = demo();
+ let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
+ let photo_a = ids[0];
+ let photo_b = ids[1];
+
+ // Setup: Photo A has exposure = 1.0, vibrance = 30.0
+ s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
+ s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30.0}})).unwrap();
+
+ // Select Photo A (active) and Photo B
+ s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
+
+ let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
+ let initial_b_hist_len = s.catalog.photo(photo_b).unwrap().history.len();
+
+ // 1. Invalid group names are rejected
+ for invalid_group in ["bogusGroup", "LIGHT", "WhiteBalance", "invalid_name"] {
+ let err = s.execute("develop.sync", &json!({"groups": [invalid_group]})).unwrap_err();
+ assert!(matches!(err, crate::EngineError::BadParams { .. }));
+ assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
+ assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
+ }
+
+ // 2. Incorrect JSON types are rejected
+ for invalid_param in [
+ json!("light"), // string instead of sequence
+ json!(100), // number
+ json!(false), // boolean
+ json!({"groups": "light"}), // object
+ json!([999]), // array of non-string
+ json!(["color", false]), // mixed array with invalid type
+ json!([null]), // array with null
+ ] {
+ let err = s.execute("develop.sync", &json!({"groups": invalid_param})).unwrap_err();
+ assert!(matches!(err, crate::EngineError::BadParams { .. }));
+ assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
+ assert_eq!(s.catalog.photo(photo_b).unwrap().history.len(), initial_b_hist_len);
+ }
+
+ // 3. Valid groups remain supported: syncing only "light" does not sync "color"
+ s.execute("develop.sync", &json!({"groups": ["light"]})).unwrap();
+ let dev_after_light = (*s.develop_of(photo_b).unwrap()).clone();
+ assert_eq!(dev_after_light.light.exposure, 1.0);
+ assert_eq!(dev_after_light.color.vibrance, initial_b_dev.color.vibrance);
+
+ // Reset photo B
+ s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
+ s.execute("develop.reset", &json!({})).unwrap();
+ assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 0.0);
+
+ // Reselect Photo A (active) and Photo B
+ s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
+
+ // 4. Omitted groups preserves default behavior (all default copy groups synced)
+ s.execute("develop.sync", &json!({})).unwrap();
+ let dev_after_omitted = (*s.develop_of(photo_b).unwrap()).clone();
+ assert_eq!(dev_after_omitted.light.exposure, 1.0);
+ assert_eq!(dev_after_omitted.color.vibrance, 30.0);
+
+ // Reset photo B
+ s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
+ s.execute("develop.reset", &json!({})).unwrap();
+
+ // Reselect Photo A (active) and Photo B
+ s.execute("library.select", &json!({"ids": [photo_a.0, photo_b.0], "active": photo_a.0})).unwrap();
+
+ // 5. Explicit null behaves as omitted
+ s.execute("develop.sync", &json!({"groups": null})).unwrap();
+ let dev_after_null = (*s.develop_of(photo_b).unwrap()).clone();
+ assert_eq!(dev_after_null.light.exposure, 1.0);
+ assert_eq!(dev_after_null.color.vibrance, 30.0);
+}
+
+#[test]
+fn develop_copy_and_paste_previous_validate_groups() {
+ let mut s = demo();
+ let ids: Vec<_> = s.visible().iter().copied().take(2).collect();
+ let photo_a = ids[0];
+ let photo_b = ids[1];
+
+ s.execute("library.select", &json!({"ids": [photo_a.0]})).unwrap();
+ s.execute("develop.set", &json!({"values": {"light.exposure": 1.5}})).unwrap();
+
+ // develop.copy rejects invalid group
+ let copy_err = s.execute("develop.copy", &json!({"groups": ["notAGroup"]})).unwrap_err();
+ assert!(matches!(copy_err, crate::EngineError::BadParams { .. }));
+
+ // develop.copy accepts valid groups
+ s.execute("develop.copy", &json!({"groups": ["light"]})).unwrap();
+
+ // Switch active photo to photo_b so photo_a becomes previous_active
+ s.execute("library.select", &json!({"ids": [photo_b.0]})).unwrap();
+
+ let initial_b_dev = (*s.develop_of(photo_b).unwrap()).clone();
+
+ // develop.pastePrevious rejects invalid group
+ let prev_err = s.execute("develop.pastePrevious", &json!({"groups": ["badGroup"]})).unwrap_err();
+ assert!(matches!(prev_err, crate::EngineError::BadParams { .. }));
+ assert_eq!(*s.develop_of(photo_b).unwrap(), initial_b_dev);
+
+ // develop.pastePrevious accepts valid group
+ s.execute("develop.pastePrevious", &json!({"groups": ["light"]})).unwrap();
+ assert_eq!(s.develop_of(photo_b).unwrap().light.exposure, 1.5);
+}
+
+#[test]
+fn presets_validate_groups_on_create_and_update() {
+ let mut s = demo();
+ s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 25}})).unwrap();
+ let initial_preset_count = s.presets.len();
+
+ // 1. preset.create rejects invalid group names and incorrect JSON types without creating a preset
+ for invalid_groups in [json!(["bogusGroup"]), json!("light"), json!(123), json!([true]), json!(["light", 99])] {
+ let err = s.execute("preset.create", &json!({"name": "Test", "groups": invalid_groups})).unwrap_err();
+ assert!(matches!(err, crate::EngineError::BadParams { .. }));
+ assert_eq!(s.presets.len(), initial_preset_count);
+ }
+
+ // 2. preset.create with valid groups creates a preset with only those groups
+ let res = s.execute("preset.create", &json!({"name": "ValidPreset", "groups": ["light"]})).unwrap();
+ let pid = res["id"].as_str().unwrap().to_string();
+ assert_eq!(s.presets.len(), initial_preset_count + 1);
+ let p = s.presets.iter().find(|p| p.id == pid).unwrap();
+ assert!(p.settings.get("light").is_some());
+ assert!(p.settings.get("color").is_none());
+
+ // 3. preset.create with omitted groups uses default copy groups
+ let res_omitted = s.execute("preset.create", &json!({"name": "DefaultPreset"})).unwrap();
+ let pid_omitted = res_omitted["id"].as_str().unwrap().to_string();
+ let p_omitted = s.presets.iter().find(|p| p.id == pid_omitted).unwrap();
+ assert!(p_omitted.settings.get("light").is_some());
+ assert!(p_omitted.settings.get("color").is_some());
+
+ // Change develop settings
+ s.execute("develop.set", &json!({"values": {"light.exposure": 2.0, "color.vibrance": 50}})).unwrap();
+
+ let preset_before_update = s.presets.iter().find(|p| p.id == pid).unwrap().clone();
+
+ // 4. preset.update rejects invalid group names and incorrect JSON types without modifying the preset
+ for invalid_groups in [json!(["bogusGroup"]), json!("color"), json!(999), json!([false]), json!(["color", "bad"])] {
+ let err = s.execute("preset.update", &json!({"id": pid, "groups": invalid_groups})).unwrap_err();
+ assert!(matches!(err, crate::EngineError::BadParams { .. }));
+ let current = s.presets.iter().find(|p| p.id == pid).unwrap();
+ assert_eq!(*current, preset_before_update);
+ }
+
+ // 5. preset.update with valid groups updates specified groups
+ s.execute("preset.update", &json!({"id": pid, "groups": ["light", "color"]})).unwrap();
+ let p_after_valid = s.presets.iter().find(|p| p.id == pid).unwrap();
+ assert_eq!(p_after_valid.settings["light"]["exposure"], 2.0);
+ assert_eq!(p_after_valid.settings["color"]["vibrance"], 50.0);
+
+ // 6. preset.update with omitted groups updates existing preset keys
+ s.execute("develop.set", &json!({"values": {"light.exposure": -1.0, "color.vibrance": -20}})).unwrap();
+ s.execute("preset.update", &json!({"id": pid})).unwrap();
+ let p_after_omitted = s.presets.iter().find(|p| p.id == pid).unwrap();
+ assert_eq!(p_after_omitted.settings["light"]["exposure"], -1.0);
+ assert_eq!(p_after_omitted.settings["color"]["vibrance"], -20.0);
+}
+
+/// A Heal or Remove spot whose size (from a sidecar, which isn't held to the slider's range) is far past the photo
+/// made the render's radius infinite: its heal blur and the source it reads went with it, overflowing the blur's box
+/// radii and the bilinear sampler (issue #1519). Such spots render, quickly, as a spot over the whole frame.
+#[test]
+fn oversized_spots_render() {
+ use lightcraft_develop::{Spot, SpotMode};
+ for size in [10.0, 1e30, f64::MAX, f64::INFINITY, f64::NAN] {
+ for mode in [SpotMode::Heal, SpotMode::Remove, SpotMode::Clone] {
+ let mut s = demo();
+ let id = s.active().unwrap();
+ let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
+ dd.spots.push(Spot { mode, points: vec![lightcraft_geom::Point::new(0.5, 0.5)], size, ..Default::default() });
+ s.set_develop(id, dd, "Remove").unwrap();
+ let t = std::time::Instant::now();
+ let r = s.render_now(id, 300, 200).unwrap();
+ assert!(r.image.width >= 1 && r.image.height >= 1, "{size} {mode:?}");
+ assert!(t.elapsed().as_secs_f64() < 60.0, "{size} {mode:?}: {:?}", t.elapsed());
+ }
+ }
+}
diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs
index f1c27c849e..a308f20fb9 100644
--- a/crates/engine/src/tests_face_models.rs
+++ b/crates/engine/src/tests_face_models.rs
@@ -205,10 +205,24 @@ fn detect_without_the_model_says_where_to_get_it() {
let d = temp("nodetector");
let mut s = Session::with_demo();
// no folder for models at all, then a folder that has none yet
- assert!(s.execute("faces.detect", &json!({})).is_err());
+ let e = s.execute("faces.detect", &json!({})).unwrap_err();
+ assert_ne!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "nothing to download into: {e}");
s.face_models_dir = Some(d.join("models"));
- let e = s.execute("faces.detect", &json!({})).unwrap_err().to_string();
- assert!(e.contains("Settings > Faces"), "{e}");
+ let e = s.execute("faces.detect", &json!({})).unwrap_err();
+ // the app offers the download on the kind (#1046), the person reads the message
+ assert_eq!(e.kind(), crate::ErrorKind::ModelMissing(crate::AiModel::FaceDetector), "{e}");
+ assert!(
+ e.to_string().starts_with("invalid parameters for `faces.detect`: ") && e.to_string().contains("Settings > Faces"),
+ "the `error` text clients see is unchanged: {e}"
+ );
+ // a detector that is there but unusable also points to Settings > Faces, and is not a missing one
+ let home = d.join("models").join(lightcraft_faces::known::YUNET_ID);
+ std::fs::create_dir_all(&home).unwrap();
+ std::fs::write(home.join("model.onnx"), b"not a model").unwrap();
+ std::fs::write(home.join("face-model.json"), b"{}").unwrap();
+ let e = s.execute("faces.detect", &json!({})).unwrap_err();
+ assert!(e.to_string().contains("Settings > Faces"), "{e}");
+ assert_eq!(e.kind(), crate::ErrorKind::BadParams, "{e}");
let _ = std::fs::remove_dir_all(&d);
}
diff --git a/crates/engine/src/tests_face_tiles.rs b/crates/engine/src/tests_face_tiles.rs
new file mode 100644
index 0000000000..3af23f0da1
--- /dev/null
+++ b/crates/engine/src/tests_face_tiles.rs
@@ -0,0 +1,275 @@
+//! Face tiles from the embedded preview, kept in the thumbnail cache (issue #1440).
+//!
+//! The fixture is a TIFF-family raw with two embedded JPEG previews (a small flat red one and a
+//! larger picture) and a sensor strip, as #1020's planned-read tests build them. Counting wrappers
+//! around the session's file loader (the path that reads the whole original and develops the raw)
+//! and its preview loader say which source a face tile came from.
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use lightcraft_catalog::PhotoId;
+use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
+use lightcraft_geom::Rect;
+use lightcraft_raster::Rgba8;
+use serde_json::json;
+
+use crate::Session;
+
+const W: u32 = 600;
+const H: u32 = 400;
+
+fn temp_dir(tag: &str) -> PathBuf {
+ let d = std::env::temp_dir().join(format!("lc-face-tiles-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+}
+
+/// The big preview's pixel at (x, y): smooth ramps and a soft disc, so a resampled square of it
+/// compares well between two resamplers.
+fn big_pixel(x: u32, y: u32) -> [u8; 3] {
+ let (dx, dy) = (x as f64 - 300.0, y as f64 - 200.0);
+ let disc = (-(dx * dx + dy * dy) / (2.0 * 40.0 * 40.0)).exp();
+ [(40.0 + 150.0 * x as f64 / W as f64) as u8, (60.0 + 120.0 * y as f64 / H as f64) as u8, (50.0 + 160.0 * disc) as u8]
+}
+
+fn jpeg(w: u32, h: u32, px: impl Fn(u32, u32) -> [u8; 3]) -> Vec {
+ let rgb: Vec = (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| px(x, y)).collect();
+ let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
+ encode_jpeg(&img, 95, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap()
+}
+
+/// A raw (DNG-tagged) whose IFD0 is a small flat red preview, IFD1 the big picture and IFD2 a CFA
+/// "sensor" strip of `tail` bytes. The sensor data can't be developed, so the import describes the
+/// photo by its largest preview (600 × 400) and the develop path renders from it, after reading
+/// the whole file.
+fn raw_with_two_previews(tail: usize) -> Vec {
+ use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
+ let preview = |w: u32, h: u32, bytes: Vec, first: bool| {
+ let mut ifd = IfdBuilder::new();
+ if first {
+ ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
+ }
+ ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
+ ifd.set(t::COMPRESSION, Value::Short(vec![7]));
+ ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
+ ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
+ ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
+ ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![bytes] });
+ ifd
+ };
+ let small = preview(120, 80, jpeg(120, 80, |_, _| [200, 40, 40]), true);
+ let big = preview(W, H, jpeg(W, H, big_pixel), false);
+ let mut raw = IfdBuilder::new();
+ raw.set(t::COMPRESSION, Value::Short(vec![1]));
+ raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
+ raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
+ raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
+ raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
+ TiffWriter::default().write(&[small, big, raw]).unwrap()
+}
+
+/// How often each loader ran.
+#[derive(Clone, Default)]
+struct Loads {
+ /// The original read whole and developed (for a raw: its sensor data decoded).
+ files: Arc,
+ /// The embedded preview read (planned read: header and preview only).
+ previews: Arc,
+}
+
+impl Loads {
+ fn get(&self) -> (usize, usize) {
+ (self.files.load(Ordering::SeqCst), self.previews.load(Ordering::SeqCst))
+ }
+}
+
+/// Count the session's loads from now on.
+fn count_loads(s: &mut Session) -> Loads {
+ let loads = Loads::default();
+ let file = s.media.file_loader.clone().unwrap();
+ let n = loads.files.clone();
+ s.media.file_loader = Some(Arc::new(move |p: &str, e: usize| {
+ n.fetch_add(1, Ordering::SeqCst);
+ file(p, e)
+ }));
+ let preview = s.media.preview_loader.clone().unwrap();
+ let n = loads.previews.clone();
+ s.media.preview_loader = Some(Arc::new(move |p: &str, e: usize| {
+ n.fetch_add(1, Ordering::SeqCst);
+ preview(p, e)
+ }));
+ loads
+}
+
+/// A session on the library in `dir` (created on first use).
+fn open(dir: &Path) -> Session {
+ let mut s = Session::new().with_fs();
+ s.open_library(dir.join("library"), false).unwrap();
+ s
+}
+
+/// A library holding the fixture: (dir, session, the photo, the file).
+fn setup(tag: &str) -> (PathBuf, Session, PhotoId, PathBuf) {
+ let dir = temp_dir(tag);
+ let file = dir.join("faces.dng");
+ std::fs::write(&file, raw_with_two_previews(400_000)).unwrap();
+ let mut s = open(&dir);
+ s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
+ let p = s.catalog.photos().next().unwrap();
+ assert_eq!((p.kind, p.width, p.height), (lightcraft_catalog::MediaKind::Raw, W, H), "described by its largest preview");
+ let id = p.id;
+ (dir, s, id, file)
+}
+
+/// A face of a tenth of the frame, centred: a 64 px tile needs a 377 px source, which the 600 px
+/// preview has.
+const FACE: Rect = Rect { x0: 0.45, y0: 0.45, x1: 0.55, y1: 0.55 };
+/// A face of 2 %: a 64 px tile would need a 1883 px source; the preview is under ¾ of that.
+const TINY_FACE: Rect = Rect { x0: 0.49, y0: 0.49, x1: 0.51, y1: 0.51 };
+
+fn tile(s: &mut Session, id: PhotoId, face: Rect, edge: usize) -> Result {
+ s.face_job(id, face, edge).unwrap().run().rendered.map(|r| r.image)
+}
+
+fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f64 {
+ assert_eq!((a.width, a.height), (b.width, b.height));
+ let sum: u64 = a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| u64::from(p[c].abs_diff(q[c])))).sum();
+ sum as f64 / (a.data.len() * 3) as f64
+}
+
+/// An unedited raw's face tile is cut from its largest embedded preview: the original is never read
+/// whole nor its raw developed, and the tile matches the develop path's at tile size.
+#[test]
+fn an_unedited_raws_face_comes_from_its_largest_preview() {
+ let (dir, mut s, id, file) = setup("preview");
+ // (the preview loader's planned read: the header and the preview, not the sensor strip)
+ let (_, _, read) = crate::files::preview_window(&file.to_string_lossy(), usize::MAX).unwrap().unwrap();
+ assert!(read.fraction() < 0.5, "{read:?}");
+ let loads = count_loads(&mut s);
+ let from_preview = tile(&mut s, id, FACE, 64).unwrap();
+ assert_eq!((from_preview.width, from_preview.height), (64, 64));
+ assert_eq!(loads.get(), (0, 1), "one preview read, no whole-file read or raw decode");
+ // the big preview, not the small flat red one
+ let centre = from_preview.data[32 * 64 + 32];
+ assert!(centre[2] > 150 && centre[0] < 150, "the face's centre is the big preview's disc: {centre:?}");
+
+ // the develop path (no preview loader: what every face used before), from the memory cache's reach
+ s.media.preview_loader = None;
+ let mut job = s.face_job(id, FACE, 64).unwrap();
+ job.cache = None;
+ let developed = job.run().rendered.unwrap().image;
+ assert_eq!(loads.files.load(Ordering::SeqCst), 1, "the develop path reads the original");
+ let d = mean_abs_diff(&from_preview, &developed);
+ eprintln!("mean difference preview/develop: {d:.3}");
+ assert!(d < 4.0, "the preview's tile looks like the developed one: mean difference {d:.2} of 255");
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+/// A preview under the soft limit (¾ of the pixels the tile needs across the face) leaves the tile to
+/// the develop path; so does an edited photo, whose look the camera's JPEG doesn't have.
+#[test]
+fn a_preview_too_small_or_an_edited_photo_falls_back_to_the_develop_path() {
+ let (dir, mut s, id, _) = setup("fallback");
+ let loads = count_loads(&mut s);
+ let img = tile(&mut s, id, TINY_FACE, 64).unwrap();
+ assert_eq!((img.width, img.height), (64, 64));
+ assert_eq!(loads.get(), (1, 1), "the preview was tried, then the original developed");
+
+ s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
+ s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
+ let edited = tile(&mut s, id, FACE, 64).unwrap();
+ assert_eq!(loads.get(), (2, 1), "an edited photo's face is developed; its preview isn't read");
+ let _ = std::fs::remove_dir_all(dir);
+ drop(edited);
+}
+
+/// Rendered tiles are kept in the library's thumbnail cache: a new session (the next launch) shows
+/// them without reading the original, from the preview or the develop path alike; and a size
+/// between two steps reuses the tile of its step.
+#[test]
+fn face_tiles_are_kept_on_disk_for_the_next_launch() {
+ let (dir, mut s, id, _) = setup("disk");
+ let first = tile(&mut s, id, FACE, 64).unwrap();
+ let _ = tile(&mut s, id, TINY_FACE, 64).unwrap();
+ drop(s);
+
+ let mut s = open(&dir);
+ let loads = count_loads(&mut s);
+ let again = tile(&mut s, id, FACE, 64).unwrap();
+ let tiny = tile(&mut s, id, TINY_FACE, 64).unwrap();
+ assert_eq!(loads.get(), (0, 0), "both tiles come from the disk cache: the original is not touched");
+ assert_eq!((again.width, tiny.width), (64, 64));
+ assert!(mean_abs_diff(&first, &again) < 3.0, "the cached tile is the rendered one (as a JPEG)");
+ // 60 px asks for the 64 px step: the same tile, no new render
+ assert_eq!(s.face_job(id, FACE, 60).unwrap().key, s.face_job(id, FACE, 64).unwrap().key);
+ let _ = tile(&mut s, id, FACE, 60).unwrap();
+ assert_eq!(loads.get(), (0, 0));
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+/// What can go wrong never panics: a preview loader that fails or answers nonsense, a damaged
+/// preview, face boxes at the edge or degenerate, an offline original, a damaged cache entry.
+#[test]
+fn face_tiles_never_crash() {
+ let (dir, mut s, id, file) = setup("crash");
+
+ // a damaged cache entry: dropped, and the tile rendered again (from the preview)
+ let job = s.face_job(id, FACE, 64).unwrap();
+ let key = job.cache.as_ref().unwrap().1.to_string();
+ assert!(job.run().rendered.is_ok());
+ let thumbs = s.library.as_ref().unwrap().thumbs_dir();
+ drop(s);
+ let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg"));
+ assert!(entry.exists(), "the tile was written to the thumbnail cache");
+ std::fs::write(&entry, b"not a jpeg").unwrap();
+ let mut s = open(&dir);
+ let loads = count_loads(&mut s);
+ assert_eq!(tile(&mut s, id, FACE, 64).unwrap().width, 64);
+ assert_eq!(loads.get(), (0, 1), "rendered again from the preview");
+
+ // face boxes at the corners, oversized, empty, not numbers: a tile or an error, never a panic
+ for face in [
+ Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
+ Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
+ Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
+ Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
+ Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
+ Rect { x0: f64::NEG_INFINITY, y0: f64::NAN, x1: f64::NAN, y1: f64::NAN },
+ ] {
+ for edge in [0, 1, 64, usize::MAX] {
+ let img = tile(&mut s, id, face, edge).unwrap_or_else(|e| panic!("{face:?} at {edge}: {e}"));
+ assert_eq!(img.width, img.height, "{face:?} at {edge}");
+ }
+ }
+
+ // a preview loader that answers nonsense or nothing: the develop path renders the tile
+ let id2 = id;
+ for answer in [None, Some(Rgba8 { width: W as usize, height: H as usize, data: Vec::new() }), Some(Rgba8::new(1, 1))] {
+ s.media.preview_loader = Some(Arc::new(move |_: &str, _: usize| answer.clone()));
+ let mut job = s.face_job(id2, FACE, 64).unwrap();
+ job.cache = None;
+ assert_eq!(job.run().rendered.unwrap().image.width, 64);
+ }
+
+ // a damaged preview in the file: no tile from it, and no panic either way
+ s.media.preview_loader = Some(crate::files::fs_preview_loader());
+ let mut bytes = std::fs::read(&file).unwrap();
+ let n = bytes.len();
+ for b in bytes.iter_mut().take(n - 400_000).skip(1000) {
+ *b = 0x5a;
+ }
+ std::fs::write(&file, &bytes).unwrap();
+ let mut job = s.face_job(id, FACE, 64).unwrap();
+ job.cache = None;
+ let _ = job.run();
+
+ // the original gone (an offline drive): an error, no panic
+ std::fs::remove_file(&file).unwrap();
+ let mut job = s.face_job(id, FACE, 64).unwrap();
+ job.cache = None;
+ assert!(job.run().rendered.is_err());
+ let _ = std::fs::remove_dir_all(dir);
+}
diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs
index d387d2fe1d..ed4cc91627 100644
--- a/crates/engine/src/tests_import.rs
+++ b/crates/engine/src/tests_import.rs
@@ -1438,14 +1438,20 @@ fn smart_preview_look_marker_is_read_safely() {
let dir = temp_dir("smartlook-marker");
assert!(!crate::smart::look_checked(&dir), "no marker");
let cur = crate::camera_preview::LOOK_VERSION;
+ let enc = crate::smart::ENCODE_VERSION;
for (text, checked) in [
(String::new(), false),
("banana".into(), false),
("-1".into(), false),
(format!("{}", cur.saturating_sub(1)), false),
("9".repeat(200), false),
- (format!("{cur}\n"), true),
- (format!(" {}\n", cur + 1), true),
+ // Written before the encode version was recorded: only says the look was checked, so the
+ // folder is scanned once more and the old-encoding proxies are rebuilt.
+ (format!("{cur}\n"), false),
+ (format!("{cur} {}\n", enc.saturating_sub(1)), false),
+ (format!("{cur} banana\n"), false),
+ (format!("{cur} {enc}\n"), true),
+ (format!(" {} {}\n", cur + 1, enc + 1), true),
] {
std::fs::write(dir.join("look-version"), &text).unwrap();
assert_eq!(crate::smart::look_checked(&dir), checked, "{text:?}");
diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs
index 3e6e9208bd..ce249df161 100644
--- a/crates/engine/src/tests_import_move.rs
+++ b/crates/engine/src/tests_import_move.rs
@@ -503,3 +503,30 @@ fn copies_run_side_by_side_with_the_same_outcome() {
assert_eq!(first.file_name, "IMG_1.png");
let _ = std::fs::remove_dir_all(&base);
}
+
+#[test]
+fn finish_keeps_the_original_when_the_copy_changed_to_the_same_length() {
+ use crate::import_move::{finish, place};
+ let base = temp_dir("finish-recheck");
+ let (src, dest) = (base.join("card/IMG_1.jpg"), base.join("dest"));
+ std::fs::create_dir_all(src.parent().unwrap()).unwrap();
+ std::fs::write(&src, b"original bytes").unwrap();
+ inject(Fault::NoLink);
+ let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
+ // unchanged copy: the original goes
+ assert!(finish(&placed).is_ok());
+ assert!(!src.exists());
+ // a copy replaced by other bytes of the same length: the original stays
+ std::fs::write(&src, b"original bytes").unwrap();
+ let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
+ std::fs::write(&placed.dst, b"different byte").unwrap();
+ assert!(finish(&placed).is_err());
+ assert_eq!(std::fs::read(&src).unwrap(), b"original bytes");
+ // an original edited meanwhile to other bytes of the same length: it stays too
+ let placed = place(&src, &dest, "IMG_1.jpg").unwrap();
+ std::fs::write(&src, b"edited bytes!").unwrap();
+ assert!(finish(&placed).is_err());
+ assert_eq!(std::fs::read(&src).unwrap(), b"edited bytes!");
+ inject(Fault::None);
+ let _ = std::fs::remove_dir_all(&base);
+}
diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs
index 27a82fbeda..cb42dbc2a8 100644
--- a/crates/engine/src/tests_merge.rs
+++ b/crates/engine/src/tests_merge.rs
@@ -67,6 +67,57 @@ fn hdr_merge_command_creates_and_imports_a_dng() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// An HDR merge keeps the reference frame's lens corrections. The sources carry an off-centre `WarpRectilinear` and
+/// `FixVignetteRadial` for their active area and have a default crop; the merged DNG must give "Enable Profile
+/// Corrections" the same correction as the reference photo, upright and for a vertical bracket (whose DNG keeps the
+/// pixels unturned).
+#[test]
+fn hdr_merge_keeps_the_reference_frames_lens_corrections() {
+ use lightcraft_geom::{Orientation, Point};
+ use lightcraft_pipeline::optics::{embedded_vignette_gain, embedded_warp};
+ use lightcraft_raw::Opcode;
+ for orientation in [Orientation::Normal, Orientation::Rotate90] {
+ let dir = temp_dir(&format!("hdr-lens-{orientation:?}"));
+ let mut paths = Vec::new();
+ for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() {
+ let mut raw = lightcraft_raw::decode(&b).unwrap();
+ raw.crop = lightcraft_raw::Rect::new(10, 6, 452, 304);
+ raw.orientation = orientation;
+ raw.metadata.orientation = Some(orientation);
+ raw.opcodes.list3 = vec![
+ Opcode::WarpRectilinear { planes: vec![[0.96, 0.05, -0.012, 0.002, 0.001, -0.0005]], center: [0.48, 0.53] },
+ Opcode::FixVignetteRadial { k: [0.35, -0.05, 0.01, 0.0, 0.0], center: [0.48, 0.53] },
+ ];
+ let p = dir.join(format!("IMG_{i}.dng"));
+ std::fs::write(&p, lightcraft_raw::write_dng(&raw, &Default::default()).unwrap()).unwrap();
+ paths.push(p.to_string_lossy().to_string());
+ }
+ let mut s = Session::new().with_fs();
+ let ids = import(&mut s, paths);
+ let r = s.execute("merge.hdr", &json!({"ids": ids})).unwrap();
+ assert_eq!(r["reference"].as_u64(), Some(ids[1]), "{r}");
+ let merged = s.catalog.photo(lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap())).unwrap().clone();
+ let reference = s.catalog.photo(lightcraft_catalog::PhotoId(ids[1])).unwrap().clone();
+ let expect = if orientation == Orientation::Normal { (452, 304) } else { (304, 452) };
+ assert_eq!(((merged.width, merged.height), (reference.width, reference.height)), (expect, expect));
+ let a = reference.embedded_lens.expect("the source's correction");
+ let b = merged.embedded_lens.expect("the merged DNG carries the reference frame's correction");
+ let (wa, wb) = (a.warp.unwrap(), b.warp.unwrap());
+ let (va, vb) = (a.vignette.unwrap(), b.vignette.unwrap());
+ let (w, h) = (expect.0 as f64, expect.1 as f64);
+ for (x, y) in [(0.0, 0.0), (w, h), (w, 0.0), (0.0, h), (w * 0.3, h * 0.7), (w * 0.5, h * 0.5)] {
+ let p = Point::new(x, y);
+ for ch in 0..3 {
+ let (pa, pb) = (embedded_warp(&wa, p, ch, w, h), embedded_warp(&wb, p, ch, w, h));
+ assert!((pa.x - pb.x).abs() < 1e-6 && (pa.y - pb.y).abs() < 1e-6, "{orientation:?} {p:?}: {pa:?} vs {pb:?}");
+ }
+ let (ga, gb) = (embedded_vignette_gain(&va, p, w, h), embedded_vignette_gain(&vb, p, w, h));
+ assert!((ga - gb).abs() < 1e-9, "{orientation:?} {p:?}: {ga} vs {gb}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
+
/// Raws without colour matrices of their own (CR2, NEF, ARW…) merge into a DNG with the colour they open with,
/// not with no colour at all (issue #965: an HDR merge of CR2s rendered green and dark).
#[test]
diff --git a/crates/engine/src/tests_presets_scale.rs b/crates/engine/src/tests_presets_scale.rs
new file mode 100644
index 0000000000..866b7deb31
--- /dev/null
+++ b/crates/engine/src/tests_presets_scale.rs
@@ -0,0 +1,126 @@
+//! A library with thousands of user presets (a Lightroom preset collection imported whole) stays
+//! responsive: commands that don't touch presets never re-serialize them, and opening or importing
+//! doesn't compare every preset with every other.
+
+use std::path::PathBuf;
+use std::time::Instant;
+
+use lightcraft_develop::Preset;
+use serde_json::json;
+
+use crate::Session;
+
+fn temp_dir(tag: &str) -> PathBuf {
+ let d = std::env::temp_dir().join(format!("lc-presets-scale-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+}
+
+fn many(n: usize) -> Vec {
+ (0..n)
+ .map(|i| Preset {
+ id: format!("user.coll-{}.look-{i}", i / 100),
+ name: format!("Look {i}"),
+ group: format!("Collection {}", i / 100),
+ settings: json!({"light": {"exposure": (i % 40) as f64 / 10.0 - 2.0, "contrast": (i % 50) as f64}, "color": {"vibrance": (i % 30) as f64}}),
+ favorite: false,
+ builtin: false,
+ })
+ .collect()
+}
+
+#[test]
+fn develop_commands_do_not_reserialize_unchanged_presets() {
+ let lib = temp_dir("no-reserialize");
+ let mut s = Session::new();
+ s.open_library(&lib, true).unwrap();
+ let added = s.add_presets(many(2000));
+ assert_eq!(added.len(), 2000);
+ s.persist().unwrap();
+ assert!(std::fs::metadata(lib.join("presets.json")).unwrap().len() > 100_000, "presets saved");
+ let before = crate::library::presets_serialized();
+ for i in 0..20 {
+ s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
+ }
+ assert_eq!(crate::library::presets_serialized(), before, "slider commands left the preset list alone");
+ // a preset change is still saved
+ let pid = added[7].clone();
+ s.execute("preset.favorite", &json!({"id": pid})).unwrap();
+ assert!(crate::library::presets_serialized() > before);
+ s.close_library().unwrap();
+ drop(s);
+ let mut t = Session::new();
+ t.open_library(&lib, false).unwrap();
+ assert!(t.presets.iter().any(|p| p.id == pid && p.favorite), "favourite persisted");
+ assert_eq!(t.presets.iter().filter(|p| !p.builtin).count(), 2000);
+ let _ = std::fs::remove_dir_all(&lib);
+}
+
+#[test]
+fn preset_list_generation_follows_mutable_access_only() {
+ let mut s = Session::new();
+ let g0 = s.presets.generation();
+ let _ = s.presets.iter().filter(|p| p.builtin).count();
+ let _ = s.presets.len();
+ assert_eq!(s.presets.generation(), g0, "reads keep the generation");
+ s.presets.push(many(1).remove(0));
+ let g1 = s.presets.generation();
+ assert_ne!(g1, g0);
+ for p in &mut s.presets {
+ p.favorite = false;
+ }
+ assert_ne!(s.presets.generation(), g1);
+ // generations are unique across sessions (UI caches key on them alone)
+ let other = Session::new();
+ assert_ne!(other.presets.generation(), s.presets.generation());
+}
+
+#[test]
+fn importing_thousands_of_presets_is_not_quadratic() {
+ let mut s = Session::new();
+ let t = Instant::now();
+ let added = s.add_presets(many(20_000));
+ assert_eq!(added.len(), 20_000);
+ // the same presets again are all skipped as duplicates
+ assert!(s.add_presets(many(20_000)).is_empty());
+ // a clashing id with different settings gets a fresh, unique one
+ let mut clash = many(1).remove(0);
+ clash.settings = json!({"light": {"exposure": 3.0}});
+ let id = s.add_presets(vec![clash]).remove(0);
+ assert_ne!(id, "user.coll-0.look-0");
+ assert_eq!(s.presets.iter().filter(|p| p.id == id).count(), 1);
+ // quadratic was ~200 M comparisons here (tens of seconds in debug builds)
+ assert!(t.elapsed().as_secs() < 20, "took {:?}", t.elapsed());
+}
+
+/// Timing with a real preset collection: `LIGHTCRAFT_BENCH_PRESETS=path/to/presets.json cargo test
+/// -p lightcraft-engine --release presets_scale_bench -- --ignored --nocapture`. The file is copied
+/// into a temporary library; the original is only read.
+#[test]
+#[ignore]
+fn presets_scale_bench() {
+ let Ok(src) = std::env::var("LIGHTCRAFT_BENCH_PRESETS") else {
+ eprintln!("set LIGHTCRAFT_BENCH_PRESETS");
+ return;
+ };
+ let lib = temp_dir("bench");
+ std::fs::copy(&src, lib.join("presets.json")).unwrap();
+ let mut s = Session::new();
+ let t = Instant::now();
+ s.open_library(&lib, true).unwrap();
+ eprintln!("open library: {:?} ({} presets)", t.elapsed(), s.presets.len());
+ let t = Instant::now();
+ for i in 0..20 {
+ s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap();
+ }
+ eprintln!("develop.set: {:?} per command", t.elapsed() / 20);
+ let pid = s.presets.iter().rev().find(|p| !p.builtin).map(|p| p.id.clone()).unwrap();
+ let t = Instant::now();
+ s.execute("preset.apply", &json!({"id": pid, "amount": 100})).unwrap();
+ eprintln!("preset.apply: {:?}", t.elapsed());
+ let t = Instant::now();
+ s.execute("preset.favorite", &json!({"id": pid})).unwrap();
+ eprintln!("preset.favorite (saves presets.json): {:?}", t.elapsed());
+ let _ = std::fs::remove_dir_all(&lib);
+}
diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests_segment.rs
index ccca1cb23b..f4b9f4f484 100644
--- a/crates/engine/src/tests_segment.rs
+++ b/crates/engine/src/tests_segment.rs
@@ -115,6 +115,7 @@ fn a_failing_download_ends_with_an_error_and_never_blocks() {
}
let st = s.segmenter.download_status();
assert!(!st.finished);
+ assert!(!st.cancelled, "a failure is not a cancel");
let e = st.error.unwrap();
assert!(e.contains("not found"), "{e}");
assert!(!s.segmenter.installed());
@@ -272,9 +273,35 @@ fn a_running_model_download_shows_in_activity_and_cancels() {
assert!(t.elapsed() < Duration::from_secs(10), "the download never stopped");
std::thread::sleep(Duration::from_millis(10));
}
- let e = s.segmenter.download_status().error.unwrap_or_default();
+ let st = s.segmenter.download_status();
+ assert!(st.cancelled, "the app tells a cancel by the flag (#1046): {st:?}");
+ let e = st.error.unwrap_or_default();
assert!(e.contains("cancelled"), "{e}");
assert!(!s.segmenter.installed());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&mirrors);
}
+
+/// Frontends decide on the error's kind, not its words (#1046): a missing model is
+/// `ModelMissing(Sam3)` whether the app queues requests (background) or a command waits (CLI, MCP);
+/// a build without AI masks is not a missing model (there is nothing to download).
+#[test]
+fn a_missing_model_is_model_missing_by_kind() {
+ use crate::{AiModel, ErrorKind};
+ for background in [true, false] {
+ let mut s = Session::with_demo();
+ s.segmenter.background = background;
+ s.segmenter.dir = Some(tmp(&format!("kind-{background}")));
+ for (cmd, p) in
+ [("mask.add", json!({"kind": "object"})), ("mask.add", json!({"kind": "prompt", "text": "sky"})), ("segment.prepare", json!({}))]
+ {
+ let e = s.execute(cmd, &p).unwrap_err();
+ if Segmenter::AVAILABLE {
+ assert_eq!(e.kind(), ErrorKind::ModelMissing(AiModel::Sam3), "{cmd} (background {background}): {e}");
+ assert!(e.to_string().starts_with(NOT_INSTALLED), "the message is unchanged: {e}");
+ } else {
+ assert_eq!(e.kind(), ErrorKind::Other, "{cmd}: {e}");
+ }
+ }
+ }
+}
diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs
index 479c877cfe..1c1b53b4a6 100644
--- a/crates/engine/src/walk.rs
+++ b/crates/engine/src/walk.rs
@@ -57,6 +57,8 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
if let Ok(c) = std::fs::canonicalize(root) {
linked.insert(c);
}
+ // Include aliases to the library in the exclusion, not just its lexical path.
+ let skip_canonical = skip.and_then(|p| std::fs::canonicalize(p).ok());
// (path, depth of the folder it is in, is a folder); popped in path order
let mut stack: Vec<(PathBuf, usize, bool)> = vec![(root.to_path_buf(), 0, true)];
while let Some((path, depth, is_dir)) = stack.pop() {
@@ -66,7 +68,7 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl
}
continue;
}
- if skip.is_some_and(|s| path == s) {
+ if skip.is_some_and(|s| path == s) || skip_canonical.as_ref().is_some_and(|s| std::fs::canonicalize(&path).is_ok_and(|p| p.starts_with(s))) {
continue;
}
if depth >= limits.max_depth {
@@ -165,6 +167,31 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
+ /// Folder imports must not revisit the user's own library through symlinks
+ /// or through an alias to the library's parent.
+ #[cfg(unix)]
+ #[test]
+ fn skipped_library_stays_skipped_through_symlinks_and_ancestors() {
+ let d = tmp("skip-alias");
+ let root = d.join("incoming");
+ let library = d.join("library");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::create_dir_all(library.join("Originals")).unwrap();
+ std::fs::write(root.join("ordinary.jpg"), b"photo").unwrap();
+ std::fs::write(library.join("Originals").join("library-only.jpg"), b"original").unwrap();
+ std::os::unix::fs::symlink(&library, root.join("library-link")).unwrap();
+ std::os::unix::fs::symlink(&d, root.join("parent-link")).unwrap();
+
+ let photos = files_in(&root, Some(&library), Limits::default(), |p| p.extension().is_some_and(|e| e == "jpg"));
+ assert!(photos.files.contains(&root.join("ordinary.jpg")));
+ assert!(!photos.files.iter().any(|p| p.ends_with("library-only.jpg")), "{photos:?}");
+
+ // Scanning a subfolder already inside the excluded library is also forbidden.
+ let inside = files_in(&library.join("Originals"), Some(&library), Limits::default(), |_| true);
+ assert!(inside.files.is_empty(), "{inside:?}");
+ let _ = std::fs::remove_dir_all(&d);
+ }
+
/// A link back up the tree (a loop) is followed at most once: the walk ends.
#[cfg(unix)]
#[test]
diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs
index 548e4b753d..5c950069be 100644
--- a/crates/engine/tests/preview_read.rs
+++ b/crates/engine/tests/preview_read.rs
@@ -20,8 +20,9 @@ fn a_preview_reads_only_the_header_and_the_preview() {
assert!(file_len > 0, "empty file");
let started = Instant::now();
- let window =
- lightcraft_engine::files::preview_window(&path).expect("the file reads").expect("it carries an embedded preview the header can point at");
+ let window = lightcraft_engine::files::preview_window(&path, usize::MAX)
+ .expect("the file reads")
+ .expect("it carries an embedded preview the header can point at");
let elapsed = started.elapsed();
let (_, preview, read) = window;
diff --git a/crates/fetch/README.md b/crates/fetch/README.md
index 60ac1d329d..8bad4003ba 100644
--- a/crates/fetch/README.md
+++ b/crates/fetch/README.md
@@ -2,6 +2,6 @@
Native model downloads in pure Rust (L0, Apache-2.0): HTTP/1.1 over std sockets and rustls with the RustCrypto provider, without ring, aws-lc, C or assembly build dependencies. The wasm32 crate is empty.
-Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host.
+Call `download` with bounded file specifications and ordered mirrors, or `download_url` for an exact catalog URL (including its query). Each file has a local filename independent of the URL, a size cap and optional pinned size/SHA-256. Transfers resume pinned `.part` files, verify before renaming, report progress and check cancellation during reads. Run on a worker thread. No proxy support; DNS resolution uses the platform resolver. Tokens are off by default and never forwarded to a different redirect host. A redirect from https to http is refused.
Tests use local synthetic HTTP servers for redirects, resume, truncation, cancellation, stalls, hashes, size limits and hostile specifications; they make no internet requests.
diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs
index e900850acb..c2b5bee1e0 100644
--- a/crates/fetch/src/http.rs
+++ b/crates/fetch/src/http.rs
@@ -110,7 +110,11 @@ impl Url {
pub fn join(&self, location: &str) -> Result {
let l = location.trim();
if l.starts_with("http://") || l.starts_with("https://") {
- return Url::parse(l);
+ let url = Url::parse(l)?;
+ if self.tls && !url.tls {
+ return Err(HttpError::BadUrl("redirect from https to http refused".into()));
+ }
+ return Ok(url);
}
// another scheme (`ftp:`, `file:`, `data:`…)
if let Some((scheme, _)) = l.split_once(':')
@@ -541,6 +545,11 @@ mod tests {
assert_eq!(u.join("other").unwrap().path, "/x/y/other");
assert_eq!(u.join("//c.example/p").unwrap().host, "c.example");
assert!(u.join("ftp://nope").is_err());
+ // no downgrade from https to http, however the redirect is written
+ assert!(u.join("http://a.example/z").is_err());
+ let plain = Url::parse("http://127.0.0.1:8080/a").unwrap();
+ assert!(!plain.join("http://127.0.0.1:9/b").unwrap().tls);
+ assert!(plain.join("https://b.example/z").unwrap().tls);
}
#[test]
diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs
index 44458350f0..b7fa5e12dd 100644
--- a/crates/gpu/src/render.rs
+++ b/crates/gpu/src/render.rs
@@ -229,29 +229,34 @@ pub(crate) fn rgb_words(img: &Rgb32f) -> &[f32] {
// ---------------------------------------------------------------------------------------------
// Filters (twins of `lightcraft_raster::blur` / `resample` and `lightcraft_pipeline::local`)
-/// Pixels each blur thread slides its running sum over (at least; longer for large radii, so the
+/// Rows each `box_v` thread slides its running sum over (at least; longer for large radii, so the
/// priming sum stays a small share of the work).
const CHUNK: usize = 16;
+/// One box pass of radius `r` (clamped edges) over a `w × h` image of `nc` interleaved channels,
+/// `src → dst`: horizontal or vertical.
+fn box_pass(cx: &mut Cx<'_>, horizontal: bool, src: &Buf, dst: &Buf, (w, h): (usize, usize), nc: usize, r: usize) {
+ let chunk = (2 * r + 1).clamp(CHUNK, 128);
+ let p = [w as u32, h as u32, nc as u32, r as u32, chunk as u32];
+ let (k, groups) = if horizontal { ("box_h", groups2(w, h, [64, 4])) } else { ("box_v", groups2(w, h.div_ceil(chunk), [64, 4])) };
+ cx.run(k, &p, &[Some(src), Some(dst)], groups);
+}
+
/// Gaussian blur of a `w × h` image of `nc` interleaved channels: three box passes each way
/// (`lightcraft_raster::blur::gaussian`).
pub(crate) fn gaussian(cx: &mut Cx<'_>, src: &Buf, w: usize, h: usize, nc: usize, sigma: f32) -> Buf {
- if sigma <= 0.3 || w * h == 0 {
+ let Some(radii) = lightcraft_raster::blur::radii_for(sigma, w, h) else {
return cx.copy(src);
- }
- let radii = lightcraft_raster::blur::box_radii(sigma);
- let passes: Vec<(&str, usize)> = radii.iter().map(|r| ("box_h", *r)).chain(radii.iter().map(|r| ("box_v", *r))).filter(|p| p.1 > 0).collect();
+ };
+ let passes: Vec<(bool, usize)> = radii.iter().map(|r| (true, *r)).chain(radii.iter().map(|r| (false, *r))).filter(|p| p.1 > 0).collect();
if passes.is_empty() {
return cx.copy(src);
}
// ping-pong between two buffers
let mut bufs = [cx.gpu.buffer(w * h * nc), cx.gpu.buffer(w * h * nc)];
- for (i, (k, r)) in passes.iter().enumerate() {
- let chunk = (2 * r + 1).clamp(CHUNK, 128);
- let groups = if *k == "box_h" { groups2(w.div_ceil(chunk), h, [64, 4]) } else { groups2(w, h.div_ceil(chunk), [64, 4]) };
- let p = [w as u32, h as u32, nc as u32, *r as u32, chunk as u32];
+ for (i, (horizontal, r)) in passes.iter().enumerate() {
let (out, prev) = (&bufs[i % 2], &bufs[(i + 1) % 2]);
- cx.run(k, &p, &[Some(if i == 0 { src } else { prev }), Some(out)], groups);
+ box_pass(cx, *horizontal, if i == 0 { src } else { prev }, out, (w, h), nc, *r);
}
let last = (passes.len() - 1) % 2;
let [a, b] = std::mem::replace(&mut bufs, [cx.gpu.buffer(0), cx.gpu.buffer(0)]);
@@ -712,6 +717,27 @@ pub fn render(
_ => Err(m),
}
});
+ // every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once).
+ // The masks buffer holds each visible mask's alpha back-to-back in the same `list` order the
+ // single-mask overlay uses, so read the whole buffer back in ONE GPU->CPU transfer and split it
+ // on the CPU, instead of one synchronous readback per mask (which stalls the pipeline N times).
+ let all_overlay_alphas: Option> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) {
+ masks.as_ref().map(|all| {
+ let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect();
+ if list.is_empty() {
+ return Vec::new();
+ }
+ let raw: Vec = cx.read(all, n * list.len());
+ let mut alphas = Vec::with_capacity(list.len());
+ for mi in 0..list.len() {
+ let s = mi * n;
+ alphas.push(Plane { width: w, height: h, data: raw[s..s + n].to_vec() });
+ }
+ alphas
+ })
+ } else {
+ None
+ };
let data: Vec<[u8; 4]> = cx.read(&out, n);
let image = Rgba8 { width: w, height: h, data };
lap("finish + readback", &mut t, &mut cx);
@@ -747,7 +773,7 @@ pub fn render(
lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref())
}
});
- lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref());
+ lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref());
// spots grew the window the render worked on: cut it back to the request
if let Some(k) = plan.keep {
image = image.crop(k.x, k.y, k.w, k.h);
@@ -1132,6 +1158,33 @@ mod tests {
assert!(covered > 0 && covered < w * h, "{covered} px covered");
}
+ /// The blur against `lightcraft_raster::blur::gaussian`: widths that do and don't fill the last
+ /// workgroup, windows wider than the image, and 1, 2 (as two planes) and 3 channels.
+ #[test]
+ fn gaussian_matches_cpu() {
+ use lightcraft_raster::blur;
+ let Some(gpu) = crate::device() else { return };
+ let _scope = crate::ctx::RenderScope::new(gpu);
+ let v = |x: usize, y: usize, c: usize| ((x * 7 + y * 13 + c * 5) % 17) as f32 / 16.0 + if x % 61 < 3 { 2.0 } else { 0.0 };
+ for (w, h) in [(1000, 5), (257, 3), (5, 4), (1, 9)] {
+ for sigma in [0.8f32, 2.0, 4.5, 30.0, 250.0, 1000.0] {
+ let planes: Vec = (0..3).map(|c| Plane::from_fn(w, h, |x, y| v(x, y, c))).collect();
+ let want: Vec = planes.iter().map(|p| blur::gaussian(p, sigma)).collect();
+ for nc in [1, 2, 3] {
+ let words: Vec = (0..w * h).flat_map(|i| (0..nc).map(move |c| (i, c))).map(|(i, c)| planes[c].data[i]).collect();
+ let mut cx = Cx::new(gpu);
+ let src = gpu.upload(&words);
+ let out = gaussian(&mut cx, &src, w, h, nc, sigma);
+ let got: Vec = cx.read(&out, w * h * nc);
+ for (i, g) in got.iter().enumerate() {
+ let want = want[i % nc].data[i / nc];
+ assert!((g - want).abs() < 1e-5 * (1.0 + sigma / 10.0), "{w}×{h} nc={nc} sigma={sigma}: word {i} {g} vs {want}");
+ }
+ }
+ }
+ }
+ }
+
/// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`.
#[test]
#[ignore]
@@ -1139,6 +1192,7 @@ mod tests {
let Some(gpu) = crate::device() else { return };
let _scope = crate::ctx::RenderScope::new(gpu);
let (w, h) = (6000, 4000);
+ eprintln!("{} ({:?})", gpu.info.name, gpu.info.backend);
let src = gpu.upload(&vec![0.5f32; w * h * 3]);
let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| {
let mut best = f64::MAX;
@@ -1152,9 +1206,19 @@ mod tests {
}
eprintln!("{name:<40} {best:.1} ms");
};
+ for nc in [1, 2, 3] {
+ let out = gpu.buffer(w * h * nc);
+ // noise reduction's radii, Texture's and Dehaze's at 6000 px
+ for r in [2, 11, 120] {
+ for (k, horizontal) in [("box_h", true), ("box_v", false)] {
+ time(&format!("{k} nc={nc} r={r}"), &mut |cx| box_pass(cx, horizontal, &src, &out, (w, h), nc, r));
+ }
+ }
+ }
for nc in [1, 2, 3] {
time(&format!("gaussian nc={nc} sigma=2"), &mut |cx| drop(gaussian(cx, &src, w, h, nc, 2.0)));
}
+ time("gaussian nc=3 sigma=4.5 (colour NR)", &mut |cx| drop(gaussian(cx, &src, w, h, 3, 4.5)));
time("map log_lum", &mut |cx| {
let o = cx.gpu.buffer(w * h);
map(cx, "log_lum_k", w * h, &[], [Some(&src), None, None], &o);
diff --git a/crates/gpu/src/wgsl/blur.wgsl b/crates/gpu/src/wgsl/blur.wgsl
index b0e28370d9..375fb6d550 100644
--- a/crates/gpu/src/wgsl/blur.wgsl
+++ b/crates/gpu/src/wgsl/blur.wgsl
@@ -1,6 +1,6 @@
// Box passes (clamped edges) over an image of NC interleaved channels; three of each approximate a
-// Gaussian (`lightcraft_raster::blur::gaussian`). Each thread slides a running sum over CH pixels.
-// P: w, h, nc, r, ch. Bindings: src, dst.
+// Gaussian (`lightcraft_raster::blur::gaussian`).
+// P: w, h, nc, r, ch (box_v only). Bindings: src, dst.
// Pixel `i` (all nc ≤ 3 channels at once: one contiguous read per pixel).
fn ld(i: u32, nc: u32) -> vec3 {
@@ -25,31 +25,27 @@ fn st(i: u32, nc: u32, v: vec3) {
}
}
-// Horizontal: one thread per CH consecutive pixels of a row.
+// Horizontal: one thread per pixel, summing its 2r + 1 window; adjacent threads read adjacent
+// pixels, which the cache serves. (Threads that each slid a running sum over 16 pixels read pixels
+// 16 apart across a workgroup: on a Radeon 780M that pass took 9–15× as long as `box_v`.)
@compute @workgroup_size(64, 4)
fn box_h(@builtin(global_invocation_id) g: vec3) {
let w = pu(0u);
let h = pu(1u);
let nc = pu(2u);
let r = i32(pu(3u));
- let ch = pu(4u);
+ let x = g.x;
let y = g.y;
- let x0 = g.x * ch;
- if (y >= h || x0 >= w) {
+ if (x >= w || y >= h) {
return;
}
let last = i32(w) - 1;
- let inv = 1.0 / f32(2 * r + 1);
let row = y * w;
- let x1 = min(x0 + ch, w);
var acc = vec3(0.0);
for (var k = -r; k <= r; k++) {
- acc += ld(row + u32(clamp(i32(x0) + k, 0, last)), nc);
- }
- for (var x = x0; x < x1; x++) {
- st(row + x, nc, acc * inv);
- acc = acc + ld(row + u32(min(i32(x) + r + 1, last)), nc) - ld(row + u32(max(i32(x) - r, 0)), nc);
+ acc += ld(row + u32(clamp(i32(x) + k, 0, last)), nc);
}
+ st(row + x, nc, acc * (1.0 / f32(2 * r + 1)));
}
// Vertical: one thread per CH consecutive rows of a column (the CPU re-primes every BAND rows the
diff --git a/crates/heif/Cargo.toml b/crates/heif/Cargo.toml
index b3bc69d627..95641fb2fd 100644
--- a/crates/heif/Cargo.toml
+++ b/crates/heif/Cargo.toml
@@ -11,11 +11,11 @@ license.workspace = true
# Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the
# other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL)
# code structure; gamut-heic has no HEVC decoder of its own).
-heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] }
+heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] }
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
-heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] }
+heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] }
rayon = { workspace = true }
[features]
diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs
index 4772363b76..73a02624e6 100644
--- a/crates/mcp/src/backend.rs
+++ b/crates/mcp/src/backend.rs
@@ -65,22 +65,46 @@ impl Remote {
Err(last)
}
- fn roundtrip(&mut self, line: &str) -> std::io::Result {
+ /// Send one request line and read the reply line. On failure the flag says whether the request
+ /// may have reached the app (written in full); only an unsent request is safe to resend.
+ fn roundtrip(&mut self, line: &str) -> Result {
+ // A connection the app already closed (it restarted, or closed it after an error reply) is
+ // replaced before anything is sent on it, so that case still reconnects without a resend.
+ if self.conn.as_ref().is_some_and(|(reader, writer)| !reader.buffer().is_empty() || peer_closed(writer)) {
+ self.conn = None;
+ }
if self.conn.is_none() {
- self.reconnect()?;
+ self.reconnect().map_err(|e| (e, false))?;
}
let Some((reader, writer)) = self.conn.as_mut() else {
- return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
+ return Err((std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"), false));
};
- writer.write_all(line.as_bytes())?;
- writer.write_all(b"\n")?;
- writer.flush()?;
+ let mut data = Vec::with_capacity(line.len() + 1);
+ data.extend_from_slice(line.as_bytes());
+ data.push(b'\n');
+ writer.write_all(&data).map_err(|e| (e, false))?;
+ writer.flush().map_err(|e| (e, true))?;
let mut reply = String::new();
- if reader.read_line(&mut reply)? == 0 {
- return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
+ if reader.read_line(&mut reply).map_err(|e| (e, true))? == 0 {
+ return Err((std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"), true));
}
Ok(reply)
}
+
+ fn unknown_outcome(&self, method: &str, e: &std::io::Error) -> String {
+ let addr = &self.addr;
+ format!("lost the LightCraft app at {addr} after sending `{method}` ({e}); the outcome is unknown and it was not resent, check the app")
+ }
+}
+
+/// True when the app has closed `stream` (or it is unusable), checked without blocking or consuming data.
+fn peer_closed(stream: &TcpStream) -> bool {
+ if stream.set_nonblocking(true).is_err() {
+ return true;
+ }
+ let mut byte = [0u8; 1];
+ let open = matches!(stream.peek(&mut byte), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock);
+ stream.set_nonblocking(false).is_err() || !open
}
impl Backend for Remote {
@@ -88,16 +112,26 @@ impl Backend for Remote {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
- // One retry with a fresh connection (the app may have restarted).
+ // Retry once with a fresh connection (the app may have restarted), but only when the request
+ // never reached the app. After a full write the command may have run, so resending it could
+ // apply a mutation twice (toggle, undo, ...): report the unknown outcome instead.
let reply = match self.roundtrip(&line) {
Ok(r) => r,
- Err(_) => {
+ Err((_, false)) => {
self.conn = None;
- self.roundtrip(&line).map_err(|e| {
+ self.roundtrip(&line).map_err(|(e, sent)| {
self.conn = None;
- format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
+ if sent {
+ self.unknown_outcome(method, &e)
+ } else {
+ format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
+ }
})?
}
+ Err((e, true)) => {
+ self.conn = None;
+ return Err(self.unknown_outcome(method, &e));
+ }
};
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
if v.get("ok").and_then(Value::as_bool) == Some(true) {
@@ -115,3 +149,66 @@ impl Backend for Remote {
format!("remote {}", self.addr)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::net::TcpListener;
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ #[test]
+ fn lost_reply_is_not_resent() {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap().to_string();
+ let seen = Arc::new(AtomicUsize::new(0));
+ let seen2 = seen.clone();
+ std::thread::spawn(move || {
+ // Every connection: read the request, count it, drop without replying.
+ while let Ok((s, _)) = listener.accept() {
+ let mut line = String::new();
+ if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 {
+ seen2.fetch_add(1, Ordering::SeqCst);
+ }
+ }
+ });
+ let mut remote = Remote::lazy(&addr);
+ let err = remote.call("engine.execute", json!({})).unwrap_err();
+ assert!(err.contains("outcome is unknown"), "{err}");
+ std::thread::sleep(Duration::from_millis(200));
+ assert_eq!(seen.load(Ordering::SeqCst), 1);
+ }
+
+ #[test]
+ fn connection_closed_by_app_reconnects_before_sending() {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap().to_string();
+ let seen = Arc::new(AtomicUsize::new(0));
+ let seen2 = seen.clone();
+ std::thread::spawn(move || {
+ // Every connection: answer one request, then close (as after an app restart).
+ while let Ok((s, _)) = listener.accept() {
+ let mut writer = s.try_clone().unwrap();
+ let mut line = String::new();
+ if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 {
+ seen2.fetch_add(1, Ordering::SeqCst);
+ writer.write_all(b"{\"ok\": true, \"result\": 7}\n").unwrap();
+ }
+ }
+ });
+ let mut remote = Remote::lazy(&addr);
+ assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(7));
+ std::thread::sleep(Duration::from_millis(200));
+ assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(7));
+ assert_eq!(seen.load(Ordering::SeqCst), 2);
+ }
+
+ #[test]
+ fn unreachable_app_is_reported() {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap().to_string();
+ drop(listener);
+ let err = Remote::lazy(&addr).call("engine.commands", json!({})).unwrap_err();
+ assert!(err.contains("not reachable"), "{err}");
+ }
+}
diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs
index 69743a1b34..9ce00a5050 100644
--- a/crates/merge/src/frame.rs
+++ b/crates/merge/src/frame.rs
@@ -6,7 +6,7 @@ use lightcraft_geom::Orientation;
use lightcraft_meta::Metadata;
use lightcraft_raster::Rgb32f;
use lightcraft_raster::resample::{Filter, fit};
-use lightcraft_raw::ColorData;
+use lightcraft_raw::{ColorData, Opcode};
use crate::{MergeError, Result};
@@ -36,6 +36,10 @@ pub struct Frame {
pub metadata: Metadata,
/// Raw files: the source's `BaselineExposure` (EV), carried into the output.
pub baseline_exposure: f64,
+ /// Raw files: the lens corrections the source applies (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`,
+ /// in order), re-expressed for `image` (the default crop, not yet turned), for the merged DNG to carry. Empty for
+ /// other sources and once [`Frame::orient`] has turned the pixels.
+ pub lens: Vec,
}
impl Frame {
@@ -53,6 +57,7 @@ impl Frame {
orientation: Orientation::Normal,
metadata: Metadata::default(),
baseline_exposure: 0.0,
+ lens: Vec::new(),
}
}
@@ -63,11 +68,12 @@ impl Frame {
self.image.height
}
- /// Apply the display orientation to the pixels.
+ /// Apply the display orientation to the pixels. The lens corrections, written for the unturned pixels, are dropped.
pub fn orient(&mut self) {
if self.orientation != Orientation::Normal {
self.image = self.image.oriented(self.orientation);
self.orientation = Orientation::Normal;
+ self.lens.clear();
}
}
}
@@ -120,7 +126,21 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo
let exposure = exif_exposure(&metadata);
if lightcraft_raw::probe(bytes).is_some() {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?;
- // geometric lens corrections stay with the develop settings ("profile corrections")
+ // Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and
+ // the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps.
+ // The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional.
+ let (aw, ah) = (raw.active_area.width, raw.active_area.height);
+ let crop = raw.develop_crop(aw, ah);
+ let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3)
+ .filter(|op| op.is_lens_correction())
+ .filter_map(|op| op.cropped(aw, ah, crop))
+ .map(|op| match op {
+ Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => {
+ Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false }
+ }
+ op => op,
+ })
+ .collect();
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
if let Some(color) = colour(&mut raw, bytes) {
raw.color = color;
@@ -146,6 +166,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo
orientation: raw.orientation,
metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() },
baseline_exposure: raw.color.baseline_exposure,
+ lens,
};
if f.exposure.is_none() {
f.exposure = exif_exposure(&f.metadata);
@@ -176,6 +197,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo
orientation: Orientation::from_exif(d.orientation),
metadata,
baseline_exposure: 0.0,
+ lens: Vec::new(),
};
if orient {
f.orient();
diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs
index 7c596c4a7b..59aa5286ce 100644
--- a/crates/merge/src/hdr.rs
+++ b/crates/merge/src/hdr.rs
@@ -83,6 +83,8 @@ pub struct HdrResult {
pub orientation: lightcraft_geom::Orientation,
pub metadata: lightcraft_meta::Metadata,
pub baseline_exposure: f64,
+ /// The reference frame's lens corrections, for `radiance` ([`Frame::lens`]).
+ pub lens: Vec,
}
#[inline]
@@ -277,6 +279,7 @@ pub fn merge_hdr(mut frames: Vec , opts: &HdrOptions, progress: &Progress)
orientation: rf.orientation,
metadata: rf.metadata.clone(),
baseline_exposure: rf.baseline_exposure,
+ lens: rf.lens.clone(),
})
}
diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs
index 8128644b66..f8b7e7173e 100644
--- a/crates/merge/src/output.rs
+++ b/crates/merge/src/output.rs
@@ -8,12 +8,14 @@
//! still camera RGB.
//! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB,
//! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1).
+//! - Lens corrections (an HDR merge's: the reference frame's, [`crate::Frame::lens`]) go in `OpcodeList3`, where the
+//! develop settings' profile corrections find them as they do in the sources.
use lightcraft_color::{D50, D65, Mat3, bradford};
use lightcraft_geom::Orientation;
use lightcraft_meta::Metadata;
use lightcraft_raster::Rgb32f;
-use lightcraft_raw::{BlackLevel, ColorData, DngCompression, DngWriteOptions, OpcodeLists, RawData, RawFormat, RawImage, Rect};
+use lightcraft_raw::{BlackLevel, ColorData, DngCompression, DngWriteOptions, Opcode, OpcodeLists, RawData, RawFormat, RawImage, Rect};
use crate::frame::FrameColor;
use crate::{MergeError, Result};
@@ -59,7 +61,8 @@ pub fn color_data(color: &FrameColor) -> ColorData {
}
}
-/// Encode `img` (scene-linear, 1.0 = source white) as a LinearRaw DNG.
+/// Encode `img` (scene-linear, 1.0 = source white) as a LinearRaw DNG, with `lens` (lens corrections for `img`, not
+/// yet turned by `orientation`) as its `OpcodeList3`.
pub fn write_linear_dng(
img: &Rgb32f,
color: &FrameColor,
@@ -67,6 +70,7 @@ pub fn write_linear_dng(
metadata: &Metadata,
baseline_exposure: f64,
samples: DngSamples,
+ lens: &[Opcode],
) -> Result> {
let (w, h) = (img.width, img.height);
if w == 0 || h == 0 {
@@ -107,7 +111,7 @@ pub fn write_linear_dng(
color: cd,
wb_multipliers: None,
linearized: false,
- opcodes: OpcodeLists::default(),
+ opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() },
metadata: meta,
};
let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half };
@@ -122,7 +126,7 @@ mod tests {
fn linear_dng_round_trips_through_the_raw_decoder() {
let img = Rgb32f::from_fn(80, 60, |x, y| [x as f32 / 20.0, y as f32 / 30.0, 0.3]);
let color = FrameColor::Linear { to_xyz_d50: bradford(D65, D50).mul(&lightcraft_color::SRGB.to_xyz()) };
- let bytes = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::Half).unwrap();
+ let bytes = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::Half, &[]).unwrap();
let raw = lightcraft_raw::decode(&bytes).unwrap();
assert_eq!((raw.width, raw.height, raw.cpp), (80, 60, 3));
let gain = 2f32.powf(raw.color.baseline_exposure as f32);
@@ -145,7 +149,7 @@ mod tests {
for c in 0..3 {
assert!((red[c] / k - want[c]).abs() < 5e-3, "{red:?} vs {want:?}");
}
- let u16 = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::U16).unwrap();
+ let u16 = write_linear_dng(&img, &color, Orientation::Normal, &Metadata::default(), 0.0, DngSamples::U16, &[]).unwrap();
assert!(lightcraft_raw::decode(&u16).is_ok());
}
}
diff --git a/crates/merge/src/synth.rs b/crates/merge/src/synth.rs
index d15646ecba..223cb30218 100644
--- a/crates/merge/src/synth.rs
+++ b/crates/merge/src/synth.rs
@@ -48,7 +48,7 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result>> {
iso: Some(100),
..Default::default()
};
- write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16)
+ write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16, &[])
})
.collect()
}
diff --git a/crates/pipeline/src/geometry.rs b/crates/pipeline/src/geometry.rs
index bea792c19f..3f2766750c 100644
--- a/crates/pipeline/src/geometry.rs
+++ b/crates/pipeline/src/geometry.rs
@@ -16,7 +16,7 @@ use crate::optics::{Warp, reorient_lens};
pub const BLANK: [f32; 3] = [0.55, 0.55, 0.55];
/// The geometric frame of a render.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq)]
pub struct Frame {
/// Source (EXIF-oriented) dimensions in pixels.
pub src_w: usize,
@@ -167,10 +167,12 @@ impl Frame {
}
}
- /// Aspect ratio (w/h) of the output.
+ /// Aspect ratio (w/h) of the output. A crop narrower than a source pixel (or of no size, from
+ /// an agent or a sidecar) counts each side as one pixel, so the aspect stays finite: 0/0 made
+ /// it NaN, and the loupe laid the photo out at a NaN size.
pub fn aspect(&self) -> f64 {
let r = self.crop.rect_px(self.ow, self.oh);
- r.width() / r.height()
+ r.width().max(1.0) / r.height().max(1.0)
}
/// Size of the (cropped) output at the source's own resolution, in pixels.
@@ -383,6 +385,25 @@ mod tests {
assert!((back.x - 50.0).abs() < 1e-6 && (back.y - 50.0).abs() < 1e-6);
}
+ /// A crop of no size (or under a pixel wide) gave a NaN or infinite aspect; each side now
+ /// counts as at least one source pixel.
+ #[test]
+ fn degenerate_crops_have_a_finite_aspect() {
+ let mut s = DevelopSettings::default();
+ for (rect, want) in [
+ (Rect::new(0.5, 0.5, 0.5, 0.5), 1.0),
+ (Rect::new(0.0, 0.0, 1e-9, 1.0), 1.0 / 200.0),
+ (Rect::new(0.0, 0.5, 1.0, 0.5), 400.0),
+ (Rect::new(0.25, 0.0, 0.75, 1.0), 1.0),
+ ] {
+ s.crop.geometry.rect = rect;
+ let f = Frame::new(400, 200, &s, true);
+ assert!((f.aspect() - want).abs() < 1e-9, "{rect:?}: {}", f.aspect());
+ let (w, h) = f.fit(1000, 1000);
+ assert!((1..=1000).contains(&w) && (1..=1000).contains(&h), "{rect:?}: {w}×{h}");
+ }
+ }
+
#[test]
fn flip_mirrors() {
let mut s = DevelopSettings::default();
diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs
index 27d5d05438..d94d3a2fc3 100644
--- a/crates/pipeline/src/lib.rs
+++ b/crates/pipeline/src/lib.rs
@@ -600,7 +600,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
lap("histogram", &mut t);
let mut image = image;
let mask = overlay_alpha(req.overlay, &plan, &prep);
- visualize::apply(&mut image, req.overlay, &plan, mask.as_ref());
+ let masks = overlay_alphas_all(req.overlay, &plan, &prep);
+ visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref());
let mut image = if plan.keep.is_some() { cut(&image) } else { image };
if req.overlay == Overlay::HdrRange && edit.hdr.enabled {
// the HDR rendition of the same request says how far above SDR white each pixel goes
@@ -652,6 +653,24 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option
Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()))
}
+/// The alpha planes an `Overlay::AllMasks` shows: one per visible mask that has components,
+/// reusing the render's own evaluation where available (a fresh evaluation otherwise).
+fn overlay_alphas_all(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option> {
+ if !matches!(o, Overlay::AllMasks { .. }) {
+ return None;
+ }
+ let ev = plan.settings.light.exposure as f32;
+ let mut out = Vec::new();
+ for m in plan.settings.masks.iter().filter(|m| m.visible && !m.components.is_empty()) {
+ if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) {
+ out.push(e.alpha.clone());
+ } else {
+ out.push(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref()));
+ }
+ }
+ Some(out)
+}
+
/// Convenience: render a before/after pair side by side is up to the UI; this renders "before"
/// (default look, keeping the crop so framing matches, under the photo's own rendering process).
pub fn before_settings(s: &DevelopSettings) -> DevelopSettings {
diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs
index ce3994cd8b..a99530777f 100644
--- a/crates/pipeline/src/local.rs
+++ b/crates/pipeline/src/local.rs
@@ -543,12 +543,35 @@ pub(crate) fn prepare(
/// The airlight of the whole output frame, estimated on a small render of it (`proxy_w` px wide):
/// a windowed render must dehaze with the frame's airlight, not that of the pixels it holds.
pub(crate) fn frame_airlight(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, frame: &Frame, proxy_w: usize, proxy_h: usize) -> f32 {
- let mut img = frame.sample(src, proxy_w, proxy_h);
+ let mut img = Rgb32f::clone(&frame_proxy(src, frame, proxy_w, proxy_h));
white_balance(&mut img, info, s);
let sigma = (0.02 * frame.px_per_long(proxy_w)).max(1.0) as f32;
airlight(&gaussian(&img.map(dark_of), sigma))
}
+/// How many airlight proxies [`frame_proxy`] keeps (one per photo and frame being zoomed into; each ≤ 1.8 MB).
+const FRAME_PROXIES_KEPT: usize = 4;
+
+/// `frame.sample(src, w, h)`, kept for the last few (source, frame, size): sampling a 64 MP original down to the
+/// airlight proxy reads all of it (~190 ms of CPU), and every window of a zoomed view with Dehaze asked for it again,
+/// on each pan and slider tick. The proxy is taken before white balance, so a WB change still hits.
+fn frame_proxy(src: &Rgb32f, frame: &Frame, w: usize, h: usize) -> Arc {
+ type Entry = (u64, Frame, usize, usize, Arc