diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index f39de00b9..8953710ce 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 4a736f12a..136e821bb 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/README.md b/README.md
index 2aea14d87..f041111a1 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 9a5331435..0a7feb32c 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 bb713a2e7..1c70e19ea 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 79f20c726..27a8c8d9c 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 dd92b5e2d..98fd616a3 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 ecde973ba..ed4cb75b4 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 be6ab6cdc..a9956703d 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 df239a177..fa067de0f 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 04a9f0beb..6ffb11f52 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 556f77def..51a9db10a 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_repair.rs b/crates/catalog/src/tests_repair.rs
new file mode 100644
index 000000000..493c89544
--- /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 74d1e3247..4294f9369 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 1df96a9e1..987a859b9 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 fc84a36cf..074963695 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 d8bbba411..a331f0fe5 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 000000000..c45cb6857
--- /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 6a473a2de..3d7dbc445 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 000000000..e9d8f2957
--- /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 fd1c6f24b..01394cd5f 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 fcfcb2cd0..528e901d3 100644
--- a/crates/engine/src/camera_preview.rs
+++ b/crates/engine/src/camera_preview.rs
@@ -346,6 +346,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 1ae72e48e..6f064b260 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 ffdedbef1..4676d1c65 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 ae8b862b6..25ff3f24d 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 2e0b6f157..fbe4608f0 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 6af62637f..cd89aa896 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 091bf5d00..b3ded1fbd 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 b7488a023..9de8c3d64 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 e53dfcbc4..403cb71f6 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 000000000..c5903ee5f
--- /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 dedb41f9c..b71fc9b53 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()));
@@ -622,11 +623,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());
@@ -634,9 +640,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) {
@@ -653,7 +664,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) {
@@ -671,16 +682,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;
@@ -708,7 +721,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)
@@ -729,6 +746,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 {
@@ -743,7 +763,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
@@ -751,7 +773,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}"))?;
@@ -773,7 +795,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);
@@ -796,7 +818,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() {
@@ -838,7 +860,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 {
@@ -867,19 +889,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()
@@ -888,7 +911,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;
@@ -1125,7 +1154,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");
@@ -1134,7 +1163,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");
@@ -1142,8 +1171,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);
}
@@ -1163,6 +1192,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 {
@@ -1189,6 +1224,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]));
@@ -1216,7 +1260,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");
@@ -1225,7 +1269,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");
@@ -1234,7 +1278,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);
}
@@ -1262,17 +1338,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();
@@ -1285,13 +1361,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");
}
@@ -2296,4 +2379,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 1f008e766..ecb80741e 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 000000000..9adadca8a
--- /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 db4421369..c92ee2796 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 9631b7d84..7e05fcffd 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),
}
@@ -1001,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;
diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs
index ffd6fc183..5e7cb02d4 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 900fe4a26..803707c1c 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 8c421668d..49094a1f3 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 6a51dc316..ef9fd00f9 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 044139ff9..3b36316fb 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/segment/download.rs b/crates/engine/src/segment/download.rs
index 7147db09d..57ba4f341 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 ec2c4ba85..c955dcc01 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 9aaf7e937..c67a2c84f 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 000000000..43170f4f6
--- /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/sync.rs b/crates/engine/src/sync.rs
index 0a00a59ab..4342ec6ad 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 6aad89b31..4e5e73850 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 f1c27c849..a308f20fb 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 000000000..3af23f0da
--- /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_move.rs b/crates/engine/src/tests_import_move.rs
index 3e6e9208b..ce249df16 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 27a82fbed..cb42dbc2a 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_segment.rs b/crates/engine/src/tests_segment.rs
index ccca1cb23..f4b9f4f48 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/tests/preview_read.rs b/crates/engine/tests/preview_read.rs
index 548e4b753..5c950069b 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 60ac1d329..8bad4003b 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 e900850ac..c2b5bee1e 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 04ae60958..b7fa5e12d 100644
--- a/crates/gpu/src/render.rs
+++ b/crates/gpu/src/render.rs
@@ -245,10 +245,9 @@ fn box_pass(cx: &mut Cx<'_>, horizontal: bool, src: &Buf, dst: &Buf, (w, h): (us
/// 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<(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);
diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs
index 4772363b7..73a02624e 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 69743a1b3..9ce00a505 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 7c596c4a7..59aa5286c 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 8128644b6..f8b7e7173 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 d15646ecb..223cb3021 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 bea792c19..3f2766750 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/local.rs b/crates/pipeline/src/local.rs
index ce3994cd8..a99530777 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);
+ static CACHE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new());
+ let key = crate::optics::fingerprint(src);
+ let found = |c: &[Entry]| c.iter().find(|e| e.0 == key && e.1 == *frame && (e.2, e.3) == (w, h)).map(|e| e.4.clone());
+ if let Some(img) = found(&CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)) {
+ return img;
+ }
+ let img = Arc::new(frame.sample(src, w, h));
+ let mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+ c.push((key, frame.clone(), w, h, img.clone()));
+ if c.len() > FRAME_PROXIES_KEPT {
+ c.remove(0);
+ }
+ img
+}
+
/// The airlight is estimated from every `AIRLIGHT_STEP`-th value of the dark channel.
pub const AIRLIGHT_STEP: usize = 7;
@@ -600,6 +623,36 @@ mod tests {
assert!((luminance_2020(c) - 0.3).abs() < 0.01);
}
+ // A windowed render's airlight proxy is kept per (source, frame, size): the airlight is the one sampling anew gives,
+ // and another source or frame of the same size gets its own, never the proxy kept for the last one
+ #[test]
+ fn the_kept_airlight_proxy_matches_a_fresh_one_and_misses_on_another_source_or_frame() {
+ let (info, s) = (SourceInfo::default(), DevelopSettings::default());
+ let hazy = Rgb32f::from_fn(300, 200, |x, y| {
+ let v = 0.05 + 0.6 * x as f32 / 300.0;
+ [v, v * 0.95, v * (0.8 + 0.2 * y as f32 / 200.0)]
+ });
+ let other = Rgb32f::from_fn(300, 200, |x, y| [0.02 + 0.1 * ((x + y) % 7) as f32 / 7.0; 3]);
+ let fresh = |src: &Rgb32f, frame: &Frame| {
+ let mut img = frame.sample(src, 48, 32);
+ white_balance(&mut img, &info, &s);
+ airlight(&gaussian(&img.map(dark_of), (0.02 * frame.px_per_long(48)).max(1.0) as f32))
+ };
+ let frame = crate::frame_for(&hazy, &info, &s, true);
+ let kept = frame_proxy(&hazy, &frame, 48, 32);
+ assert!(Arc::ptr_eq(&kept, &frame_proxy(&hazy, &frame, 48, 32)), "the second ask is served from the cache");
+ assert_eq!(frame_airlight(&hazy, &info, &s, &frame, 48, 32).to_bits(), fresh(&hazy, &frame).to_bits());
+ assert_eq!(frame_airlight(&hazy, &info, &s, &frame, 48, 32).to_bits(), fresh(&hazy, &frame).to_bits());
+ // another source of the same size
+ assert_ne!(fresh(&other, &frame), fresh(&hazy, &frame));
+ assert_eq!(frame_airlight(&other, &info, &s, &frame, 48, 32).to_bits(), fresh(&other, &frame).to_bits());
+ // the same source, cropped to its darker half
+ let mut cropped = frame.clone();
+ cropped.crop.rect.x1 = 0.5;
+ assert_ne!(fresh(&hazy, &cropped), fresh(&hazy, &frame));
+ assert_eq!(frame_airlight(&hazy, &info, &s, &cropped, 48, 32).to_bits(), fresh(&hazy, &cropped).to_bits());
+ }
+
#[test]
fn exposure_doubles() {
let mut s = DevelopSettings::default();
diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs
index 0628a6a6f..14fa558f8 100644
--- a/crates/pipeline/src/masks.rs
+++ b/crates/pipeline/src/masks.rs
@@ -398,7 +398,9 @@ fn box_down(m: &Image, f: usize) -> Plane {
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5), edges extended.
fn bilinear(p: &Plane, x: f32, y: f32) -> f32 {
- let (fx, fy) = (x - 0.5, y - 0.5);
+ // (held a pixel outside, which reads the same edge: an infinite coordinate reads the edge
+ // instead of a NaN weight and a neighbour index past isize::MAX)
+ let (fx, fy) = (x.clamp(-1.0, p.width as f32 + 1.0) - 0.5, y.clamp(-1.0, p.height as f32 + 1.0) - 0.5);
let (x0, y0) = (fx.floor(), fy.floor());
let (tx, ty) = (fx - x0, fy - y0);
let (x0, y0) = (x0 as isize, y0 as isize);
diff --git a/crates/pipeline/src/optics.rs b/crates/pipeline/src/optics.rs
index 843f1e822..31e8d1351 100644
--- a/crates/pipeline/src/optics.rs
+++ b/crates/pipeline/src/optics.rs
@@ -407,8 +407,9 @@ pub(crate) fn fingerprint(img: &Rgb32f) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ ((img.width as u64) << 32 | img.height as u64);
let n = img.data.len();
for i in 0..512usize {
- let p = img.data[(i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)];
- for c in p {
+ // (an empty image has no pixels to sample: its dimensions are the fingerprint)
+ let Some(p) = img.data.get((i.wrapping_mul(2_654_435_761) ^ i) % n.max(1)) else { break };
+ for c in *p {
h ^= c.to_bits() as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
@@ -578,9 +579,13 @@ pub fn defringe(img: &mut Rgb32f, s: &DevelopSettings, scale: f64) {
return;
}
let (w, h) = (img.width, img.height);
- let max_amt = o.defringe_purple_amount.max(o.defringe_green_amount);
- // fringe width in source px grows with the amount (1..7 px), in output px at least one
- let radius = ((1.0 + max_amt * 0.3) * scale).round().max(1.0) as usize;
+ // as far as the sliders go (0–20): an amount from a damaged catalog or sidecar made the
+ // radius overflow
+ let max_amt = o.defringe_purple_amount.max(o.defringe_green_amount).min(20.0);
+ // fringe width in source px grows with the amount (1..7 px), in output px at least one (and
+ // never wider than the picture)
+ let radius = ((1.0 + max_amt * 0.3) * scale).round();
+ let radius = if radius.is_finite() { radius.clamp(1.0, w.max(h).max(1) as f64) as usize } else { 1 };
// perceptual lightness and its local range (dilated contrast)
let lab: Vec<[f32; 3]> = img.data.iter().map(|p| oklab_from_2020([p[0].max(0.0), p[1].max(0.0), p[2].max(0.0)])).collect();
let l = Plane { width: w, height: h, data: lab.iter().map(|p| p[0]).collect() };
@@ -648,6 +653,13 @@ fn min_filter(p: &Plane, r: usize, max: bool) -> Plane {
mod tests {
use super::*;
+ // An empty image (no pixel to sample) has a fingerprint, its dimensions' (indexing its first pixel panicked)
+ #[test]
+ fn an_empty_image_has_a_fingerprint() {
+ assert_eq!(fingerprint(&Rgb32f::new(0, 0)), fingerprint(&Rgb32f::new(0, 0)));
+ assert_ne!(fingerprint(&Rgb32f::new(0, 3)), fingerprint(&Rgb32f::new(3, 0)));
+ }
+
/// splitmix64: deterministic pseudo-random numbers for the warp tests.
struct Rng(u64);
@@ -1095,4 +1107,31 @@ mod tests {
assert!(chroma(img.get(20, 10)) < chroma(purple) * 0.3);
assert!((chroma(flat2.get(20, 10)) - chroma(purple)).abs() < 1e-4);
}
+
+ /// Found by fuzzing catalog snapshots: a defringe amount far past the sliders' 20 (from a
+ /// damaged catalog or sidecar) made the filter's radius overflow (`x + r`), which panics.
+ /// It acts as the sliders' end.
+ #[test]
+ fn defringe_amounts_past_the_sliders_act_as_their_end() {
+ let purple = [0.25f32, 0.05, 0.45];
+ let scene = Rgb32f::from_fn(40, 20, |x, _| {
+ if x < 20 {
+ [0.9; 3]
+ } else if x == 20 {
+ purple
+ } else {
+ [0.02; 3]
+ }
+ });
+ let at = |amount: f64, scale: f64| {
+ let mut s = DevelopSettings::default();
+ s.optics.defringe_purple_amount = amount;
+ s.optics.defringe_green_amount = amount / 2.0;
+ let mut img = scene.clone();
+ defringe(&mut img, &s, scale);
+ img
+ };
+ assert_eq!(at(1e300, 1.0).data, at(20.0, 1.0).data);
+ assert_eq!(at(f64::MAX, 1e300).data.len(), 800, "a huge scale too");
+ }
}
diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs
index 3bb775df4..315f7f956 100644
--- a/crates/pipeline/src/spots.rs
+++ b/crates/pipeline/src/spots.rs
@@ -18,10 +18,27 @@ use crate::geometry::Frame;
#[inline]
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
+ // Zero feather is a hard edge, including when the pixel lies exactly on the radius.
+ // Dividing 0 by 0 here would give that pixel a NaN alpha, even at zero opacity.
+ if e0 >= e1 {
+ return if x < e1 { 0.0 } else { 1.0 };
+ }
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
+/// Most a spot's size counts for, in long edges: a size read from a sidecar isn't held to the slider's range, and one
+/// far past the picture (or infinite) made the radius, the blur it heals with and the patch it reads from unbounded
+/// (issue #1519). Two long edges cover the whole frame from anywhere in it.
+const MAX_SPOT_SIZE: f64 = 2.0;
+
+/// A spot's radius in output pixels, `ppl` output pixels to the long edge: at least a pixel (a size that is not a
+/// number too), at most [`MAX_SPOT_SIZE`] long edges.
+fn radius_px(size: f64, ppl: f64) -> f64 {
+ let size = if size.is_nan() { 0.0 } else { size.min(MAX_SPOT_SIZE) };
+ (size * ppl).max(1.0)
+}
+
/// Mean colour of `img` in a disc/annulus.
fn ring_stats(img: &Rgb32f, c: (f32, f32), r0: f32, r1: f32) -> Option> {
let mut out = Vec::new();
@@ -99,7 +116,7 @@ pub fn pick_source(
let img = frame.sample(src, w, h);
let (to_out, to_norm) = (frame.norm_to_out(w, h), frame.out_to_norm(w, h));
let t = to_out.apply(*spot.points.first()?);
- let r = (spot.size * frame.px_per_long(w)).max(1.0) as f32;
+ let r = radius_px(spot.size, frame.px_per_long(w)) as f32;
let avoid = avoid.map(|o| {
let (a, b) = (to_out.apply(Point::new(0.0, 0.0)), to_out.apply(o));
((b.x - a.x) as f32, (b.y - a.y) as f32)
@@ -165,7 +182,7 @@ pub fn window_for_reads_checked(
let boxes: Vec<([f64; 4], [f64; 4])> = spots
.iter()
.filter_map(|spot| {
- let r = (spot.size * ppl).max(1.0);
+ let r = radius_px(spot.size, ppl);
let pts: Vec<_> = spot.points.iter().map(|p| to_out.apply(*p)).collect();
let first = pts.first()?;
let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.x, first.y);
@@ -261,7 +278,7 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
let (w, h) = (img.width, img.height);
let to_out = frame.norm_to_out(w, h);
for spot in spots {
- let r = (spot.size * ppl).max(1.0) as f32;
+ let r = radius_px(spot.size, ppl) as f32;
let pts: Vec<(f32, f32)> = spot
.points
.iter()
@@ -369,6 +386,51 @@ mod tests {
use super::*;
use lightcraft_develop::DevelopSettings;
+ #[test]
+ fn hard_edge_spots_at_zero_opacity_leave_every_pixel_unchanged() {
+ for mode in [SpotMode::Clone, SpotMode::Heal, SpotMode::Remove, SpotMode::Fill] {
+ for (cx, cy) in [(32.5, 24.5), (0.5, 0.5)] {
+ let original = Rgb32f::from_fn(64, 48, |x, y| [0.2 + x as f32 / 128.0, 0.3 + y as f32 / 128.0, 0.4]);
+ let mut img = original.clone();
+ let frame = Frame::new(64, 48, &DevelopSettings::default(), true);
+ let spot = Spot {
+ mode,
+ points: vec![Point::new(cx / 64.0, cy / 48.0)],
+ size: 5.0 / 64.0,
+ feather: 0.0,
+ opacity: 0.0,
+ source_offset: Some(Point::new(8.0 / 64.0, 0.0)),
+ };
+ apply(&mut img, &[spot], &frame, frame.px_per_long(64));
+ for (i, (actual, expected)) in img.data.iter().zip(&original.data).enumerate() {
+ assert_eq!(actual, expected, "zero-opacity {mode:?} at ({cx}, {cy}), pixel {i}");
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn hard_edge_clone_copies_inside_and_preserves_pixels_on_the_radius() {
+ for (w, h, cx, cy, radius) in [(64, 48, 32.5, 24.5, 5.0), (128, 96, 64.5, 48.5, 10.0)] {
+ let original = Rgb32f::from_fn(w, h, |x, y| [0.2 + x as f32 / w as f32, y as f32 / h as f32, 0.4]);
+ let mut img = original.clone();
+ let frame = Frame::new(w, h, &DevelopSettings::default(), true);
+ let spot = Spot {
+ mode: SpotMode::Clone,
+ points: vec![Point::new(cx / w as f64, cy / h as f64)],
+ size: radius / w as f64,
+ feather: 0.0,
+ opacity: 100.0,
+ source_offset: Some(Point::new(0.125, 0.0)),
+ };
+ apply(&mut img, &[spot], &frame, frame.px_per_long(w));
+ let (x, y) = (cx as usize, cy as usize);
+ assert_eq!(img.get(x, y), original.get(x + w / 8, y), "inside copies the source at {w}px");
+ assert_eq!(img.get(x + radius as usize, y), original.get(x + radius as usize, y), "radius stays unchanged at {w}px");
+ assert!(img.data.iter().flatten().all(|v| v.is_finite()));
+ }
+ }
+
#[test]
fn remove_erases_a_dot() {
// flat grey with a gentle gradient and a black dot in the middle
diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs
index 2398feece..00851fa2a 100644
--- a/crates/pipeline/src/tests.rs
+++ b/crates/pipeline/src/tests.rs
@@ -711,3 +711,27 @@ fn stored_process_numbers_render_with_a_known_process() {
}
}
}
+
+/// A crop a hair wide (or of no size, as a command or a sidecar can set) shown to fit made the
+/// tone, clarity and dehaze stages blur with sigmas of millions of pixels (infinite for no size):
+/// the box width's square overflowed (a panic in debug builds, a radius of billions of pixels and
+/// a hung render in release ones). The blur is held to twice the picture's size: the render is
+/// quick and its pixels are numbers.
+#[test]
+fn sliver_crops_render_quickly_with_wide_stages_on() {
+ let src = scene();
+ for (rect, angle) in [([0.0, 0.0, 1e-9, 1.0], 45.0), ([0.5, 0.5, 0.5, 0.5], 0.0), ([0.0, 0.0, 1e-5, 1.0], -30.0)] {
+ let mut s = DevelopSettings::default();
+ s.crop.geometry.rect = lightcraft_geom::Rect::new(rect[0], rect[1], rect[2], rect[3]);
+ s.crop.geometry.angle = angle;
+ s.effects.clarity = 80.0;
+ s.effects.dehaze = 50.0;
+ s.effects.texture = 40.0;
+ s.light.highlights = -60.0;
+ s.light.shadows = 60.0;
+ let t = std::time::Instant::now();
+ let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(280, 180));
+ assert!(t.elapsed().as_secs_f64() < 20.0, "{rect:?}: {:?}", t.elapsed());
+ assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
+ }
+}
diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs
index 15c164bb1..8f3cf1884 100644
--- a/crates/pipeline/src/visualize.rs
+++ b/crates/pipeline/src/visualize.rs
@@ -199,13 +199,18 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>,
}
}
Overlay::AllMasks { view, color, opacity } => {
- if let Some(planes) = masks {
- for a in planes {
- if (a.width, a.height) == (img.width, img.height) {
- mask_view(img, a, view, color, opacity);
- }
+ // one view of the union of the masks: painting the planes one after another
+ // overwrites (White on Black, Image on Black…) or compounds (Color) the earlier ones
+ let mut union: Option = None;
+ for a in masks.unwrap_or_default().iter().filter(|a| (a.width, a.height) == (img.width, img.height)) {
+ match &mut union {
+ None => union = Some(a.clone()),
+ Some(u) => u.data.iter_mut().zip(&a.data).for_each(|(u, a)| *u = u.max(*a)),
}
}
+ if let Some(u) = &union {
+ mask_view(img, u, view, color, opacity);
+ }
}
}
}
@@ -425,7 +430,6 @@ mod tests {
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }],
..Default::default()
});
- let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image;
let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 };
let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40);
@@ -435,7 +439,7 @@ mod tests {
// a hidden mask is skipped
s.masks[1].visible = false;
let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image;
- assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted");
+ assert_eq!(img2.data[in_r], [0, 0, 0, 255], "hidden right mask not painted: black like everything outside");
assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted");
// parts round trip and a stable, distinct cache key
let (k, v) = o.to_parts();
diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs
index 26d180ec9..e8f4e80bf 100644
--- a/crates/preview/src/disk.rs
+++ b/crates/preview/src/disk.rs
@@ -9,6 +9,7 @@
//! (`<2 hex>/<32 hex>.jpg` and the `<32 hex>.tmp…` temp files of [`DiskCache::put`], see
//! [`is_cache_file`]): the directory may be shared with other files (e.g. a library opened on a
//! photo folder that already has a `thumbs/` folder), and those are never touched (issue #98).
+//! A shard directory that is a symlink or junction is not followed (issue #1504).
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -65,7 +66,7 @@ impl DiskCache {
pub fn get(&self, key: Hash128) -> Option {
let p = self.path(key);
- let Ok(bytes) = std::fs::read(&p) else {
+ let Some(bytes) = p.parent().filter(|d| is_real_dir(d)).and_then(|_| std::fs::read(&p).ok()) else {
self.misses.fetch_add(1, Ordering::Relaxed);
return None;
};
@@ -91,7 +92,7 @@ impl DiskCache {
let Some(bytes) = encode_jpeg(img) else { return };
let p = self.path(key);
let Some(parent) = p.parent() else { return };
- if std::fs::create_dir_all(parent).is_err() {
+ if std::fs::create_dir_all(parent).is_err() || !is_real_dir(parent) {
return;
}
// Serialize the file replacement and its size accounting. A repeated render of
@@ -124,6 +125,10 @@ impl DiskCache {
if !is_shard_name(sub_name) {
continue;
}
+ // a shard that is a symlink (or junction) leads outside the cache: never walk it
+ if !is_real_dir(&sub.path()) {
+ continue;
+ }
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
let name = f.file_name();
@@ -173,6 +178,11 @@ impl DiskCache {
}
}
+/// Whether `p` is a directory itself, not a symlink or Windows reparse point to one.
+fn is_real_dir(p: &Path) -> bool {
+ std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir())
+}
+
fn is_lower_hex(s: &str) -> bool {
s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs
index 3179b91ec..6c93e51af 100644
--- a/crates/preview/src/tests.rs
+++ b/crates/preview/src/tests.rs
@@ -380,6 +380,33 @@ fn disk_cache_never_touches_foreign_files() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// A shard directory that is a symlink to a folder outside the cache is neither listed, cleared,
+/// pruned, read nor written (issue #1504).
+#[cfg(unix)]
+#[test]
+fn disk_cache_ignores_symlinked_shard_directories() {
+ let dir = temp_dir("symlink-shard");
+ let outside = temp_dir("symlink-outside");
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::create_dir_all(&outside).unwrap();
+ let key = hash_bytes(&[9, 9]);
+ let hex = key.to_string();
+ let sentinel = outside.join(format!("{hex}.jpg"));
+ std::fs::write(&sentinel, vec![7u8; 50_000]).unwrap();
+ std::os::unix::fs::symlink(&outside, dir.join(&hex[..2])).unwrap();
+ let d = DiskCache::new(&dir, 10);
+ assert_eq!(d.size(), 0, "files behind the link are not the cache's");
+ d.prune();
+ assert!(sentinel.exists(), "prune followed the link");
+ assert!(d.get(key).is_none(), "get read through the link");
+ d.put(key, &gradient(16, 16, 1));
+ assert_eq!(std::fs::metadata(&sentinel).unwrap().len(), 50_000, "put wrote through the link");
+ d.clear();
+ assert!(sentinel.exists(), "clear followed the link");
+ let _ = std::fs::remove_dir_all(&dir);
+ let _ = std::fs::remove_dir_all(&outside);
+}
+
/// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or
/// after the timeout.
#[test]
diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs
index 8deba2d13..33816dffa 100644
--- a/crates/raster/src/blur.rs
+++ b/crates/raster/src/blur.rs
@@ -3,8 +3,16 @@
use crate::resample::Pixel;
use crate::{Image, par_rows};
-/// Box radii approximating a Gaussian of `sigma` with 3 passes (Kovesi / Wells).
+/// The largest blur sigma (pixels) the box radii are made for. A box wider than any image (the
+/// encoders' limit is 65535 px) blurs to the same flat result, and a larger sigma made the box
+/// width's square overflow (from about 23 170 px: a panic where overflow is checked, a garbage
+/// radius of billions of pixels, i.e. a hung render, where it isn't).
+pub const MAX_SIGMA: f32 = 65_536.0;
+
+/// Box radii approximating a Gaussian of `sigma` with 3 passes (Kovesi / Wells). A sigma that is
+/// not a number counts as 0 (no blur); one above [`MAX_SIGMA`] (infinity too) as `MAX_SIGMA`.
pub fn box_radii(sigma: f32) -> [usize; 3] {
+ let sigma = if sigma.is_nan() { 0.0 } else { sigma.clamp(0.0, MAX_SIGMA) };
let n = 3.0f32;
let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
let mut wl = w_ideal.floor() as i32;
@@ -12,7 +20,8 @@ pub fn box_radii(sigma: f32) -> [usize; 3] {
wl -= 1;
}
let wu = wl + 2;
- let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0);
+ // (the square in i64: it passes i32 from a box 46 341 px wide)
+ let m_ideal = (12.0 * sigma * sigma - n * (i64::from(wl) * i64::from(wl)) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0);
let m = m_ideal.round() as i32;
std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize)
}
@@ -71,12 +80,24 @@ fn box_v(src: &Image, dst: &mut Image, r: usize) {
});
}
+/// The box radii of [`gaussian`] on a `w × h` image (the GPU renderer's twin uses them too), or
+/// `None` when the blur leaves the image as it is: `sigma` at most 0.3 or not a number, or an
+/// empty image. A sigma wider than twice the image's long side counts as that: a crop a hair
+/// wide shown to fit asked the tone and dehaze stages for sigmas of millions of pixels (infinite
+/// for a crop of no size), which blur no flatter than that but cost boxes that wide on every row.
+pub fn radii_for(sigma: f32, w: usize, h: usize) -> Option<[usize; 3]> {
+ if sigma.is_nan() || sigma <= 0.3 || w == 0 || h == 0 {
+ return None;
+ }
+ let cap = (w.max(h) as f32 * 2.0).max(1.0);
+ Some(box_radii(sigma.min(cap)))
+}
+
/// Blur with Gaussian `sigma` (pixels). `sigma <= 0.3` returns a copy.
pub fn gaussian(img: &Image, sigma: f32) -> Image {
- if sigma <= 0.3 || img.is_empty() {
+ let Some(radii) = radii_for(sigma, img.width, img.height) else {
return img.clone();
- }
- let radii = box_radii(sigma);
+ };
let mut a = img.clone();
par_rows(&mut a.data, img.width, |_, row| {
let mut tmp = Vec::with_capacity(row.len());
@@ -97,6 +118,56 @@ pub fn gaussian(img: &Image, sigma: f32) -> Image {
#[cfg(test)]
mod tests {
+ /// The box radii of the formula as it was, for the check below.
+ fn box_radii_before(sigma: f32) -> [usize; 3] {
+ let n = 3.0f32;
+ let w_ideal = (12.0 * sigma * sigma / n + 1.0).sqrt();
+ let mut wl = w_ideal.floor() as i32;
+ if wl % 2 == 0 {
+ wl -= 1;
+ }
+ let wu = wl + 2;
+ let m_ideal = (12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0);
+ let m = m_ideal.round() as i32;
+ std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize)
+ }
+
+ /// A blur sigma from a degenerate edit or a deep zoom (found by a UI campaign: a render
+ /// worker's blur with a huge sigma) squared the box width past i32, which panics in a debug
+ /// build and gave a radius of billions of pixels in a release one. Huge, infinite and NaN
+ /// sigmas give bounded radii, and a blur with them is quick and stays finite; every sigma that
+ /// worked before keeps exactly its radii.
+ #[test]
+ fn huge_and_non_finite_sigmas_give_bounded_radii() {
+ for sigma in [26_800.0, 46_341.0, 1e5, 1e9, f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN, -5.0] {
+ let r = box_radii(sigma);
+ assert!(r.iter().all(|&r| r <= MAX_SIGMA as usize * 2), "{sigma}: {r:?}");
+ }
+ assert_eq!(box_radii(f32::NAN), [0, 0, 0]);
+ assert_eq!(box_radii(f32::NEG_INFINITY), [0, 0, 0]);
+ assert_eq!(box_radii(1e9), box_radii(MAX_SIGMA));
+ assert_eq!(box_radii(f32::INFINITY), box_radii(MAX_SIGMA));
+ // on an image, the blur is at most twice its long side; NaN and tiny sigmas don't blur
+ assert_eq!(radii_for(3.1e7, 1400, 900), Some(box_radii(2800.0)));
+ assert_eq!(radii_for(f32::INFINITY, 1400, 900), Some(box_radii(2800.0)));
+ assert_eq!(radii_for(40.0, 1400, 900), Some(box_radii(40.0)), "an ordinary sigma is as asked");
+ assert_eq!(radii_for(f32::NAN, 1400, 900), None);
+ assert_eq!(radii_for(0.2, 1400, 900), None);
+ assert_eq!(radii_for(5.0, 0, 900), None);
+ let p = crate::Plane { width: 4, height: 3, data: (0..12).map(|i| i as f32).collect() };
+ let t = std::time::Instant::now();
+ for sigma in [1e9, f32::INFINITY, f32::NAN] {
+ let b = gaussian(&p, sigma);
+ assert!(b.data.iter().all(|v| v.is_finite()), "{sigma}");
+ }
+ assert!(t.elapsed().as_secs_f64() < 30.0, "a huge sigma's blur is bounded");
+ let mut s = 0.0f32;
+ while s < 23_000.0 {
+ assert_eq!(box_radii(s), box_radii_before(s), "sigma {s}");
+ s = s * 1.013 + 0.07;
+ }
+ }
+
use super::*;
#[test]
diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs
index 41ae75af8..19fe1f7ff 100644
--- a/crates/raster/src/lib.rs
+++ b/crates/raster/src/lib.rs
@@ -206,8 +206,11 @@ impl Rgb32f {
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5).
#[inline]
pub fn sample_bilinear(&self, x: f32, y: f32) -> [f32; 3] {
- let fx = x - 0.5;
- let fy = y - 0.5;
+ // a pixel past the edge reads the edge pixel, so a coordinate held a pixel outside gives
+ // the same sample; an infinite one (a crop of no size's inverse mapping) then reads the
+ // edge too, instead of a NaN weight and a neighbour index past isize::MAX
+ let fx = x.clamp(-1.0, self.width as f32 + 1.0) - 0.5;
+ let fy = y.clamp(-1.0, self.height as f32 + 1.0) - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = fx - x0;
@@ -258,6 +261,18 @@ mod tests {
use super::*;
use lightcraft_geom::Orientation;
+ /// A sample at an infinite coordinate (the inverse mapping of a crop of no size) cast to
+ /// isize::MAX, and its right / lower neighbour overflowed (a panic where overflow is checked):
+ /// the neighbours saturate and the edge pixel comes back.
+ #[test]
+ fn bilinear_samples_at_infinite_coordinates_read_the_edge() {
+ let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]);
+ assert_eq!(img.sample_bilinear(f32::INFINITY, f32::INFINITY), [2.0, 1.0, 1.0]);
+ assert_eq!(img.sample_bilinear(f32::MAX, 0.0), [2.0, 0.0, 1.0]);
+ assert_eq!(img.sample_bilinear(f32::NEG_INFINITY, f32::NEG_INFINITY), [0.0, 0.0, 1.0]);
+ assert_eq!(img.sample_bilinear(1.5, 0.5), [1.0, 0.0, 1.0], "an ordinary sample is as before");
+ }
+
#[test]
fn crop_and_rotate() {
let img = Image::::from_fn(4, 3, |x, y| (y * 10 + x) as u32);
diff --git a/crates/raw/src/demosaic/mod.rs b/crates/raw/src/demosaic/mod.rs
index c868dc6aa..b8a3c599a 100644
--- a/crates/raw/src/demosaic/mod.rs
+++ b/crates/raw/src/demosaic/mod.rs
@@ -79,7 +79,20 @@ pub fn demosaic(n: &Normalized, method: Method) -> Rgb32f {
}
match (&n.cfa, n.cpp) {
(_, 3) => Rgb32f { width: w, height: h, data: n.data.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect() },
- (_, cpp) if cpp != 1 => Rgb32f { width: w, height: h, data: n.data.chunks_exact(cpp).map(|c| [c[0], c[1], c[2]]).collect() },
+ // four colours develop from their first three; narrower data (refused by `RawImage::validate`, issue #1511)
+ // repeats its last sample instead of reading past the pixel
+ (_, cpp) if cpp != 1 => Rgb32f {
+ width: w,
+ height: h,
+ data: n
+ .data
+ .chunks_exact(cpp.max(1))
+ .map(|c| {
+ let s = |i: usize| c.get(i).or(c.last()).copied().unwrap_or(0.0);
+ [s(0), s(1), s(2)]
+ })
+ .collect(),
+ },
(None, _) => Rgb32f { width: w, height: h, data: n.data.iter().map(|&v| [v; 3]).collect() },
(Some(cfa), _) => {
let m = Mosaic { w, h, data: &n.data, cfa };
diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs
index 3c7ece61b..026aae90d 100644
--- a/crates/raw/src/dng.rs
+++ b/crates/raw/src/dng.rs
@@ -207,7 +207,8 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Result (*r as usize, *c as usize),
_ => (1, 1),
};
- let mut values: Vec = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).collect();
+ // (a FLOAT / DOUBLE tag can hold NaN or infinity: such a level reads as 0, in its place)
+ let level = |v: f64| Some(v as f32).filter(|v| v.is_finite()).unwrap_or(0.0);
+ let mut values: Vec = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect();
if values.len() != br * bc * cpp {
values = vec![values.first().copied().unwrap_or(0.0)];
}
let (br, bc) = if values.len() == 1 { (1, 1) } else { (br, bc) };
- let floats = |tag| raw.f64s(tag).unwrap_or_default().into_iter().map(|v| v as f32).collect::>();
+ let floats = |tag| raw.f64s(tag).unwrap_or_default().into_iter().map(level).collect::>();
let black =
BlackLevel { repeat_rows: br, repeat_cols: bc, values, delta_h: floats(t::BLACK_LEVEL_DELTA_H), delta_v: floats(t::BLACK_LEVEL_DELTA_V) };
@@ -303,7 +306,11 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result u16 {
let b = v.to_bits();
@@ -108,18 +112,27 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> {
ifd.set(t::PHOTOMETRIC, Value::Short(vec![if raw.cfa.is_some() { photometric::CFA } else { photometric::LINEAR_RAW }]));
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
ifd.set(t::DNG_BACKWARD_VERSION, Value::Byte(vec![1, 3, 0, 0]));
- let make = raw.metadata.make.clone().unwrap_or_default();
- let model = raw.metadata.model.clone().unwrap_or_default();
+ let make = raw.metadata.make.as_deref().unwrap_or_default();
+ let model = raw.metadata.model.as_deref().unwrap_or_default();
if !make.is_empty() {
- ifd.set(t::MAKE, Value::Ascii(make.clone()));
+ ifd.set(t::MAKE, Value::Ascii(make.to_owned()));
}
if !model.is_empty() {
- ifd.set(t::MODEL, Value::Ascii(model.clone()));
+ ifd.set(t::MODEL, Value::Ascii(model.to_owned()));
}
- let unique = format!("{make} {model}").trim().to_string();
+ let model_includes_make = !make.is_empty()
+ && model.get(..make.len()).is_some_and(|prefix| prefix.eq_ignore_ascii_case(make))
+ && model.get(make.len()..).is_some_and(|suffix| suffix.chars().next().is_none_or(|c| !c.is_alphanumeric() && c != '_'));
+ let unique = if model_includes_make || make.is_empty() {
+ model.to_owned()
+ } else if model.is_empty() {
+ make.to_owned()
+ } else {
+ format!("{make} {model}")
+ };
ifd.set(t::UNIQUE_CAMERA_MODEL, Value::Ascii(if unique.is_empty() { "LightCraft".into() } else { unique }));
ifd.set(t::ORIENTATION, Value::Short(vec![raw.orientation.to_exif()]));
- ifd.set(t::SOFTWARE, Value::Ascii("LightCraft".into()));
+ ifd.set(t::SOFTWARE, Value::Ascii(SOFTWARE.into()));
let xmp = opts.xmp.clone().unwrap_or_else(|| lightcraft_meta::write_xmp(&raw.metadata, None));
ifd.set(t::XMP, Value::Byte(xmp.into_bytes()));
diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs
index 9cbce1a89..37c85663d 100644
--- a/crates/raw/src/lib.rs
+++ b/crates/raw/src/lib.rs
@@ -56,7 +56,7 @@ pub use lightcraft_meta::Metadata;
pub use lightcraft_raster::Rgb32f;
pub use opcodes::{Opcode, OpcodeLists};
pub use preview::{
- PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space,
+ PREVIEW_PREFIX, PreviewColorSpace, PreviewRange, bounded_preview, embedded_preview, embedded_preview_color_space, embedded_preview_covering,
embedded_preview_dynamic_range_optimized, embedded_preview_from_range,
};
pub use semantic::{SemanticMask, semantic_masks};
@@ -717,6 +717,10 @@ impl RawImage {
if self.width == 0 || self.height == 0 || !(1..=4).contains(&self.cpp) {
return Err(RawError::Corrupt("bad dimensions".into()));
}
+ // two samples a pixel are neither one colour nor RGB: nothing downstream can develop them (issue #1511)
+ if self.cpp == 2 {
+ return Err(RawError::Unsupported("2 samples per pixel".into()));
+ }
if samples && self.data.len() != self.width * self.height * self.cpp {
return Err(RawError::Corrupt("data length mismatch".into()));
}
@@ -770,7 +774,10 @@ impl RawImage {
for s in 0..cpp {
let v = src(base + x * cpp + s);
let b = self.black.at(x, y, s, cpp);
- row[x * cpp + s] = (v - b) * scale[s];
+ // a NaN or infinite sample (a float DNG's, or a black level a file got wrong)
+ // reads as black, so the demosaic and every stage after it see numbers
+ let n = (v - b) * scale[s];
+ row[x * cpp + s] = if n.is_finite() { n } else { 0.0 };
}
}
});
diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs
index 1d3a2c546..bcc59ab57 100644
--- a/crates/raw/src/opcodes.rs
+++ b/crates/raw/src/opcodes.rs
@@ -129,6 +129,76 @@ impl Opcode {
pub fn is_lens_correction(&self) -> bool {
matches!(self, Opcode::WarpRectilinear { .. } | Opcode::WarpRectilinear2 { .. } | Opcode::FixVignetteRadial { .. })
}
+
+ /// This lens correction re-expressed for the part `crop` of the `width × height` image it was written for: applied
+ /// to the cropped image it gives the crop of the corrected whole. The centre moves into the crop and the
+ /// coefficients are rescaled for the crop's farthest corner, the radius every coordinate is normalised by. Centres
+ /// use the warps' convention (0 and 1 at the first and last pixel), also for `FixVignetteRadial`, as the develop
+ /// pipeline reads them. `None` for other opcodes, an image or crop under 2 × 2, or a crop outside the image.
+ pub fn cropped(&self, width: usize, height: usize, crop: crate::Rect) -> Option {
+ let fits = crop.x.checked_add(crop.width).is_some_and(|r| r <= width) && crop.y.checked_add(crop.height).is_some_and(|b| b <= height);
+ if width < 2 || height < 2 || crop.width < 2 || crop.height < 2 || !fits {
+ return None;
+ }
+ let (aw, ah) = (width as f64 - 1.0, height as f64 - 1.0);
+ let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0);
+ // the centre in the crop, and the ratio of the crop's normalising radius to the image's
+ let reframe = |c: [f64; 2]| -> Option<([f64; 2], f64)> {
+ let (px, py) = (c[0] * aw, c[1] * ah);
+ let (qx, qy) = (px - crop.x as f64, py - crop.y as f64);
+ let s = max_radius(qx, qy, cw, ch) / max_radius(px, py, aw, ah);
+ (s.is_finite() && s > 0.0 && qx.is_finite() && qy.is_finite()).then_some(([qx / cw, qy / ch], s))
+ };
+ match self {
+ // source radius r·(kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶) and tangential terms quadratic in r: with r = r'·s,
+ // kr_i scales by s^2i and kt by s
+ Opcode::WarpRectilinear { planes, center } => {
+ let (center, s) = reframe(*center)?;
+ let planes = planes
+ .iter()
+ .map(|k| {
+ let mut k = *k;
+ for (i, v) in k[..4].iter_mut().enumerate() {
+ *v *= s.powi(2 * i as i32);
+ }
+ k[4] *= s;
+ k[5] *= s;
+ k
+ })
+ .collect();
+ Some(Opcode::WarpRectilinear { planes, center })
+ }
+ // radial factor Σ kr_i r^i between the valid radii: kr_i scales by s^i, the radii by 1/s
+ Opcode::WarpRectilinear2 { planes, center, reciprocal, optional } => {
+ let (center, s) = reframe(*center)?;
+ let planes = planes
+ .iter()
+ .map(|k| {
+ let mut k = *k;
+ for (i, v) in k[..15].iter_mut().enumerate() {
+ *v *= s.powi(i as i32);
+ }
+ k[15] *= s;
+ k[16] *= s;
+ k[17] /= s;
+ k[18] /= s;
+ k
+ })
+ .collect();
+ Some(Opcode::WarpRectilinear2 { planes, center, reciprocal: *reciprocal, optional: *optional })
+ }
+ // gain 1 + Σ k_i r^(2i+2)
+ Opcode::FixVignetteRadial { k, center } => {
+ let (center, s) = reframe(*center)?;
+ let mut k = *k;
+ for (i, v) in k.iter_mut().enumerate() {
+ *v *= s.powi(2 * i as i32 + 2);
+ }
+ Some(Opcode::FixVignetteRadial { k, center })
+ }
+ _ => None,
+ }
+ }
}
/// The opcodes of a list that a reader applies, in order: all of them, minus the DNG 1.6 skip rule (specification
@@ -1032,4 +1102,60 @@ pub(crate) mod tests {
let bad = Opcode::Unknown { id: 14, flags: 1, params: vec![] };
assert!(applied_ops(&[bad, wr]).any(|o| matches!(o, Opcode::WarpRectilinear { .. })));
}
+
+ /// `cropped` re-expresses a lens correction for a crop: the corrected crop is the crop of the corrected whole. A
+ /// linear ramp makes bilinear sampling exact, so only the borders (where the warp reaches past the crop) differ.
+ #[test]
+ fn cropped_lens_corrections_correct_the_crop_like_the_whole() {
+ let (w, h) = (90usize, 64usize);
+ let crop = crate::Rect::new(7, 5, 70, 50);
+ let ramp = Rgb32f::from_fn(w, h, |x, y| [0.002 * x as f32 + 0.001 * y as f32 + 0.1, 0.3 + 0.0015 * y as f32, 0.2]);
+ let mut wr2 = [0.0; WR2_LEN];
+ wr2[..5].copy_from_slice(&[1.0, 0.01, -0.03, 0.0, 0.01]);
+ wr2[15] = 0.002;
+ wr2[16] = -0.001;
+ wr2[17] = 0.05;
+ wr2[18] = 0.9;
+ let warps = [
+ Opcode::WarpRectilinear { planes: vec![[0.97, 0.04, -0.01, 0.002, 0.003, -0.002]], center: [0.47, 0.53] },
+ Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: false, optional: true },
+ Opcode::WarpRectilinear2 { planes: vec![wr2], center: [0.52, 0.46], reciprocal: true, optional: false },
+ ];
+ for op in &warps {
+ let mut whole = ramp.clone();
+ apply_list3(std::slice::from_ref(op), &mut whole);
+ let re = op.cropped(w, h, crop).unwrap();
+ let mut part = ramp.clone().into_crop(crop.x, crop.y, crop.width, crop.height);
+ apply_list3(std::slice::from_ref(&re), &mut part);
+ let mut worst = 0f32;
+ for y in 6..crop.height - 6 {
+ for x in 6..crop.width - 6 {
+ let (a, b) = (whole.data[(y + crop.y) * w + x + crop.x], part.data[y * crop.width + x]);
+ worst = (0..3).map(|c| (a[c] - b[c]).abs()).fold(worst, f32::max);
+ }
+ }
+ assert!(worst < 1e-4, "{op:?} → {re:?}: {worst}");
+ }
+ // the vignette, in the warps' centre convention the develop pipeline reads it with
+ let gain = |k: &[f64; 5], c: [f64; 2], w: usize, h: usize, x: f64, y: f64| {
+ let (aw, ah) = (w as f64 - 1.0, h as f64 - 1.0);
+ let (px, py) = (c[0] * aw, c[1] * ah);
+ let r2 = ((x - px).powi(2) + (y - py).powi(2)) / max_radius(px, py, aw, ah).powi(2);
+ 1.0 + (0..5).map(|i| k[i] * r2.powi(i as i32 + 1)).sum::()
+ };
+ let (k, center) = ([-0.3, 0.1, -0.05, 0.02, -0.01], [0.45, 0.55]);
+ let Some(Opcode::FixVignetteRadial { k: k2, center: c2 }) = (Opcode::FixVignetteRadial { k, center }).cropped(w, h, crop) else {
+ panic!("vignette")
+ };
+ for (x, y) in [(0.0, 0.0), (69.0, 49.0), (33.5, 20.0), (69.0, 0.0), (12.0, 40.0)] {
+ let (a, b) = (gain(&k, center, w, h, x + crop.x as f64, y + crop.y as f64), gain(&k2, c2, crop.width, crop.height, x, y));
+ assert!((a - b).abs() < 1e-12, "({x}, {y}): {a} vs {b}");
+ }
+ // no change for a full-frame "crop"; nothing for other opcodes, tiny images or a crop past the edge
+ assert_eq!(warps[0].cropped(w, h, crate::Rect::new(0, 0, w, h)).unwrap(), warps[0]);
+ assert!(Opcode::TrimBounds { top: 0, left: 0, bottom: 2, right: 2 }.cropped(w, h, crop).is_none());
+ assert!(warps[0].cropped(1, h, crate::Rect::new(0, 0, 1, h)).is_none());
+ assert!(warps[0].cropped(w, h, crate::Rect::new(30, 0, 70, 50)).is_none());
+ assert!(warps[0].cropped(w, h, crate::Rect::new(usize::MAX, 0, 70, 50)).is_none());
+ }
}
diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs
index e3e882690..ca447d184 100644
--- a/crates/raw/src/preview.rs
+++ b/crates/raw/src/preview.rs
@@ -65,31 +65,43 @@ pub fn embedded_preview_dynamic_range_optimized(bytes: &[u8]) -> Option {
crate::vendor::arw::dynamic_range_optimizer(&mn)
}
-/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
-fn is_dct_jpeg(b: &[u8]) -> bool {
- if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 {
- return false;
+/// The first frame (SOF) marker of a JPEG and its position, from the marker segments before the first scan.
+fn first_frame(b: &[u8]) -> Option<(u8, usize)> {
+ if b.len() < 4 || !b.starts_with(&[0xff, 0xd8]) {
+ return None;
}
- let mut i = 2;
- while i + 4 <= b.len() {
- if b[i] != 0xff {
- return false;
+ let mut i = 2usize;
+ while i.checked_add(4)? <= b.len() {
+ if *b.get(i)? != 0xff {
+ return None;
}
- let m = b[i + 1];
+ let m = *b.get(i + 1)?;
if m == 0xff {
i += 1;
continue;
}
match m {
- 0xc0..=0xc2 => return true,
- 0xc3 | 0xc5..=0xc7 | 0xcb | 0xcd..=0xcf => return false,
- 0xda | 0xd9 => return false,
+ 0xc0..=0xc3 | 0xc5..=0xc7 | 0xcb | 0xcd..=0xcf => return Some((m, i)),
+ 0xda | 0xd9 => return None,
_ => {}
}
- let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
- i += 2 + len;
+ let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?]));
+ i = i.checked_add(2 + len)?;
}
- false
+ None
+}
+
+/// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless.
+fn is_dct_jpeg(b: &[u8]) -> bool {
+ first_frame(b).is_some_and(|(m, _)| matches!(m, 0xc0..=0xc2))
+}
+
+/// Width, height and component count of a displayable (DCT) JPEG, from its frame header.
+fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> {
+ let (_, i) = first_frame(b).filter(|(m, _)| matches!(m, 0xc0..=0xc2))?;
+ let at = |k: usize| b.get(i.checked_add(k)?).copied();
+ let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?])));
+ Some((be16(7)?, be16(5)?, at(9)?))
}
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
@@ -139,6 +151,33 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
/// `lightcraft_codecs::decode`.
pub fn embedded_preview(bytes: &[u8]) -> Option> {
+ preview_covering(bytes, None)
+}
+
+/// The smallest embedded JPEG whose long edge is at least `min_edge` and that looks like a copy of the largest preview
+/// (its aspect ratio within 1 %, so not a letterboxed or cropped thumbnail, and as many colour components, so not a
+/// grey mask or depth map), else [`embedded_preview`]. Only TIFF-based raws keep several: an A7R VI's 1616 × 1080
+/// preview is 0.3 MB beside its 9984 × 6656 one (5.6 MB), so a stand-in up to 1616 px decodes about a twentieth of the data.
+pub fn embedded_preview_covering(bytes: &[u8], min_edge: u32) -> Option> {
+ preview_covering(bytes, Some(min_edge))
+}
+
+/// Of the DCT JPEGs in `found`, the one with the shortest long edge of at least `min_edge` with `largest`'s aspect
+/// ratio (within 1 %) and component count.
+fn smallest_covering<'a>(found: &[&'a [u8]], largest: &[u8], min_edge: u32) -> Option<&'a [u8]> {
+ let (bw, bh, bc) = jpeg_frame(largest)?;
+ let like = |(w, h, c): (u32, u32, u8)| {
+ let (a, b) = (u64::from(w) * u64::from(bh), u64::from(h) * u64::from(bw));
+ w > 0 && h > 0 && c == bc && a.abs_diff(b) * 100 <= a
+ };
+ found
+ .iter()
+ .filter_map(|&s| jpeg_frame(s).filter(|&f| like(f) && f.0.max(f.1) >= min_edge).map(|(w, h, _)| (s, w.max(h))))
+ .min_by_key(|&(_, long)| long)
+ .map(|(s, _)| s)
+}
+
+fn preview_covering(bytes: &[u8], min_edge: Option) -> Option> {
if bytes.starts_with(b"FUJIFILMCCD-RAW") {
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
return is_dct_jpeg(j).then(|| trim_eoi(j).to_vec());
@@ -173,10 +212,11 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> {
if let Some(p) = crate::vendor::orf::preview(bytes) {
found.push(p);
}
- let best = found
- .into_iter()
- .filter(|s| is_dct_jpeg(s) || is_jxl(s))
- .max_by_key(|s| s.len())
+ let largest = found.iter().copied().filter(|s| is_dct_jpeg(s) || is_jxl(s)).max_by_key(|s| s.len());
+ let best = min_edge
+ .zip(largest)
+ .and_then(|(min, big)| smallest_covering(&found, big, min))
+ .or(largest)
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
@@ -543,6 +583,61 @@ mod tests {
}
}
+ /// A JPEG like [`fake_jpeg`] whose frame header says `w` x `h` with `nc` components.
+ fn sized_jpeg(w: u16, h: u16, nc: u8, n: usize) -> Vec {
+ let mut j = vec![0xff, 0xd8, 0xff, 0xc0, 0x00, 8 + 3 * nc, 8];
+ j.extend_from_slice(&h.to_be_bytes());
+ j.extend_from_slice(&w.to_be_bytes());
+ j.push(nc);
+ for c in 0..nc {
+ j.extend_from_slice(&[c + 1, 0x11, 0]);
+ }
+ j.extend(std::iter::repeat_n(0x55u8, n));
+ j.extend_from_slice(&[0xff, 0xd9]);
+ j
+ }
+
+ /// A TIFF raw holding `jpegs` as JPEG-compressed strips: the first in IFD0, the others in sub-IFDs.
+ fn tiff_with_jpegs(jpegs: &[&[u8]]) -> Vec {
+ let ifd = |j: &[u8]| {
+ let mut ifd = IfdBuilder::new();
+ ifd.set(t::COMPRESSION, Value::Short(vec![6]));
+ ifd.set(t::IMAGE_WIDTH, Value::Long(vec![1]));
+ ifd.set(t::IMAGE_LENGTH, Value::Long(vec![1]));
+ ifd.set_image(ImageData::Strips { rows_per_strip: 1, strips: vec![j.to_vec()] });
+ ifd
+ };
+ let mut ifd0 = ifd(jpegs[0]);
+ for j in &jpegs[1..] {
+ ifd0.add_sub_ifd(ifd(j));
+ }
+ TiffWriter::default().write(&[ifd0]).unwrap()
+ }
+
+ /// A stand-in decodes the smallest JPEG that covers its box and shows the same picture as the largest: not a
+ /// thumbnail of another shape (a letterboxed one) or a grey mask, and the largest when no smaller one is big enough.
+ #[test]
+ fn a_stand_in_takes_the_smallest_covering_jpeg_of_the_same_shape() {
+ let (big, small, boxed) = (sized_jpeg(9984, 6656, 3, 500), sized_jpeg(1616, 1080, 3, 50), sized_jpeg(160, 120, 3, 10));
+ // a ProRAW DNG keeps grey semantic masks of the picture's shape as JPEGs too
+ let mask = sized_jpeg(768, 512, 1, 20);
+ let file = tiff_with_jpegs(&[&small, &big, &boxed, &mask]);
+ assert_eq!(embedded_preview(&file).unwrap(), big, "unchanged: the largest");
+ assert_eq!(embedded_preview_covering(&file, 1600).unwrap(), small, "1616 px covers a 1600 px box");
+ assert_eq!(embedded_preview_covering(&file, 1616).unwrap(), small);
+ assert_eq!(embedded_preview_covering(&file, 1617).unwrap(), big, "too small: the largest");
+ assert_eq!(embedded_preview_covering(&file, u32::MAX).unwrap(), big);
+ assert_eq!(embedded_preview_covering(&file, 100).unwrap(), small, "the 4:3 thumbnail and the grey mask are not this picture");
+ // a small copy of the same shape (within 1 %) is taken
+ let thumb = sized_jpeg(160, 107, 3, 10);
+ let file = tiff_with_jpegs(&[&small, &big, &thumb]);
+ assert_eq!(embedded_preview_covering(&file, 100).unwrap(), thumb);
+ assert_eq!(embedded_preview_covering(&tiff_with_jpegs(&[&big]), 100).unwrap(), big, "one preview: that one");
+ for n in (0..file.len()).step_by(7) {
+ let _ = embedded_preview_covering(&file[..n], 100);
+ }
+ }
+
/// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box.
#[test]
fn cr3_prefers_the_full_size_jpeg_track() {
diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs
index 14fa3c1f8..e5a8e787d 100644
--- a/crates/raw/src/sparse.rs
+++ b/crates/raw/src/sparse.rs
@@ -13,9 +13,9 @@
//! tracks (keeping the raw track's coding headers and quantization maps, and the timed-metadata
//! track where Canon puts the as-shot white balance), a RAF's CFA strip (keeping its compression
//! header) and a Panasonic RW2's raw data. Formats whose header probe reads samples (NEF: masked
-//! columns; ORF: bit depth and CFA phase; PEF: dark borders) and anything else get
-//! [`HeaderPlan::Whole`], as does a structure that is incomplete, inconsistent or keeps metadata
-//! inside its sample data: the plan never guesses.
+//! columns; ORF: bit depth and CFA phase; PEF: dark borders; ARW6, for its header plan only: the
+//! start of every tile) and anything else get [`HeaderPlan::Whole`], as does a structure that is
+//! incomplete, inconsistent or keeps metadata inside its sample data: the plan never guesses.
use std::collections::HashSet;
@@ -236,6 +236,8 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan {
let chunks: Vec<(u64, u64)> = match ifd.image() {
// a preview's strips are read when the preview is wanted: only the raw image is skipped
Ok(info) if preview && !matches!(info.photometric, t::photometric::CFA | t::photometric::LINEAR_RAW) => continue,
+ // (Sony Compressed RAW 2: the header probe decodes the start of every tile; a preview reads none)
+ Ok(info) if !preview && info.compression == 32766 => return HeaderPlan::Whole("an ARW6 header probe reads its tiles"),
Ok(info) => info.chunks(len).iter().map(|c| (c.offset, c.len)).collect(),
// a raw IFD without the tags a TIFF image needs (an older CR2's) still lists its strips
Err(_) => match (ifd.u64s(t::STRIP_OFFSETS), ifd.u64s(t::STRIP_BYTE_COUNTS)) {
@@ -550,4 +552,17 @@ mod tests {
raf[104..108].copy_from_slice(&1000u32.to_be_bytes());
assert_eq!(run(&raf, 160, 0).0, HeaderPlan::Whole("RAF raw container"));
}
+
+ /// An ARW6 header probe reads every tile's headers and the first lines of its data, which lie in the strip a
+ /// plan would skip: here the second tile, past a first one of bulk size.
+ #[test]
+ fn arw6_is_described_from_the_whole_file() {
+ use crate::vendor::arw6_testenc::{Qis, arw6_file, one_tile_quantised};
+ let (tile, _) = one_tile_quantised(64, 48, 0, Qis::REAL);
+ let mut first = tile.clone();
+ first.resize(BULK as usize, 0);
+ let file = arw6_file(&[(0, 0, 64, 48, first), (64, 0, 64, 48, tile)], 128, 48);
+ assert!(crate::probe_info(&file).is_ok());
+ assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles"));
+ }
}
diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs
index 676bb6197..293fafb61 100644
--- a/crates/raw/src/tiffraw.rs
+++ b/crates/raw/src/tiffraw.rs
@@ -47,6 +47,13 @@ pub fn check_image(data: &[u8], info: &ImageInfo) -> Result {
if chunks.is_empty() {
return Err(RawError::Corrupt("no image data chunks".into()));
}
+ // every visible sample needs a chunk: a short offsets table would leave zero-filled (black) regions
+ let (across, down) = info.grid();
+ let planes = if info.planar == 2 { cpp } else { 1 };
+ let expected = (across as usize).saturating_mul(down as usize).saturating_mul(planes);
+ if chunks.len() < expected {
+ return Err(RawError::Corrupt(format!("{} image data chunks, expected {expected}", chunks.len())));
+ }
// Plausibility bound before allocating: no supported coding stores more than ~2000 samples per byte
// (Deflate of constant data is the extreme), so tiny files cannot trigger huge allocations.
let available: u64 = chunks.iter().map(|c| chunk_bytes(data, c).map_or(0, |s| s.len() as u64)).sum();
@@ -102,21 +109,9 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
chunks.par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] };
let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() };
- let mut ok = 0usize;
- let mut first_err = None;
for r in decoded {
- let (c, px) = match r {
- Ok(v) => v,
- Err(e) => {
- // a JPEG XL tile that cannot be decoded is not a partial image: the file stays preview-only
- if info.compression == comp::JPEG_XL {
- return Err(e);
- }
- first_err.get_or_insert(e);
- continue;
- }
- };
- ok += 1;
+ // a chunk that cannot be decoded is not a partial image: the file stays preview-only
+ let (c, px) = r?;
let (cw, ch) = (c.width as usize, c.height as usize);
let (x0, y0) = (c.x as usize, c.y as usize);
if x0 >= w || y0 >= h {
@@ -149,9 +144,6 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
}
}
}
- if ok == 0 {
- return Err(first_err.unwrap_or_else(|| RawError::Corrupt("no decodable chunks".into())));
- }
Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) })
}
@@ -168,8 +160,8 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
comp::JPEG => {
let f = ljpeg::decode(src, n.saturating_mul(2).max(1 << 16))?;
if f.data.len() < n {
- // Some writers encode edge tiles smaller than nominal: accept a frame that covers the rows it has.
- if f.data.is_empty() || f.data.len() % (cw * cpp) != 0 {
+ // Some writers encode edge tiles smaller than nominal: accept a frame that covers the rows inside the image.
+ if f.data.is_empty() || f.data.len() % (cw * cpp) != 0 || f.data.len() / (cw * cpp) < vh {
return Err(RawError::Corrupt(format!("lossless JPEG tile has {} samples, expected {n}", f.data.len())));
}
}
@@ -377,4 +369,69 @@ mod lossy_tests {
assert!((px(20, 3)[0] as i32 - 220).abs() <= 3, "second tile: {:?}", px(20, 3));
assert!((px(10, 5)[1] as i32 - 150).abs() <= 6, "green ramp: {:?}", px(10, 5));
}
+
+ fn strip_info(compression: u16, offsets: Vec, byte_counts: Vec) -> ImageInfo {
+ ImageInfo {
+ width: 4,
+ height: 4,
+ bits_per_sample: vec![8],
+ samples_per_pixel: 1,
+ compression,
+ photometric: 1,
+ planar: 1,
+ predictor: 1,
+ sample_format: 1,
+ new_subfile_type: 0,
+ layout: Layout::Strips { rows_per_strip: 2 },
+ offsets,
+ byte_counts,
+ }
+ }
+
+ /// A missing strip or a strip that fails to decode is an error, not a black band in a "successful" image.
+ #[test]
+ fn missing_or_failed_chunks_are_errors() {
+ let file = vec![9u8; 8 + 8];
+ // both strips present
+ let ok = strip_info(comp::NONE, vec![0, 8], vec![8, 8]);
+ assert!(read_image(&file, &ok, ByteOrder::Little, Packing::Msb).is_ok());
+ // the second strip has no offset
+ let missing = strip_info(comp::NONE, vec![0], vec![8]);
+ assert!(read_image(&file, &missing, ByteOrder::Little, Packing::Msb).is_err());
+ // the header-only read rejects it too, as the full decode will
+ assert!(read_image_in(crate::Mode::Header, &file, &missing, ByteOrder::Little, Packing::Msb).is_err());
+ // the second strip starts past the end of the file
+ let past_end = strip_info(comp::NONE, vec![0, 100], vec![8, 8]);
+ assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err());
+ }
+
+ /// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter.
+ #[test]
+ fn short_lossless_jpeg_tiles_are_errors() {
+ let tile = |rows: usize| ljpeg::encode(&vec![300u16; 8 * rows], 8, rows, 1, 12, 1, 0);
+ let decode = |height: u32, rows: usize| {
+ let t = tile(rows);
+ let info = ImageInfo {
+ width: 8,
+ height,
+ bits_per_sample: vec![12],
+ samples_per_pixel: 1,
+ compression: comp::JPEG,
+ photometric: 32803,
+ planar: 1,
+ predictor: 1,
+ sample_format: 1,
+ new_subfile_type: 0,
+ layout: Layout::Tiles { tile_width: 8, tile_height: 8 },
+ offsets: vec![0],
+ byte_counts: vec![t.len() as u64],
+ };
+ read_image(&t, &info, ByteOrder::Little, Packing::Msb)
+ };
+ // an edge tile coded at its in-image height, and a full tile
+ assert_eq!(decode(6, 6), Ok(RawData::U16(vec![300; 8 * 6])));
+ assert_eq!(decode(8, 8), Ok(RawData::U16(vec![300; 8 * 8])));
+ // half of the visible rows are missing
+ assert!(decode(8, 4).is_err());
+ }
}
diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs
index 938c6a251..0321ac042 100644
--- a/crates/raw/src/vendor/crx.rs
+++ b/crates/raw/src/vendor/crx.rs
@@ -1115,6 +1115,10 @@ mod tests {
("cr3-canon-m50-craw.cr3", 0x9aacbfe66f505e1bu64),
("cr3-canon-r8-raw.cr3", 0x63c1c6d8d1eb2312u64),
("cr3-canon-r8-craw.cr3", 0xf836a663a795a375u64),
+ ("cr3-canon-rp-raw.cr3", 0x5cb0f46404b8b663u64),
+ ("cr3-canon-rp-craw.cr3", 0xf48df06ca1d67382u64),
+ ("cr3-canon-rp-raw-crop.cr3", 0x46808a9de3b3dcc7u64),
+ ("cr3-canon-rp-craw-crop.cr3", 0x39a60c4efed4e1f4u64),
] {
let path = std::path::Path::new(&directory).join(name);
let bytes = std::fs::read(path).unwrap();
diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs
index e3b273f4a..10d7552d5 100644
--- a/crates/raw/src/vendor/mod.rs
+++ b/crates/raw/src/vendor/mod.rs
@@ -13,6 +13,7 @@ pub(crate) mod crx_wavelet;
pub mod kodak;
pub mod mrw;
pub mod nef;
+mod nef_lens;
pub mod nefc;
pub mod orf;
pub mod pef;
diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs
index 695d55712..7d5b24a4f 100644
--- a/crates/raw/src/vendor/nef.rs
+++ b/crates/raw/src/vendor/nef.rs
@@ -177,7 +177,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result {
(Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() },
_ => Cfa::bayer_static("RGGB"),
};
- let body = measured_body(&ifd0.string(t::MODEL).unwrap_or_default(), w, h, bits);
+ let model = ifd0.string(t::MODEL).unwrap_or_default();
+ let body = measured_body(&model, w, h, bits);
let active_w = trailing_masked_columns(samples, w, bits);
// the white level is measured from the whole image (or known for a measured body); the probe (no samples in its
// result) takes the nominal one
@@ -212,7 +213,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result {
color: ColorData::default(),
wb_multipliers: wb,
linearized: false,
- opcodes: OpcodeLists::default(),
+ // Nikon's in-file distortion correction, about the optical centre (which follows the sensor shift)
+ opcodes: OpcodeLists {
+ list3: super::nef_lens::distortion(&model, raw, Rect::new(0, 0, active_w, h), crop).into_iter().collect(),
+ ..Default::default()
+ },
metadata,
};
img.validate_for(mode)?;
@@ -316,6 +321,49 @@ mod tests {
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
}
+ /// A Z 6II NEF carrying NikonNEFInfo gets Nikon's distortion correction as one warp, in the header probe and
+ /// the full decode alike, and keeps it through DNG export; another body with the same data gets none.
+ #[test]
+ fn nikon_in_file_distortion_becomes_a_warp() {
+ use super::super::nef_lens::tests::{distortion_block, nef_info_blob, offset_block};
+ let (w, h) = (48u32, 32u32);
+ let file = |model: &str| {
+ let words: Vec = (0..w * h).flat_map(|i| (600 + (i % 97) as u16 * 50).to_be_bytes()).collect();
+ let mut raw = IfdBuilder::new();
+ raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
+ raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
+ raw.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
+ raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14]));
+ raw.set(t::COMPRESSION, Value::Short(vec![1]));
+ raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
+ raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
+ raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
+ let info = nef_info_blob(&[
+ (0x0005, distortion_block(3, 21.6, 4, [0.039895, 0.019211, -0.127218], -0.025043)),
+ (0x0008, offset_block(1.5, -0.75)),
+ ]);
+ raw.set(0xc7d5, Value::Undefined(info));
+ raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![words] });
+ let mut ifd0 = IfdBuilder::new();
+ ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into()));
+ ifd0.set(t::MODEL, Value::Ascii(model.into()));
+ ifd0.add_sub_ifd(raw);
+ TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
+ };
+ let bytes = file("NIKON Z 6_2");
+ let header = decode(&bytes, Mode::Header).unwrap();
+ let full = decode(&bytes, Mode::Full).unwrap();
+ assert_eq!(header.info(), full.info());
+ assert_eq!(header.opcodes.list3, full.opcodes.list3);
+ let [crate::Opcode::WarpRectilinear { planes, center }] = full.opcodes.list3.as_slice() else { panic!("{:?}", full.opcodes.list3) };
+ assert!((planes[0][0] - 1.0 / (1.0 - 0.025043)).abs() < 1e-5, "{planes:?}");
+ assert!((center[0] * 47.0 - (23.5 + 1.5)).abs() < 0.01 && (center[1] * 31.0 - (15.5 - 0.75)).abs() < 0.01, "{center:?}");
+ let dng = crate::write_dng(&full, &Default::default()).unwrap();
+ assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3);
+ full.develop(crate::Method::Bilinear).unwrap();
+ assert!(decode(&file("NIKON D850"), Mode::Full).unwrap().opcodes.list3.is_empty());
+ }
+
#[test]
fn preview_color_space_is_read_from_the_nikon_note() {
for (value, expected) in [(1, Some(crate::PreviewColorSpace::Srgb)), (2, Some(crate::PreviewColorSpace::AdobeRgb)), (3, None)] {
diff --git a/crates/raw/src/vendor/nef_lens.rs b/crates/raw/src/vendor/nef_lens.rs
new file mode 100644
index 000000000..c5e9490eb
--- /dev/null
+++ b/crates/raw/src/vendor/nef_lens.rs
@@ -0,0 +1,259 @@
+//! Nikon's in-file distortion correction (raw SubIFD tag `0xc7d5`, NikonNEFInfo), expressed as a DNG warp for
+//! the shared optics pipeline.
+//!
+//! Tag identity: the ExifTool Nikon tag-name documentation (NikonNEFInfo `0xc7d5`; its DistortionInfo `0x0005`
+//! with DistortionCorrection and RadialDistortionCoefficient1..3). The model below (which stored value multiplies
+//! which power, the normalisation, the fill-frame zoom and the optical-centre offset in entry `0x0008`) was inferred
+//! independently by matching the raw against the camera's own corrected JPEG; see docs/nikon-lens-corrections.md.
+//! No lens database or external decoder code.
+
+use crate::{Opcode, Rect};
+use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, parse_ifd_at};
+
+const NEF_INFO: u16 = 0xc7d5;
+const DISTORTION_INFO: u16 = 0x0005;
+const CENTRE_OFFSET: u16 = 0x0008;
+/// Bodies whose files were checked against the camera's JPEG (docs/nikon-lens-corrections.md). Matching tags on
+/// another body do not establish its pixel pitch or image area; add a body only after checking its files.
+const VALIDATED: &[&str] = &["NIKON Z 6_2", "NIKON Z 8", "NIKON Z f"];
+/// The normalisation radius every checked file stores: the FX half-diagonal in millimetres.
+const FX_HALF_DIAGONAL_MM: f64 = 21.6;
+const SAMPLES: usize = 256;
+
+/// `r_src = r_out * (1 + k3 rho^2 + k2 rho^4 + k1 rho^6) / (1 + zoom)` around the optical centre, `rho` the radius
+/// over the half-diagonal of the image area (`k1..k3` as ExifTool names them, in the order they are stored).
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Distortion {
+ k: [f64; 3],
+ zoom: f64,
+}
+
+/// A signed rational at `at` (`i32 / i32`), when it is one.
+fn rational(b: &[u8], at: usize, order: ByteOrder) -> Option {
+ let n = order.u32(b.get(at..at + 4)?.try_into().ok()?) as i32;
+ let d = order.u32(b.get(at + 4..at + 8)?.try_into().ok()?) as i32;
+ (d > 0).then(|| f64::from(n) / f64::from(d)).filter(|v| v.is_finite())
+}
+
+/// The NikonNEFInfo entries: the blob is `"Nikon\0"`, a 4-byte version, then a little TIFF stream.
+fn nef_info(raw: &Ifd) -> Option<(Ifd, ByteOrder)> {
+ let tiff = raw.bytes(NEF_INFO)?.strip_prefix(b"Nikon\0")?.get(4..)?;
+ let (order, false) = lightcraft_tiff::Tiff::sniff(tiff)? else { return None };
+ let first = order.u32(tiff.get(4..8)?.try_into().ok()?);
+ let opts = ParseOptions { follow_children: false, ..Default::default() };
+ let (ifd, _) = parse_ifd_at(tiff, u64::from(first), order, 0, false, &opts).ok()?;
+ Some((ifd, order))
+}
+
+/// DistortionInfo: `"0100"`, the correction mode (0 none), the normalisation radius (mm) at 8, the term count (4)
+/// at 16, the coefficients at 20 / 28 / 36 and the zoom at 64, each a signed rational.
+fn distortion_info(b: &[u8], order: ByteOrder) -> Option {
+ if b.get(0..4)? != b"0100" || *b.get(4)? == 0 {
+ return None;
+ }
+ let norm = rational(b, 8, order)?;
+ let terms = order.u32(b.get(16..20)?.try_into().ok()?);
+ if (norm - FX_HALF_DIAGONAL_MM).abs() > 0.05 || terms != 4 {
+ return None;
+ }
+ let k = [rational(b, 20, order)?, rational(b, 28, order)?, rational(b, 36, order)?];
+ let zoom = rational(b, 64, order)?;
+ // real lenses stay far inside these (the checked files: |k| < 0.13, |zoom| < 0.09)
+ (k.iter().all(|v| v.abs() < 1.0) && zoom.abs() < 0.5).then_some(Distortion { k, zoom })
+}
+
+/// Entry `0x0008`: `"0100"`, then the optical centre's offset from the image-area centre in pixels (x right,
+/// y down), two signed rationals. Zero when the lens stabilises (the sensor stays centred).
+fn centre_offset(b: &[u8], order: ByteOrder) -> Option<(f64, f64)> {
+ (b.get(0..4)? == b"0100").then_some(())?;
+ Some((rational(b, 4, order)?, rational(b, 12, order)?))
+}
+
+/// The correction of a `model` NEF whose raw IFD is `raw`, for the image area `crop` inside `active` (both in
+/// sensor pixels), as an `OpcodeList3` warp in active-area coordinates. `None` for unvalidated bodies, other
+/// image areas, missing or implausible data, and maps that fold.
+pub(super) fn distortion(model: &str, raw: &Ifd, active: Rect, crop: Rect) -> Option {
+ if !VALIDATED.contains(&model.trim()) {
+ return None;
+ }
+ let crop = crop.clipped(active.width, active.height);
+ if active.width < 2 || active.height < 2 || crop.width < 2 || crop.height < 2 {
+ return None;
+ }
+ // the stored radius is the FX half-diagonal: only the full 3:2 image area (not a DX or other crop of the
+ // sensor) is normalised by it
+ if (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 || (crop.width as f64) < active.width as f64 * 0.95 {
+ return None;
+ }
+ let (info, order) = nef_info(raw)?;
+ let d = distortion_info(info.bytes(DISTORTION_INFO)?, order)?;
+ let (ox, oy) = info.bytes(CENTRE_OFFSET).and_then(|b| centre_offset(b, order)).unwrap_or((0.0, 0.0));
+ let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0);
+ if ox.abs() > cw * 0.05 || oy.abs() > ch * 0.05 {
+ return None;
+ }
+ let half_diagonal = cw.hypot(ch) / 2.0;
+ let poly = |rho: f64| {
+ let x = rho * rho;
+ 1.0 + x * (d.k[2] + x * (d.k[1] + x * d.k[0]))
+ };
+ // DNG normalises by the farthest active-area corner from the centre; the image area reaches at most there
+ let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0);
+ let (cx, cy) = (crop.x as f64 + cw / 2.0 + ox, crop.y as f64 + ch / 2.0 + oy);
+ let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy));
+ let a = farthest / half_diagonal;
+ // the source radius must grow with the output radius out to the farthest corner, or the map folds
+ let mut previous = -1.0;
+ for j in 0..=SAMPLES {
+ let rho = a * j as f64 / SAMPLES as f64;
+ let source = rho * poly(rho) / (1.0 + d.zoom);
+ if !source.is_finite() || source <= previous {
+ return None;
+ }
+ previous = source;
+ }
+ let s = 1.0 / (1.0 + d.zoom);
+ let a2 = a * a;
+ let planes = vec![[s, d.k[2] * a2 * s, d.k[1] * a2 * a2 * s, d.k[0] * a2 * a2 * a2 * s, 0.0, 0.0]];
+ Some(Opcode::WarpRectilinear { planes, center: [cx / ax, cy / ay] })
+}
+
+#[cfg(test)]
+pub(in crate::vendor) mod tests {
+ use super::*;
+ use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value};
+
+ const AREA: Rect = Rect { x: 0, y: 0, width: 6064, height: 4040 };
+ const CROP: Rect = Rect { x: 8, y: 8, width: 6048, height: 4024 };
+
+ fn rat(v: f64, den: i32) -> [u8; 8] {
+ let mut b = [0u8; 8];
+ b[..4].copy_from_slice(&((v * f64::from(den)).round() as i32).to_le_bytes());
+ b[4..].copy_from_slice(&den.to_le_bytes());
+ b
+ }
+
+ /// DistortionInfo as the checked files lay it out (84 bytes, little-endian).
+ pub(in crate::vendor) fn distortion_block(mode: u8, norm: f64, terms: u32, k: [f64; 3], zoom: f64) -> Vec {
+ let mut b = vec![0u8; 84];
+ b[..4].copy_from_slice(b"0100");
+ b[4] = mode;
+ b[5] = 1;
+ b[8..16].copy_from_slice(&rat(norm, 4096));
+ b[16..20].copy_from_slice(&terms.to_le_bytes());
+ for (i, v) in k.iter().enumerate() {
+ b[20 + 8 * i..28 + 8 * i].copy_from_slice(&rat(*v, 1 << 20));
+ }
+ b[44..52].copy_from_slice(&rat(0.0, 1));
+ b[52..60].copy_from_slice(&rat(0.0, 1));
+ b[64..72].copy_from_slice(&rat(zoom, 1 << 20));
+ b
+ }
+
+ pub(in crate::vendor) fn offset_block(x: f64, y: f64) -> Vec