-linux-aarch64.rpm` | |
@@ -623,8 +648,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/README.zh-hans.md b/README.zh-hans.md
index f2d155214..58e1d627f 100644
--- a/README.zh-hans.md
+++ b/README.zh-hans.md
@@ -18,7 +18,7 @@
- 照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。
+ 照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
@@ -299,9 +299,9 @@ lightcraft --control 7980 ~/Pictures/trip
LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)):
-- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在
+- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在
[docs/parity.md](docs/parity.md)。
-- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
+- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
@@ -453,9 +453,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI
人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本:
-- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
+- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依据公开规范和黑盒观察来工作。
-- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
+- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个
图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中
于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。
@@ -579,8 +579,6 @@ ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改
本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
分支和修改版本必须移除它们。
-Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。
-
由 ArtCraft 团队和社区制作。
diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs
index 01dae3055..c8371cbb9 100644
--- a/apps/lightcraft-cli/src/main.rs
+++ b/apps/lightcraft-cli/src/main.rs
@@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> {
for (k, v) in opts {
p[k] = v;
}
- let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
if p.get("format").is_none() {
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
- o.format = ExportFormat::parse(&ext)
- .ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
+ ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
+ // given before validating, so `bitDepth` is checked against the format written (issue #1952)
+ p["format"] = json!(ext);
}
+ let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
// Add to Apple Photos: refused before anything is written when it can't be done
let after = s.after_export(&o)?;
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
diff --git a/apps/lightcraft-web/src/backend.rs b/apps/lightcraft-web/src/backend.rs
index e20f6dc9c..1dbc2266b 100644
--- a/apps/lightcraft-web/src/backend.rs
+++ b/apps/lightcraft-web/src/backend.rs
@@ -22,6 +22,12 @@ fn err(e: JsValue) -> String {
e.as_string().or_else(|| e.dyn_ref::().map(|e| String::from(e.message()))).unwrap_or_else(|| format!("{e:?}"))
}
+/// Did an OPFS call fail because the entry doesn't exist (`NotFoundError`)? Any other failure
+/// (security, type mismatch, an unreadable or evicted store) is an error, never "absent" (#1507).
+fn not_found(e: &JsValue) -> bool {
+ crate::files::is_not_found(Reflect::get(e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref())
+}
+
/// `navigator.storage` in a window or a worker.
fn storage_manager() -> Option {
let g = js_sys::global();
@@ -134,10 +140,7 @@ impl Backend {
opts.set_create(create);
match JsFuture::from(dir.get_directory_handle_with_options(p, &opts)).await {
Ok(d) => dir = d.unchecked_into(),
- Err(e) if !create => {
- let _ = e;
- return Ok(None);
- }
+ Err(e) if !create && not_found(&e) => return Ok(None),
Err(e) => return Err(err(e)),
}
}
@@ -150,7 +153,7 @@ impl Backend {
opts.set_create(create);
match JsFuture::from(dir.get_file_handle_with_options(&name, &opts)).await {
Ok(h) => Ok(Some(h.unchecked_into())),
- Err(_) if !create => Ok(None),
+ Err(e) if !create && not_found(&e) => Ok(None),
Err(e) => Err(err(e)),
}
}
@@ -250,7 +253,7 @@ impl Backend {
let Some((dir, name)) = Self::parent(root, path, false).await? else { return Ok(()) };
match JsFuture::from(dir.remove_entry(&name)).await {
Ok(_) => Ok(()),
- Err(e) if Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref() == Some("NotFoundError") => Ok(()),
+ Err(e) if not_found(&e) => Ok(()),
Err(e) => Err(err(e)),
}
}
diff --git a/apps/lightcraft-web/src/backup.rs b/apps/lightcraft-web/src/backup.rs
index 00ec12a41..281a32296 100644
--- a/apps/lightcraft-web/src/backup.rs
+++ b/apps/lightcraft-web/src/backup.rs
@@ -52,6 +52,18 @@ pub fn valid_hash(h: &str) -> bool {
!h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric())
}
+/// Why a backup can't start yet, given [`lightcraft_engine::Session::unsaved`]: changes applied in
+/// the app but not yet written to browser storage (a save failed). The zip copies the stored
+/// library files, so it would report success while missing them; `None` when everything is saved.
+pub fn unsaved_refusal(unsaved: Option<(usize, &str)>) -> Option {
+ let (ops, why) = unsaved?;
+ let changes = if ops == 1 { "1 change".to_string() } else { format!("{ops} changes") };
+ Some(format!(
+ "Backup not started: {changes} could not be saved to browser storage yet ({why}), so the backup would miss them. \
+ Free up storage space; LightCraft retries the save with every change. Back up once nothing is left unsaved."
+ ))
+}
+
/// Zip entry name for an original.
pub fn original_entry(hash: &str, file_name: &str) -> String {
let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect();
diff --git a/apps/lightcraft-web/src/files.rs b/apps/lightcraft-web/src/files.rs
index d0c9f348b..9cd934ce5 100644
--- a/apps/lightcraft-web/src/files.rs
+++ b/apps/lightcraft-web/src/files.rs
@@ -198,6 +198,27 @@ impl Store for FilesStore {
/// Names of the library files read at start-up.
pub const LIBRARY_FILES: [&str; 6] = ["catalog.snap", "catalog.log", "presets.json", "view.json", "prefs.json", "ui.json"];
+/// Is a browser storage error, by its `DOMException` name, "the entry doesn't exist"? Only
+/// `NotFoundError` is; anything else (security, type mismatch, an unreadable store) is a failure.
+pub fn is_not_found(dom_exception_name: Option<&str>) -> bool {
+ dom_exception_name == Some("NotFoundError")
+}
+
+/// Preload the library files read at start-up (`None`: the file doesn't exist yet). A file that
+/// exists but couldn't be read stops start-up instead (#1507): opening the library without it
+/// would show an incomplete catalog and then save over the stored copy.
+pub fn preload_library(files: &Files, reads: Vec<(&str, Result>, String>)>) -> Result<(), String> {
+ let mut failed = Vec::new();
+ for (name, read) in reads {
+ match read {
+ Ok(Some(data)) => files.preload(name, data),
+ Ok(None) => {}
+ Err(e) => failed.push(format!("{name}: {e}")),
+ }
+ }
+ if failed.is_empty() { Ok(()) } else { Err(format!("couldn't read the library from browser storage ({})", failed.join("; "))) }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -282,6 +303,66 @@ mod tests {
assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived");
}
+ /// A backup copies the stored library files, so while a save is failing it would miss the
+ /// queued changes: it is refused with the reason until they are written (#1508).
+ #[test]
+ fn backup_is_refused_while_changes_are_unsaved() {
+ let files = Files::default();
+ let mut s = Session::new();
+ let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false };
+ s.open_library_in(stores, true).unwrap();
+ assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "everything saved: the backup may start");
+ files.set_error(Some("QuotaExceededError".into()));
+ let _ = s.execute("photo.rate", &json!({"rating": 5}));
+ let refusal = crate::backup::unsaved_refusal(s.unsaved()).expect("refused while the rating is unsaved");
+ assert!(refusal.contains("could not be saved") && refusal.contains("QuotaExceeded"), "{refusal}");
+ files.set_error(None);
+ s.persist_if_dirty();
+ s.persist().unwrap();
+ assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "allowed again once the change is written");
+ }
+
+ #[test]
+ fn unsaved_refusal_counts_changes() {
+ use crate::backup::unsaved_refusal;
+ assert!(unsaved_refusal(Some((1, "quota"))).unwrap().contains("1 change could not"));
+ assert!(unsaved_refusal(Some((3, "quota"))).unwrap().contains("3 changes could not"));
+ }
+
+ /// Start-up only treats a missing file as absent: a stored file that couldn't be read stops
+ /// it, naming the file, instead of opening an incomplete library (#1507).
+ #[test]
+ fn unreadable_library_file_stops_start_up() {
+ let files = Files::default();
+ let reads = vec![
+ ("catalog.snap", Ok(Some(b"snap".to_vec()))),
+ ("catalog.log", Err("NotReadableError: the file could not be read".to_string())),
+ ("presets.json", Ok(None)),
+ ];
+ let e = preload_library(&files, reads).unwrap_err();
+ assert!(e.contains("catalog.log") && e.contains("NotReadableError"), "{e}");
+ }
+
+ /// A new library (nothing stored yet) still starts.
+ #[test]
+ fn missing_library_files_are_a_new_library() {
+ let files = Files::default();
+ let reads = LIBRARY_FILES.iter().map(|n| (*n, Ok(None))).collect();
+ preload_library(&files, reads).unwrap();
+ assert!(files.get("catalog.snap").is_none());
+ let reads = vec![("catalog.snap", Ok(Some(b"snap".to_vec()))), ("catalog.log", Ok(None))];
+ preload_library(&files, reads).unwrap();
+ assert_eq!(files.get("catalog.snap").as_deref(), Some(&b"snap"[..]));
+ }
+
+ #[test]
+ fn only_not_found_errors_mean_absent() {
+ assert!(is_not_found(Some("NotFoundError")));
+ for name in [Some("SecurityError"), Some("TypeMismatchError"), Some("NotReadableError"), Some("InvalidStateError"), None] {
+ assert!(!is_not_found(name), "{name:?}");
+ }
+ }
+
#[test]
fn library_survives_reload_through_flushes() {
let mut disk = BTreeMap::new();
diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs
index 6a4487d09..14a5d050b 100644
--- a/apps/lightcraft-web/src/web.rs
+++ b/apps/lightcraft-web/src/web.rs
@@ -173,6 +173,9 @@ fn services(originals: Originals, backend: Option, files: Files, frozen
screen_profile: None,
backup_library: Some(Box::new(move |session: &mut Session| {
let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) };
+ if let Some(why) = crate::backup::unsaved_refusal(session.unsaved()) {
+ return Err(why);
+ }
// the photos' own names for the originals in the zip
let names: std::collections::HashMap = session
.catalog
@@ -280,6 +283,9 @@ struct Boot {
index: ThumbIndex,
/// The library folder in storage (`library`, or a restored backup's).
lib_dir: String,
+ /// Library files that exist but couldn't be read (#1507): the library isn't opened and
+ /// nothing is saved, so the stored copy stays as it was.
+ load_error: Option,
}
async fn boot(opts: Options) -> Boot {
@@ -298,6 +304,7 @@ async fn boot(opts: Options) -> Boot {
let files = Files::default();
let mut index = ThumbIndex::default();
let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string();
+ let mut load_error = None;
if let Some(b) = &backend {
if opts.reset {
// everything this site keeps in the browser, photos included: never without asking
@@ -315,21 +322,32 @@ async fn boot(opts: Options) -> Boot {
}
}
// a restored backup lives in its own folder (see `backup`)
- if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await {
- let d = String::from_utf8_lossy(&d).trim().to_string();
- if valid_library_dir(&d) {
- lib_dir = d;
+ match b.read(ACTIVE_LIBRARY).await {
+ Ok(Some(d)) => {
+ let d = String::from_utf8_lossy(&d).trim().to_string();
+ if valid_library_dir(&d) {
+ lib_dir = d;
+ }
+ }
+ Ok(None) => {}
+ // not knowing which library is in use, any library opened could be the wrong one
+ Err(e) => load_error = Some(format!("couldn't read which library is in use from browser storage ({e})")),
+ }
+ if load_error.is_none() {
+ let mut reads = Vec::new();
+ for name in LIBRARY_FILES {
+ reads.push((name, b.read(&library_key(&lib_dir, name)).await));
+ }
+ if let Err(e) = crate::files::preload_library(&files, reads) {
+ log::error!("{e}");
+ load_error = Some(e);
}
}
- for name in LIBRARY_FILES {
- match b.read(&library_key(&lib_dir, name)).await {
- Ok(Some(data)) => files.preload(name, data),
- Ok(None) => {}
- Err(e) => log::error!("reading {name}: {e}"),
- }
- }
- if let Ok(Some(j)) = b.read(THUMB_INDEX).await {
- index = ThumbIndex::from_json(&j);
+ let index_read = b.read(THUMB_INDEX).await;
+ match &index_read {
+ Ok(Some(j)) => index = ThumbIndex::from_json(j),
+ Ok(None) => {}
+ Err(e) => log::warn!("reading the thumbnail index: {e}"),
}
crate::backend::request_persistence(|granted| {
if !granted {
@@ -339,7 +357,8 @@ async fn boot(opts: Options) -> Boot {
);
}
});
- // thumbnails stored without an index entry (index saved late, or lost): delete them
+ // thumbnails stored without an index entry (index saved late, or lost): delete them; not
+ // when the index couldn't be read, which would make every thumbnail look unindexed
let b2 = b.clone();
let known: std::collections::HashSet = b
.list("thumbs")
@@ -349,7 +368,7 @@ async fn boot(opts: Options) -> Boot {
.filter(|n| n.ends_with(".jpg"))
.map(|n| n.trim_end_matches(".jpg").to_string())
.collect();
- let orphans: Vec = known.into_iter().filter(|k| !index.contains(k)).collect();
+ let orphans: Vec = if index_read.is_ok() { known.into_iter().filter(|k| !index.contains(k)).collect() } else { Vec::new() };
if !orphans.is_empty() {
wasm_bindgen_futures::spawn_local(async move {
for k in &orphans {
@@ -366,7 +385,7 @@ async fn boot(opts: Options) -> Boot {
index.len(),
index.total() as f64 / 1e6
);
- Boot { opts, backend, files, index, lib_dir }
+ Boot { opts, backend, files, index, lib_dir, load_error }
}
struct WebApp {
@@ -389,7 +408,7 @@ struct WebApp {
impl WebApp {
fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec) -> Self {
- let Boot { opts, backend, files, index, lib_dir } = boot;
+ let Boot { opts, backend, files, index, lib_dir, load_error } = boot;
let originals = Originals::default();
let t = perf_now();
let mut session = Session::new();
@@ -401,7 +420,13 @@ impl WebApp {
on_disk: false,
};
let mut problem = None;
- match session.open_library_in(stores, true).cloned() {
+ // a library that couldn't be read is not opened: it would show incomplete, then save over the stored copy
+ let read_failed = load_error.is_some();
+ let opened = match load_error {
+ Some(e) => Err(format!("{e}. Nothing was changed in storage")),
+ None => session.open_library_in(stores, true).cloned().map_err(|e| e.to_string()),
+ };
+ match opened {
Ok(r) => log::info!(
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
if r.created { "created" } else { "loaded" },
@@ -432,7 +457,8 @@ impl WebApp {
}
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
session.media.preview_capacity = 3;
- let frozen = Rc::new(Cell::new(false));
+ // nothing is saved while the stored library couldn't be read
+ let frozen = Rc::new(Cell::new(read_failed));
let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone()))
.with_chinese_font(chinese_font);
let ui_written = files.get("ui.json").unwrap_or_default();
@@ -667,11 +693,40 @@ impl eframe::App for WebApp {
}
}
+/// "Auto" follows the browser's languages (issue #2085): the first of `navigator.languages` (else
+/// `navigator.language`) that LightCraft ships becomes the UI's default. Every step is fallible
+/// (as in [`crate::safety::acquire_tab_lock`]): without a usable navigator Auto stays English,
+/// as before.
+fn install_browser_language() {
+ let Ok(navigator) = js_sys::Reflect::get(&js_sys::global(), &"navigator".into()) else { return };
+ let mut tags: Vec = Vec::new();
+ if let Ok(languages) = js_sys::Reflect::get(&navigator, &"languages".into())
+ && let Some(languages) = languages.dyn_ref::()
+ {
+ for tag in languages.iter() {
+ if let Some(tag) = tag.as_string() {
+ tags.push(tag);
+ }
+ }
+ }
+ if tags.is_empty()
+ && let Ok(language) = js_sys::Reflect::get(&navigator, &"language".into())
+ && let Some(language) = language.as_string()
+ {
+ tags.push(language);
+ }
+ if let Some(language) = lightcraft_ui_egui::i18n::locale_from_tags(&tags) {
+ lightcraft_ui_egui::i18n::set_default_language(language);
+ log::info!("lightcraft: default language {language:?} from the browser's languages");
+ }
+}
+
/// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds).
#[wasm_bindgen]
pub fn start(chinese_font: Vec) {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
safety::install_panic_hook();
+ install_browser_language();
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
if chinese_font.is_empty() {
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
diff --git a/contributors/people.toml b/contributors/people.toml
index bec67597c..6f0bd6085 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -45,3 +45,19 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.nledford]
+real_name = "Nathaniel Ledford"
+
+[people.marcoarnulfo]
+real_name = "Marco Arnulfo"
+display_name = "Marco Arnulfo"
+sync_github_name = false
+
+[people.egegl]
+real_name = "Ege Gursel"
+sync_github_name = true
+
+[people.mahmatias]
+display_name = "Márcio Matias de Andrade"
+sync_github_name = true
diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs
index d180d013d..ca463dc55 100644
--- a/crates/catalog/src/dates.rs
+++ b/crates/catalog/src/dates.rs
@@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option {
if !(first..=last).contains(&t) {
return None;
}
- let tail = iso.trim().get(19..).unwrap_or("");
+ let iso = iso.trim();
+ // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
+ let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
}
@@ -222,6 +224,24 @@ mod tests {
assert_eq!(normalize_iso("2026-02-30"), None);
}
+ #[test]
+ fn shift_keeps_the_zone_of_a_time_without_seconds() {
+ assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
+ assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
+ assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
+ }
+
+ #[test]
+ fn impossible_times_are_rejected_not_rolled_over() {
+ for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] {
+ assert_eq!(normalize_iso(bad), None, "{bad}");
+ assert_eq!(shift_iso(bad, 5), None, "{bad}");
+ }
+ assert_eq!(normalize_iso("2026-04-01T23:59:59").as_deref(), Some("2026-04-01T23:59:59"));
+ }
+
#[test]
fn runs_by_day_month_year_keep_stacks_together() {
let mut c = Catalog::new();
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
index 289ee4fb8..edcfae22a 100644
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
- (rec.0 <= last + 1).then_some((i, rec))
+ (rec.0 <= last.saturating_add(1)).then_some((i, rec))
})
}
@@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
}
+/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow.
+const MAX_SEQ: u64 = u64::MAX / 2;
+
/// Just the identification of `catalog.snap` (read before the catalog itself).
#[derive(serde::Deserialize)]
struct SnapHeader {
@@ -331,6 +334,10 @@ 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}")))?;
+ // a seq this high can't come from real use; the next record's seq would wrap
+ if s.seq > MAX_SEQ {
+ return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq)));
+ }
let mut catalog = s.catalog;
report.repaired = catalog.repair();
for r in &report.repaired {
@@ -392,7 +399,7 @@ impl Journal {
let _ = op;
report.stale += 1;
}
- Some((seq, op)) if seq == j.seq + 1 => {
+ Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => {
if catalog.apply(op).is_err() {
report.failed += 1;
} else {
@@ -476,7 +483,7 @@ impl Journal {
let mut buf = String::new();
let mut seq = self.seq;
for op in ops {
- seq += 1;
+ seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?;
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
@@ -527,7 +534,7 @@ impl Journal {
log::warn!("catalog: background snapshot failed ({e}); writing one now");
}
let t0 = web_time::Instant::now();
- let seq = self.seq + unlogged;
+ let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?;
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
Ok(t) => t,
Err(e) => {
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index 0c7ca5112..6ac3544ee 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -643,6 +643,7 @@ mod edited_tests {
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("Canon Canon EOS R6m2", "CR3", false),
+ ("Canon Canon EOS R6 Mark II", "CR3", false),
("Canon Canon EOS R7", "CR3", true),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index d1548f8bd..7625c9ab6 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -157,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
+ // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8.
+ let cmp32 = |x: f32, s: &str| match num(s) {
+ Some(('>', v)) => x > v as f32,
+ Some(('<', v)) => x < v as f32,
+ Some((_, v)) => x == v as f32,
+ None => false,
+ };
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
- "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
- "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
+ "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)),
+ "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)),
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
@@ -173,11 +180,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
- _ => false,
+ // Not a known field: the colon is part of a plain word ("10:30", "Re:").
+ _ => text_matches(p, &t),
};
}
+ text_matches(p, &t)
+}
+
+/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
+/// format or a keyword of `p`.
+fn text_matches(p: &Photo, t: &str) -> bool {
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
- hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
+ hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs
index a713b7736..6542bfe18 100644
--- a/crates/catalog/src/stacks.rs
+++ b/crates/catalog/src/stacks.rs
@@ -294,7 +294,11 @@ pub fn iso_seconds(s: &str) -> Option {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
- let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
+ let (hh, mm, ss) =
+ if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
+ if hh >= 24 || mm >= 60 || ss >= 60 {
+ return None;
+ }
// days from civil (proleptic Gregorian)
let y2 = if m <= 2 { y - 1 } else { y };
let era = y2.div_euclid(400);
diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs
index 2f3dc4912..553eb241f 100644
--- a/crates/catalog/src/store.rs
+++ b/crates/catalog/src/store.rs
@@ -242,7 +242,7 @@ impl Store for MemStore {
}
}
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests_staging {
use super::*;
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index 8f1aaf151..a32b5acf5 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -123,6 +123,48 @@ fn filter_search_sort() {
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
}
+/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
+/// text, not a fielded token that matches nothing.
+#[test]
+fn free_text_with_a_colon_searches_the_text_fields() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
+ let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.caption = "Meeting at 10:30 with Re: notes".into();
+ m.iso = Some(1600);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("meeting"), vec![a]);
+ assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
+ assert_eq!(q("re:"), vec![a]);
+ assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
+ assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
+ assert_eq!(q("iso:>2000"), Vec::::new());
+ assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing");
+}
+
+/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary
+/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value.
+#[test]
+fn search_tokens_match_f32_aperture_and_focal() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.aperture = Some(2.8);
+ m.focal_mm = Some(5.6);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("f:2.8"), vec![a], "f:2.8");
+ assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8");
+ assert_eq!(q("focal:5.6"), vec![a], "focal:5.6");
+ assert!(q("f:2.9").is_empty(), "f:2.9");
+ assert!(q("f:<2.8").is_empty(), "f:<2.8");
+ assert!(q("f:>2.8").is_empty(), "f:>2.8");
+ assert!(q("focal:<5.6").is_empty(), "focal:<5.6");
+ assert_eq!(q("f:>2.7"), vec![a], "f:>2.7");
+}
+
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs
index 8570bd077..26b4e902a 100644
--- a/crates/catalog/src/tests_journal.rs
+++ b/crates/catalog/src/tests_journal.rs
@@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() {
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
}
+
+/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would
+/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug.
+#[test]
+fn snapshot_with_seq_near_u64_max_is_corrupt() {
+ for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] {
+ let m = MemStore::new();
+ m.set(
+ SNAPSHOT,
+ format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(),
+ );
+ let e = Journal::open(Box::new(m)).err();
+ assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}");
+ }
+}
diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml
index 1ae016bc8..589d285fa 100644
--- a/crates/codecs/Cargo.toml
+++ b/crates/codecs/Cargo.toml
@@ -55,3 +55,6 @@ jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+
+[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
+dhat = { workspace = true }
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index 6ea5866b7..5902243fc 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB,
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
+Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity
+become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths
+preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1.
+
## Dependencies and licences
All pure Rust, verified with `cargo info` / `cargo tree`:
diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs
index 02652db5a..2cdf67381 100644
--- a/crates/codecs/src/convert.rs
+++ b/crates/codecs/src/convert.rs
@@ -44,11 +44,23 @@ impl Buf {
match self {
Buf::U8(v) => v[i] as f32 / 255.0,
Buf::U16(v) => v[i] as f32 / 65535.0,
- Buf::F32(v) => v[i],
+ Buf::F32(v) => normalize_float(v[i]),
}
}
}
+/// Clip non-finite device samples before colour conversion, preserving finite extended range.
+#[inline]
+fn normalize_float(value: f32) -> f32 {
+ if value == f32::INFINITY {
+ 1.0
+ } else if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
/// Interleaved device samples as produced by a format decoder.
pub(crate) struct Raw {
pub width: usize,
@@ -87,9 +99,10 @@ pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) -
}
pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result {
- let n = raw.width * raw.height;
let ch = raw.model.channels() + raw.alpha as usize;
- if n == 0 || raw.buf.len() < n * ch {
+ let buf_len =
+ crate::checked_sample_buf_len(raw.width, raw.height, ch).ok_or_else(|| Error::Malformed(format, "sample buffer too short".into()))?;
+ if (raw.buf.len() as u64) < buf_len {
return Err(Error::Malformed(format, "sample buffer too short".into()));
}
let float = matches!(raw.buf, Buf::F32(_));
@@ -129,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3
let (image, alpha) = match opts.max_size {
Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => {
let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64);
- let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell };
+ // (Lanczos at 2:1 and more as well: a box average there halves the contrast of
+ // detail near the output's resolution limit and aliases what is finer)
+ let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell };
let img = fit(&image, mw as usize, mh as usize, f);
let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f));
(img, alpha)
@@ -218,10 +233,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
par_rows(&mut img.data, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * w + x) * ch;
- let s = |c: usize| {
- let val = v[i + c];
- if val.is_finite() { val } else { 0.0 }
- };
+ let s = |c: usize| normalize_float(v[i + c]);
*o = if gray {
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
[l, l, l]
diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs
index 26e144aed..adf59c0aa 100644
--- a/crates/codecs/src/jxl.rs
+++ b/crates/codecs/src/jxl.rs
@@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
-use jxl_oxide::{AuxBoxData, JxlImage};
+use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage};
const F: Format = Format::Jxl;
@@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
+/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees.
+/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517).
+const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8;
+
+/// The allocation budget never drops below this, so small images keep the decoder's fixed
+/// overhead (group buffers, entropy tables) whatever `max_pixels` is.
+const MIN_TRACKED_BYTES: u64 = 64 << 20;
+
+/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in).
+const INPUT_CHUNK: usize = 4096;
+
+/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames
+/// within a tracked allocation budget.
+fn open(bytes: &[u8], opts: &DecodeOptions) -> Result {
+ let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES);
+ let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX));
+ let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit();
+ // Initialization can load already-buffered frame data, so small feeds limit work before
+ // dimension checking.
+ let mut rest = bytes;
+ let mut window = INPUT_CHUNK;
+ let mut img = loop {
+ let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?;
+ rest = rest.get(consumed..).unwrap_or_default();
+ match uninit.try_init().map_err(err)? {
+ InitializeResult::Initialized(img) => break img,
+ InitializeResult::NeedMoreData(more) => uninit = more,
+ }
+ if consumed == 0 {
+ // the parser wants more than the window holds at once
+ if window >= rest.len() {
+ return Err(err("truncated JPEG XL header"));
+ }
+ window = window.saturating_mul(2);
+ }
+ };
+ check_size(F, img.width() as u64, img.height() as u64, opts)?;
+ // Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first
+ // keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail
+ // after the last frame; feeding the remaining file at once bypasses the allocation budget.
+ while !img.is_loading_done() && !rest.is_empty() {
+ let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?;
+ if consumed == 0 {
+ break;
+ }
+ rest = rest.get(consumed..).unwrap_or_default();
+ }
+ img.finalize().map_err(err)?;
+ Ok(img)
+}
+
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
- let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?;
+ let mut img = open(bytes, opts)?;
let (w, h) = (img.width(), img.height());
- check_size(F, w as u64, h as u64, opts)?;
let header = img.image_header();
let bits = header.metadata.bit_depth.bits_per_sample() as u8;
let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs
index d14b1812f..a20a47d14 100644
--- a/crates/codecs/src/lib.rs
+++ b/crates/codecs/src/lib.rs
@@ -338,3 +338,29 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Option {
+ (width as u64).checked_mul(height as u64).and_then(|n| n.checked_mul(ch as u64)).filter(|&n| n > 0)
+}
+
+#[cfg(test)]
+mod checked_arithmetic_tests {
+ use super::*;
+
+ #[test]
+ fn checked_sample_buf_len_wrap_inputs_rejected() {
+ assert_eq!(checked_sample_buf_len(usize::MAX, usize::MAX, 4), None);
+ assert_eq!(checked_sample_buf_len(0, 1, 3), None);
+ }
+
+ #[test]
+ fn checked_sample_buf_len_normal_accepted() {
+ assert_eq!(checked_sample_buf_len(100, 1, 3), Some(300));
+ assert_eq!(checked_sample_buf_len(1, 1, 1), Some(1));
+ // 300 Mpx RGBA is within the default pixel limit, so it must not be refused by sample count.
+ assert_eq!(checked_sample_buf_len(20_000, 15_000, 4), Some(1_200_000_000));
+ }
+}
diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs
index b077c214a..1bbfa8e6c 100644
--- a/crates/codecs/src/tiff_codec.rs
+++ b/crates/codecs/src/tiff_codec.rs
@@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
fn limits(opts: &DecodeOptions) -> Limits {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
+ // A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a
+ // single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap
+ // (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows
+ // (JPEG headers, padding to the tile size).
+ limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size);
limits.ifd_value_size = 32 << 20;
limits
}
diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/float_tiff.rs
new file mode 100644
index 000000000..fe1cd2411
--- /dev/null
+++ b/crates/codecs/tests/float_tiff.rs
@@ -0,0 +1,128 @@
+//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6).
+
+use lightcraft_codecs::{icc, *};
+
+fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec {
+ encode_tiff(
+ &EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)),
+ TiffCompression::None,
+ &EncodeMeta { icc: profile, ..Default::default() },
+ )
+ .unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() {
+ let samples =
+ [[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]];
+ let bytes = tiff(samples.as_flattened(), 3, None);
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert!(decoded.float);
+ assert_eq!(decoded.bit_depth, 32);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]);
+ let display = decoded.to_srgb8();
+ assert_eq!(display.data[0], [255; 4]);
+ for pixel in &display.data[1..4] {
+ assert_eq!(*pixel, [0, 0, 0, 255]);
+ }
+ assert_eq!(display.data[6], [255, 188, 0, 255]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // A box-filtered pair must average clipped white with its ordinary neighbor.
+ let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None);
+ let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap();
+ assert_eq!(small.image.data, [[0.625; 3]]);
+}
+
+#[test]
+fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() {
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25];
+ let profiles = [
+ None,
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)),
+ Some(icc::write_named(NamedSpace::Srgb)),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))),
+ Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()),
+ Some(b"invalid ICC profile".to_vec()),
+ ];
+ for channels in [1, 3] {
+ let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ for profile in &profiles {
+ let profile = profile.as_deref();
+ let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.grayscale, channels == 1);
+ assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space);
+ assert_eq!(decoded.to_srgb8(), reference.to_srgb8());
+ assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve");
+ assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve");
+ }
+ }
+}
+
+#[test]
+fn float_tiff_nonfinite_alpha_is_finite_and_bounded() {
+ let samples = [
+ [f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY],
+ [f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN],
+ [0.25, 0.5, 2.0, f32::NEG_INFINITY],
+ [2.0, 3.0, 4.0, 0.5],
+ ];
+ let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // Associated alpha takes the same path before unpremultiplication.
+ let mut cursor = std::io::Cursor::new(Vec::new());
+ {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let mut encoder = TiffEncoder::new(&mut cursor).unwrap();
+ let mut image = encoder.new_image::(2, 1).unwrap();
+ image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
+ image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap();
+ }
+ let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]);
+ assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]);
+}
+
+fn lut_profile() -> Vec {
+ use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd};
+ let mut profile = ColorProfile::new_srgb();
+ profile.cicp = None;
+ profile.red_colorant = Xyzd::default();
+ profile.green_colorant = Xyzd::default();
+ profile.blue_colorant = Xyzd::default();
+ profile.red_trc = None;
+ profile.green_trc = None;
+ profile.blue_trc = None;
+ // A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves.
+ profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType {
+ num_input_channels: 3,
+ num_output_channels: 3,
+ grid_points: [0; 16],
+ clut: None,
+ a_curves: vec![],
+ m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
+ b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ bias: Vector3d::default(),
+ }));
+ profile.encode().unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_cms_samples_match_clipped_samples() {
+ let profile = lut_profile();
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75];
+ let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.space.origin, SpaceOrigin::IccCms);
+ assert_eq!(decoded.image, reference.image);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+}
diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/jxl_streaming.rs
new file mode 100644
index 000000000..298034656
--- /dev/null
+++ b/crates/codecs/tests/jxl_streaming.rs
@@ -0,0 +1,98 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::exif::minimal_exif;
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+fn box_bytes(out: &mut Vec, kind: &[u8; 4], data: &[u8]) {
+ out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes());
+ out.extend_from_slice(kind);
+ out.extend_from_slice(data);
+}
+
+#[test]
+fn metadata_in_final_feed_matches_builder() {
+ let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let raw = codestream(&encoded);
+ let exif = minimal_exif(1);
+ let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field
+ exif_box.extend_from_slice(&exif);
+ // Split the codestream across two feeds, with Exif and XMP following its last frame.
+ let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
+ box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl ");
+ let split = raw.len() / 2;
+ let mut part = 0u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[..split]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ let padding = vec![0; 4096 - bytes.len() - 8];
+ box_bytes(&mut bytes, b"free", &padding);
+ let mut part = 0x8000_0001u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[split..]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ box_bytes(&mut bytes, b"Exif", &exif_box);
+ box_bytes(&mut bytes, b"xml ", b" ");
+ assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk");
+ let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap();
+ let old_exif = match old.aux_boxes().first_exif().unwrap() {
+ jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(),
+ _ => panic!("builder must preserve Exif in its final feed"),
+ };
+ let old_xml = match old.aux_boxes().first_xml() {
+ jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(),
+ _ => panic!("builder must preserve XMP in its final feed"),
+ };
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice()));
+ assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap()));
+ assert_eq!(decoded.to_srgb8(), pixels);
+}
+
+#[test]
+fn progressive_truncation_matches_builder() {
+ let pixels = Rgba8::from_fn(96, 64, |x, y| {
+ let mut seed = (y * 96 + x + 1) as u32;
+ let mut rgb = [0; 4];
+ for channel in &mut rgb[..3] {
+ seed ^= seed << 13;
+ seed ^= seed >> 17;
+ seed ^= seed << 5;
+ *channel = seed as u8;
+ }
+ rgb[3] = 255;
+ rgb
+ });
+ for lossless in [false, true] {
+ let encoded =
+ encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default())
+ .unwrap();
+ let raw = codestream(&encoded);
+ assert!(raw.len() > 4096, "fixture must exercise post-header feeds");
+ for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] {
+ let bytes = &raw[..cut];
+ // `finalize` permits partial images: rendering determines whether frame 0 is usable.
+ let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok());
+ assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len());
+ }
+ }
+}
diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs
new file mode 100644
index 000000000..f2200e862
--- /dev/null
+++ b/crates/codecs/tests/jxl_tail_heap.rs
@@ -0,0 +1,43 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+#[global_allocator]
+static ALLOC: dhat::Alloc = dhat::Alloc;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+#[test]
+fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() {
+ let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let mut raw = codestream(&encoded);
+ let opts = DecodeOptions { max_pixels: 64, ..Default::default() };
+ decode(&raw, opts).unwrap();
+ raw.resize(raw.len() + (1 << 20), 0);
+ let _profiler = dhat::Profiler::builder().testing().build();
+ let decoded = decode(&raw, opts).unwrap();
+ assert_eq!((decoded.width, decoded.height), (8, 8));
+ let peak = dhat::HeapStats::get().max_bytes;
+ eprintln!("peak heap bytes: {peak}");
+ assert!(peak < (128 << 10), "tiny image used {peak} heap bytes");
+}
diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs
index af689ae5d..d802366c3 100644
--- a/crates/codecs/tests/roundtrip.rs
+++ b/crates/codecs/tests/roundtrip.rs
@@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() {
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
}
+/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877,
+/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so
+/// the thumbnail (and the full decode) failed with "decoder limits exceeded".
+#[test]
+fn tiff_single_strip_larger_than_128_mib() {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let (w, h) = (3923u32, 5877u32);
+ let mut cur = std::io::Cursor::new(Vec::new());
+ {
+ let mut enc = TiffEncoder::new(&mut cur).unwrap();
+ let mut im = enc.new_image::(w, h).unwrap();
+ im.rows_per_strip(h).unwrap();
+ im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap();
+ }
+ let bytes = cur.into_inner();
+ assert!(bytes.len() > 128 << 20);
+ let t = decode_thumbnail(&bytes, 256).unwrap();
+ assert_eq!((t.source_width, t.source_height), (w, h));
+ let px = t.image.get(10, 10);
+ assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}");
+}
+
+/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`.
+fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec {
+ let mut b = b"II*\0".to_vec();
+ b.extend(8u32.to_le_bytes());
+ b.extend((entries.len() as u16).to_le_bytes());
+ for &(tag, ty, value) in entries {
+ b.extend(tag.to_le_bytes());
+ b.extend(ty.to_le_bytes());
+ b.extend(1u32.to_le_bytes());
+ b.extend(value.to_le_bytes());
+ }
+ b.extend(0u32.to_le_bytes());
+ b.extend_from_slice(data);
+ b
+}
+
+/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so
+/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB
+/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at
+/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989.
+#[test]
+fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() {
+ let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels };
+
+ // 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget.
+ let mut jpeg = Vec::new();
+ jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap();
+ let at = 8 + 2 + 10 * 12 + 4;
+ let entries = [
+ (256, 4, 1),
+ (257, 4, 1),
+ (258, 3, 8),
+ (259, 3, 7),
+ (262, 3, 1),
+ (273, 4, at),
+ (277, 3, 1),
+ (278, 4, 1),
+ (279, 4, jpeg.len() as u32),
+ (284, 3, 1),
+ ];
+ let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (1, 1));
+
+ // 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels.
+ let at = 8 + 2 + 11 * 12 + 4;
+ let entries = [
+ (256, 4, 16),
+ (257, 4, 16),
+ (258, 3, 8),
+ (259, 3, 1),
+ (262, 3, 1),
+ (277, 3, 1),
+ (284, 3, 1),
+ (322, 4, 256),
+ (323, 4, 256),
+ (324, 4, at),
+ (325, 4, 65536),
+ ];
+ let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (16, 16));
+}
+
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
/// already packed into `strip`, one padded row after another.
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec {
@@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() {
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
+/// #1517: the pixel limit rejects an oversized image; selected truncations never panic.
+#[test]
+#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+fn jxl_size_limit_and_truncation() {
+ let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() });
+ assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}");
+ let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap();
+ assert_eq!((d.width, d.height), (97, 61));
+ for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] {
+ let _ = decode(&b[..cut], DecodeOptions::default());
+ }
+ assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err());
+}
+
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 855c3d049..89fe93c9f 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8);
impl Rgb8 {
pub fn from_hex(s: &str) -> Option {
- let s = s.trim_start_matches('#');
- if s.len() != 6 {
+ let s = s.strip_prefix('#').unwrap_or(s);
+ if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let v = u32::from_str_radix(s, 16).ok()?;
@@ -196,6 +196,15 @@ impl Rgb8 {
mod tests {
use super::*;
+ #[test]
+ fn rgb8_from_hex_rejects_signs_and_extra_hashes() {
+ assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15)));
+ assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0)));
+ for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] {
+ assert_eq!(Rgb8::from_hex(bad), None, "{bad}");
+ }
+ }
+
#[test]
fn srgb_matrix_matches_standard() {
let m = SRGB.to_xyz();
diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs
index 987a859b9..4314ed8c3 100644
--- a/crates/denoise-core/src/run.rs
+++ b/crates/denoise-core/src/run.rs
@@ -14,7 +14,7 @@ use crate::manifest::Gain;
use crate::tiles::{starts, weight};
/// Most pixels one picture may have (guards the allocations that follow from its size).
-pub const MAX_PIXELS: usize = 100_000_000;
+pub const MAX_PIXELS: usize = 110_000_000;
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
const MIN_SIGNAL: f32 = 0.003;
@@ -642,4 +642,19 @@ mod tests {
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
}
+
+ #[test]
+ fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() {
+ let layout = Layout { dx: 0, dy: 0 };
+ let model = Mock { tile: 32, scale: 1.0 };
+ let p = params(32, 8, Gain::None);
+ assert!(matches!(
+ denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the mosaic does not have width × height samples"
+ ));
+ assert!(matches!(
+ denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the picture has no size, or is too large"
+ ));
+ }
}
diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs
index f376b3c67..4631e5e49 100644
--- a/crates/develop/src/lib.rs
+++ b/crates/develop/src/lib.rs
@@ -41,7 +41,7 @@ impl DevelopSettings {
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
- /// Kelvin settings.
+ /// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]).
pub fn merged(&self, partial: &Value) -> Result {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
@@ -52,9 +52,34 @@ impl DevelopSettings {
{
out.wb.scale = WbScale::Legacy;
}
+ out.keep_in_range(self);
Ok(out)
}
+ /// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`,
+ /// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a
+ /// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop
+ /// may reach past 0..1 and is left to the crop tools, which know the photo's size.
+ fn keep_in_range(&mut self, before: &DevelopSettings) {
+ for c in controls::CONTROLS {
+ if let Some(v) = controls::get(self, c.id) {
+ controls::set(self, c.id, v);
+ }
+ }
+ let c = &mut self.curve;
+ for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() {
+ *p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
+ }
+ let g = &mut self.crop.geometry;
+ if g.angle == 0.0 {
+ g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT);
+ }
+ if g.rect.is_empty() {
+ let old = before.crop.geometry.rect;
+ g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old };
+ }
+ }
+
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
pub fn hash64(&self) -> u64 {
let s = serde_json::to_string(self).unwrap_or_default();
@@ -255,6 +280,30 @@ mod tests {
assert_eq!(m.light.exposure, 0.0);
}
+ #[test]
+ fn merged_settings_are_kept_in_range() {
+ // issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said
+ let mut base = DevelopSettings::default();
+ base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9);
+ let m = base
+ .merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}}))
+ .unwrap();
+ assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0));
+ // point curves stay in 0..1, as an imported XMP curve does
+ let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap();
+ assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]);
+ // a crop with no area is left out, as XMP import leaves out one whose edges are inverted
+ for rect in
+ [json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})]
+ {
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}");
+ }
+ // unrotated, the crop stays inside the photo
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75));
+ }
+
#[test]
fn unedited_and_reset() {
let mut s = DevelopSettings::default();
diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs
index 85f27d321..b3edc801c 100644
--- a/crates/develop/src/presets.rs
+++ b/crates/develop/src/presets.rs
@@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
-/// through the control specs.
+/// through the control specs, as every merge does ([`DevelopSettings::merged`]).
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
- let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
- for c in crate::controls::CONTROLS {
- if let Some(v) = crate::controls::get(&out, c.id) {
- crate::controls::set(&mut out, c.id, v);
- }
- }
- out
+ s.merged(&patch).unwrap_or_else(|_| s.clone())
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index ed20cd605..091efe066 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job
workspace = true
[dependencies]
+# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share.
+craft-docpath = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
diff --git a/crates/engine/build.rs b/crates/engine/build.rs
index 056c7adc8..7f5aa7777 100644
--- a/crates/engine/build.rs
+++ b/crates/engine/build.rs
@@ -3,7 +3,7 @@
//! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this
//! script only reads a local directory (no network).
//!
-//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the
+//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB) and Noto Sans Georgian, to keep the
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
//! watermark falls back to it there. Native builds embed every font.
use std::fmt::Write as _;
@@ -78,7 +78,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result {
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai");
- if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) {
+ if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai" | "Noto Sans Georgian", "Regular")) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs
index 614c58c60..e56e80305 100644
--- a/crates/engine/src/camera_preview.rs
+++ b/crates/engine/src/camera_preview.rs
@@ -34,7 +34,8 @@ use lightcraft_raw::{
/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
/// 8: the bundled NIKON Z 6_2 profile.
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
-pub const LOOK_VERSION: u32 = 9;
+/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
+pub const LOOK_VERSION: u32 = 10;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -107,14 +108,13 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo
}
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
-/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
+/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an
+/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on
+/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables
+/// to its proxy, so it fits only the tone around them.
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
let p = &raw.color.profile;
- raw.format == RawFormat::Dng
- && !t.matrix_is_fallback
- && identity_tone_curve(p.tone_curve.as_ref())
- && p.look_table.is_none()
- && p.gain_table_map.is_none()
+ raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none()
}
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
@@ -424,9 +424,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
-/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
-/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
-/// the plain render ([`clips_more`]).
+/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a
+/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not
+/// clip more highlights than the plain render ([`clips_more`]).
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option {
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
let refuse = |why: &str| {
@@ -500,8 +500,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb
}
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
-/// small thumbnail is not fitted.
-const MIN_PREVIEW_FRACTION: f64 = 0.9;
+/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
+/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty.
+const MIN_PREVIEW_FRACTION: f64 = 0.1;
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
@@ -3959,7 +3960,7 @@ mod tests {
}
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
- /// identity, no look table; and no other raw format.
+ /// identity, with or without a look table; and no other raw format.
#[test]
fn dng_tone_gates_follow_the_files_own_tags() {
use lightcraft_raw::profile::{HsvTable, ToneCurve};
@@ -3981,6 +3982,15 @@ mod tests {
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
let mut looked = color;
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
- assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
+ assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone");
+ }
+
+ /// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not.
+ #[test]
+ fn dng_tone_fit_takes_lightroom_sized_previews() {
+ let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0);
+ assert!(1024.0 >= floor(7008.0));
+ assert!(256.0 < floor(7008.0));
+ assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
}
}
diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs
index eae955cce..0871e4352 100644
--- a/crates/engine/src/cmd/convert.rs
+++ b/crates/engine/src/cmd/convert.rs
@@ -313,30 +313,15 @@ pub(crate) fn reload_ops(
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
-/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
+/// dimensions and content hash, cached sources dropped. The app runs the same steps
+/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result {
- let ids = s.targets(p)?;
- let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
- .iter()
- .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
- Some(Source::File { path }) => Some((*id, path)),
- _ => None,
- })
- .collect();
- let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::>());
- let mut ops = Vec::new();
- let mut reloaded = Vec::new();
- for ((id, _), info) in paths.into_iter().zip(probed) {
- let Ok(info) = info else { continue };
- reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
- }
- for id in &reloaded {
- s.media.forget(*id);
- }
- if !ops.is_empty() {
- s.commit("Reload Changed Files", Op::Batch { ops })?;
- }
- Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()}))
+ use crate::cmd::reload as r;
+ let items = r::plan(s, p)?;
+ let probe = r::file_probe(s);
+ let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {});
+ let reloaded = r::apply(s, items, results)?;
+ Ok(r::to_json(&reloaded))
}
/// Duplicate: copy the file next to itself (`-copy`, never overwriting) and add it with the
@@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec {
"Reload from Disk",
["Photo"],
None,
- "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
+ "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}",
has_selection,
reload
),
diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs
index 676519d29..030cb6e93 100644
--- a/crates/engine/src/cmd/cull.rs
+++ b/crates/engine/src/cmd/cull.rs
@@ -16,7 +16,7 @@ const SAME: f32 = 0.93;
fn secs(iso: &str) -> Option {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
- Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
+ Some(d.unix_seconds())
}
fn analyze(s: &mut Session, p: &Value) -> Result {
diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs
index e3d59c567..ce7bf4a65 100644
--- a/crates/engine/src/cmd/develop.rs
+++ b/crates/engine/src/cmd/develop.rs
@@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
Ok((a as u32, b as u32))
}
-fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
+/// How a new aspect ratio is turned (issue #2253).
+#[derive(Clone, Copy, PartialEq)]
+enum Turn {
+ /// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop
+ /// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image.
+ LikeCrop,
+ /// As given: a custom `[w, h]` taller than wide is a portrait crop.
+ AsGiven,
+}
+
+fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) {
+ let r = d.crop.geometry.rect_px(w, h);
+ let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h };
+ let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) });
d.crop.aspect = aspect;
- let a = aspect.map(|(aw, ah)| {
- // orientation of the crop follows the image (landscape image → landscape crop)
- let (aw, ah) = (aw as f64, ah as f64);
- if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw }
- });
+ let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64);
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
@@ -628,11 +637,19 @@ pub fn specs() -> Vec {
"Crop Aspect",
[],
None,
- "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
+ "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either",
has_active,
|s, p| {
let id = active(s, "crop.aspect")?;
let (w, h) = crop_dims(s, id);
+ // checked first, whatever the aspect: a bad value is an error on every path
+ let turn = match p.get("orientation") {
+ Some(Value::String(o)) if o == "crop" => Turn::LikeCrop,
+ Some(Value::String(o)) if o == "given" => Turn::AsGiven,
+ Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")),
+ None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven,
+ None => Turn::LikeCrop,
+ };
let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
let lock_current = match p.get("aspect").and_then(Value::as_str) {
Some("current") => true,
@@ -667,7 +684,7 @@ pub fn specs() -> Vec {
if aspect.is_none() {
d.crop.aspect = None;
} else {
- set_aspect(d, w, h, aspect);
+ set_aspect(d, w, h, aspect, turn);
}
Ok(())
})
@@ -1103,11 +1120,8 @@ pub fn specs() -> Vec {
|s, p| {
let c = "history.restore";
let id = active(s, c)?;
- let i = f64_req(p, "index", c)?;
- if !(i.is_finite() && i >= 0.0) {
- return Err(bad(c, "no such step"));
- }
- go_to_history_step(s, c, id, i as usize)
+ let i = index_req(p, "index", c)?;
+ go_to_history_step(s, c, id, i)
}
),
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs
index e93d04175..949c08f1b 100644
--- a/crates/engine/src/cmd/export.rs
+++ b/crates/engine/src/cmd/export.rs
@@ -17,6 +17,12 @@ impl Session {
/// `app.export` params with a named `preset` expanded: the preset's params, overridden by the
/// call's own (`preset` itself removed). Unknown preset names are an error.
pub fn export_params(&self, p: &Value) -> std::result::Result {
+ self.export_params_or(p, crate::export::ExportFormat::default())
+ }
+
+ /// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format
+ /// (see [`crate::export::ExportOptions::export_format`]).
+ pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result {
let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) };
let (preset, _) = self
.all_export_presets()
@@ -24,12 +30,17 @@ impl Session {
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
- let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
+ let mut out = crate::export::ExportOptions::known_keys(&preset.params);
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
o.insert(k.clone(), v.clone());
}
}
+ // the preset's depth is judged against the format written, known only now (`path`); a
+ // depth the call passes itself is the caller's, and validated as such
+ if !p.get("bitDepth").is_some_and(|d| !d.is_null()) {
+ crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback);
+ }
Ok(out)
}
}
@@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result {
let mut params = match p.get("params") {
Some(v @ Value::Object(_)) => v.clone(),
Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")),
- None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?,
+ // a stored setting, not the caller's typo: a depth it holds that its `format` lacks was
+ // written before #1952 and is dropped, as Export with Previous does; without `format`
+ // it is judged when the preset exports
+ None => {
+ let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?;
+ let mut last = crate::export::ExportOptions::known_keys(last);
+ if last.get("format").is_some_and(Value::is_string) {
+ crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default());
+ }
+ last
+ }
};
if let Some(o) = params.as_object_mut() {
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
}
- // a preset that app.export would refuse is refused here, where the typo is on screen
- crate::export::ExportOptions::validate(ID, ¶ms)?;
+ // a preset that app.export would refuse is refused here, where the typo is on screen; without
+ // `format`, its `bitDepth` is judged when it exports, against the format written then
+ let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default()));
+ crate::export::ExportOptions::validate_as(ID, ¶ms, format)?;
let preset = ExportPreset { name: name.to_string(), params };
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
Some(x) => *x = preset,
diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs
index 085183d58..c9ffb6b7e 100644
--- a/crates/engine/src/cmd/geocode.rs
+++ b/crates/engine/src/cmd/geocode.rs
@@ -115,6 +115,12 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
fn reverse(s: &mut Session, p: &Value) -> Result {
const C: &str = "metadata.reverseGeocode";
let overwrite = bool_or(p, "overwrite", false);
+ // the place list holds towns of 15 000 people or more: a photo in a smaller one finds the nearest
+ // larger town, which may be another municipality; past this many metres City is left empty (#2242)
+ let max_distance = match p.get("maxDistance") {
+ None | Some(Value::Null) => None,
+ Some(v) => Some(v.as_f64().filter(|m| m.is_finite() && *m > 0.0).ok_or_else(|| bad(C, "`maxDistance` must be a positive number of metres"))?),
+ };
let ids = s.targets(p)?;
let todo: Vec<(PhotoId, LatLon)> = ids
.iter()
@@ -140,14 +146,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result {
}
t.progress(n as u64, todo.len() as u64);
}
- let Some(place) = names.reverse(*at) else { continue };
+ let Some(mut place) = names.reverse(*at) else { continue };
+ // too far from the town found: State and Country still hold, City (and below) doesn't
+ let too_far = max_distance.is_some_and(|m| place.distance > m);
+ let nearest_city = too_far.then(|| std::mem::take(&mut place.city));
+ if too_far {
+ place.sublocation.clear();
+ }
let Some(ph) = s.catalog.photo(*id) else { continue };
let mut meta = ph.meta.clone();
fill(&mut meta, &place, overwrite);
if meta != ph.meta {
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
}
- found.push(json!({"id": id.0, "place": place}));
+ let mut entry = json!({"id": id.0, "place": place});
+ if let Some(city) = nearest_city {
+ entry["nearestCity"] = json!(city);
+ }
+ found.push(entry);
}
drop(task);
let changed = ops.len();
@@ -271,7 +287,7 @@ pub fn specs() -> Vec {
"Fill Location from GPS",
["Photo"],
None,
- "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
+ "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder, maxDistance?: metres (past this distance from the town found, City is left empty and the town is given as nearestCity)} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}, nearestCity?}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step. The place names list towns of 15 000 people or more (GeoNames cities15000): a photo taken in a smaller town gets the nearest larger one, up to 50 km away (`distance` says how far); `maxDistance` keeps such a town out of City",
has_selection,
reverse
),
@@ -343,6 +359,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ /// Issue #2242: the place names hold only towns of 15 000 people or more, so a photo in a
+ /// village finds the nearest larger town. `maxDistance` leaves City empty past that distance
+ /// (State and Country still fill) and says which town it was.
+ #[test]
+ fn max_distance_leaves_a_far_town_out_of_city() {
+ let mut s = Session::with_demo();
+ let ids = s.visible_cloned();
+ let dir = dataset("maxdist");
+ // about 5 km north of Paris (like Portofino and Rapallo in the issue), and right in Paris
+ set_gps(&mut s, ids[0], Some((48.90, 2.35)), "");
+ set_gps(&mut s, ids[1], Some((48.855, 2.35)), "");
+ let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0], "dataset": dir, "maxDistance": 2000})).unwrap();
+ let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
+ assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("", "Île-de-France", "France"));
+ assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Paris", "close enough");
+ let far = r["photos"].as_array().unwrap().iter().find(|p| p["id"] == json!(ids[0].0)).unwrap();
+ assert_eq!((far["place"]["city"].as_str(), far["nearestCity"].as_str()), (Some(""), Some("Paris")), "{far}");
+ assert!(far["place"]["distance"].as_f64().unwrap() > 2000.0);
+ // without it, the nearest town fills City as before
+ s.undo_step().unwrap();
+ s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
+ assert_eq!(s.catalog.photo(ids[0]).unwrap().meta.city, "Paris");
+ // hostile values are refused, not taken as "no limit"
+ for bad in [json!(-1), json!(0), json!("far"), json!(true)] {
+ let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir, "maxDistance": bad})).unwrap_err();
+ assert!(e.to_string().contains("maxDistance"), "{e}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn explains_missing_data_and_gps() {
let mut s = Session::with_demo();
diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs
index 7b0e1e562..5567e0abf 100644
--- a/crates/engine/src/cmd/library.rs
+++ b/crates/engine/src/cmd/library.rs
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec {
always, |_, p| {
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
- Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings}))
+ Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped}))
}),
cmd!(
"library.importLightroom",
diff --git a/crates/engine/src/cmd/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs
index 1a1b8b23c..8af63cd9b 100644
--- a/crates/engine/src/cmd/library/tests_relative_ratings.rs
+++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
std::fs::create_dir_all(&dir).unwrap();
// Metadata-only fixtures: no import, image decoding or model/service dependency.
for id in 1..=4 {
+ std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
s.catalog
.apply(Op::SetFile {
id: PhotoId(id),
diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs
index 66ddfe9fb..339b20b90 100644
--- a/crates/engine/src/cmd/manage.rs
+++ b/crates/engine/src/cmd/manage.rs
@@ -9,8 +9,13 @@ use crate::Result;
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, String, usize)> {
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
- let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
- Ok((s.targets(p)?, template, start))
+ let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
+ let ids = s.targets(p)?;
+ // every number of the sequence `start..start + n` must fit
+ let last = start.checked_add((ids.len() as u64).saturating_sub(1));
+ let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
+ let start = start.ok_or_else(|| bad(c, "start out of range"))?;
+ Ok((ids, template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs
index 387909a6d..d791b4260 100644
--- a/crates/engine/src/cmd/mask_effects.rs
+++ b/crates/engine/src/cmd/mask_effects.rs
@@ -287,12 +287,7 @@ fn effect_index(s: &Session, name: &str, c: &str) -> Result {
/// The selected mask's adjustments on the active photo.
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
- let mid = p
- .get("id")
- .and_then(Value::as_u64)
- .and_then(|v| u32::try_from(v).ok())
- .or(s.active_mask)
- .ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
+ let mid = super::masks::mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
}
@@ -341,10 +336,12 @@ fn apply_effect(s: &mut Session, p: &Value) -> Result {
// the masks: `ids`, `all`, else `id` or the selected one
let ids: Vec = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
d.masks.iter().map(|m| m.id).collect()
- } else if let Some(a) = p.get("ids").and_then(Value::as_array) {
- a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
} else {
- vec![selected_mask(s, p, C)?.id]
+ match p.get("ids") {
+ None | Some(Value::Null) => vec![selected_mask(s, p, C)?.id],
+ Some(Value::Array(a)) => a.iter().map(|v| super::masks::parse_mask_id(v, C)).collect::>()?,
+ Some(_) => return Err(bad(C, "`ids` must be a list of mask IDs")),
+ }
};
if ids.is_empty() {
return Err(bad(C, "no masks to apply the effect to"));
diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs
index 0efb3b658..dc403df9b 100644
--- a/crates/engine/src/cmd/masks.rs
+++ b/crates/engine/src/cmd/masks.rs
@@ -199,8 +199,28 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec , c: &str) -> Result {
- p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
+pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result {
+ v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32"))
+}
+
+/// Omitted or null targets keep the command's default; malformed explicit targets never do.
+fn optional_mask_id(p: &Value, c: &str) -> Result> {
+ p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose()
+}
+
+pub(super) fn mask_id(p: &Value, active: Option, c: &str) -> Result {
+ optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
+}
+
+fn optional_index(p: &Value, key: &str, c: &str) -> Result> {
+ p.get(key)
+ .filter(|v| !v.is_null())
+ .map(|v| {
+ v.as_u64()
+ .and_then(|v| usize::try_from(v).ok())
+ .ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize")))
+ })
+ .transpose()
}
fn find(masks: &mut [Mask], id: u32, c: &str) -> Result {
@@ -307,7 +327,7 @@ pub fn specs() -> Vec {
let connect = bool_or(p, "connect", false);
let mut connected = false;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
- let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
+ let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask);
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
@@ -363,7 +383,7 @@ pub fn specs() -> Vec {
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
let shape: MaskShape =
serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?;
- let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize;
+ let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0);
let mid = mask_id(p, s.active_mask, "mask.update")?;
masks_edit(s, "mask.update", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.update")?;
@@ -381,6 +401,8 @@ pub fn specs() -> Vec {
has_active,
|s, p| {
let c = "mask.sampleColor";
+ let mid = mask_id(p, s.active_mask, c)?;
+ let comp = optional_index(p, "component", c)?;
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
@@ -390,8 +412,6 @@ pub fn specs() -> Vec {
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
- let mid = mask_id(p, s.active_mask, c)?;
- let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
let mut out = Vec::new();
masks_edit(s, c, "Color Range", |masks, _| {
let i = find(masks, mid, c)?;
@@ -482,8 +502,8 @@ pub fn specs() -> Vec {
let mid = mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
- let k = match p.get("component").and_then(Value::as_u64) {
- Some(k) => k as usize,
+ let k = match optional_index(p, "component", c)? {
+ Some(k) => k,
None => m
.components
.iter()
@@ -604,7 +624,7 @@ pub fn specs() -> Vec {
}
),
cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| {
- s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32);
+ s.active_mask = optional_mask_id(p, "mask.select")?;
ok()
}),
cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| {
@@ -684,7 +704,7 @@ pub fn specs() -> Vec {
|s, p| {
let c = "mask.component";
let mid = mask_id(p, s.active_mask, c)?;
- let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
+ let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?;
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
let op: Option = match p.get("op") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
@@ -879,8 +899,8 @@ pub fn specs() -> Vec {
),
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
let n = spots_len(s);
- s.active_spot = match p.get("index").and_then(Value::as_u64) {
- Some(i) if (i as usize) < n => Some(i as usize),
+ s.active_spot = match optional_index(p, "index", "spot.select")? {
+ Some(i) if i < n => Some(i),
Some(_) => return Err(bad("spot.select", "no such spot")),
None => None,
};
@@ -1040,7 +1060,7 @@ fn spots_len(s: &Session) -> usize {
/// `index` from the params, else the selected spot.
fn spot_index(s: &Session, p: &Value, c: &str) -> Result {
- let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
+ let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
if i >= spots_len(s) {
return Err(bad(c, "no such spot"));
}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 4b86d4012..22af48f44 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -38,6 +38,7 @@ pub mod previews;
mod print;
mod query;
pub mod recheck;
+pub mod reload;
mod slideshow;
mod sync;
mod xmp;
@@ -198,12 +199,18 @@ pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError {
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
-/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize`
-/// saturates to 0), just as `history.restore` already refuses a negative step index.
+/// A non-negative whole number that fits `usize`. Keep integer JSON exact; accept integral
+/// floating-point values for compatibility, without truncating or saturating their conversion.
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result {
+ let invalid = || bad(cmd, format!("`{key}` must be a non-negative integer"));
+ if let Some(i) = p.get(key).and_then(Value::as_u64) {
+ return usize::try_from(i).map_err(|_| invalid());
+ }
let i = f64_req(p, key, cmd)?;
- if !(i.is_finite() && i >= 0.0) {
- return Err(bad(cmd, format!("`{key}` must be a non-negative integer")));
+ // The exclusive bound is exactly representable, unlike usize::MAX on 64-bit targets.
+ let upper_bound = 2.0_f64.powi(usize::BITS as i32);
+ if !(i.is_finite() && i >= 0.0 && i.fract() == 0.0 && i < upper_bound) {
+ return Err(invalid());
}
Ok(i as usize)
}
@@ -223,3 +230,29 @@ pub(crate) fn point(p: &Value, key: &str) -> Option {
let a = p.get(key)?.as_array()?;
Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::index_req;
+
+ #[test]
+ fn command_indices_preserve_integer_precision_and_check_usize_bounds() {
+ for i in [0, 1, usize::MAX] {
+ assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i);
+ }
+ #[cfg(target_pointer_width = "64")]
+ {
+ let i = 9_007_199_254_740_993usize;
+ assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i, "integer JSON must not round through f64");
+ }
+ let upper_bound = 2.0_f64.powi(usize::BITS as i32);
+ assert!(index_req(&json!({"index": upper_bound}), "index", "test").is_err(), "2^usize::BITS must not saturate");
+ #[cfg(target_pointer_width = "32")]
+ {
+ assert!(index_req(&json!({"index": u64::from(u32::MAX) + 1}), "index", "test").is_err());
+ assert_eq!(index_req(&json!({"index": f64::from(u32::MAX)}), "index", "test").unwrap(), usize::MAX);
+ }
+ }
+}
diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs
index 05a851893..26f5d9bc4 100644
--- a/crates/engine/src/cmd/print.rs
+++ b/crates/engine/src/cmd/print.rs
@@ -324,7 +324,7 @@ pub fn specs() -> Vec {
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
always, get),
cmd!(query "print.set", "Set Print Settings", [], None,
- "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
+ "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
always, set),
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
cmd!(query "print.cell.add", "Add Print Cell", [], None,
diff --git a/crates/engine/src/cmd/reload.rs b/crates/engine/src/cmd/reload.rs
new file mode 100644
index 000000000..0c1cb964c
--- /dev/null
+++ b/crates/engine/src/cmd/reload.rs
@@ -0,0 +1,114 @@
+//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a
+//! raw re-decode takes a second or more per file, and a selection of them froze the window while
+//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads
+//! their files without the session (cancellable, with progress), and [`apply`] commits what came
+//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once.
+//!
+//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so
+//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later
+//! has already been applied to it: an older read never replaces a newer one.
+
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+
+use lightcraft_catalog::{Op, PhotoId, Source};
+use serde_json::{Value, json};
+
+use crate::media::{FileProbe, ProbeInfo};
+use crate::{Result, Session};
+
+/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile
+/// is skipped by [`apply`]).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Item {
+ pub id: PhotoId,
+ pub path: String,
+ /// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]).
+ pub ticket: u64,
+}
+
+/// Which reload each photo last took its file information from, so a reload started earlier but
+/// finishing later doesn't put back what its file was before (session memory, not saved).
+#[derive(Debug, Default)]
+pub struct Tickets {
+ last: u64,
+ applied: std::collections::BTreeMap,
+}
+
+/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No
+/// file-system calls.
+pub fn plan(s: &mut Session, p: &Value) -> Result> {
+ let ids = s.targets(p)?;
+ s.reload_tickets.last = s.reload_tickets.last.saturating_add(1);
+ let ticket = s.reload_tickets.last;
+ Ok(ids
+ .into_iter()
+ .filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) {
+ Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }),
+ _ => None,
+ })
+ .collect())
+}
+
+/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file
+/// access, where a reload reads nothing but the file name, as the command does).
+pub fn file_probe(s: &Session) -> Option {
+ s.media.file_probe.clone()
+}
+
+/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session.
+/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further
+/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]).
+pub fn probe(
+ probe: Option<&FileProbe>,
+ items: &[Item],
+ cancel: &AtomicBool,
+ progress: &dyn Fn(usize),
+) -> Vec> {
+ let paths: Vec = items.iter().map(|i| i.path.clone()).collect();
+ let done = AtomicUsize::new(0);
+ #[cfg(not(target_arch = "wasm32"))]
+ let r = std::thread::scope(|sc| {
+ let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel));
+ while !h.is_finished() {
+ progress(done.load(Ordering::Relaxed));
+ std::thread::sleep(std::time::Duration::from_millis(50));
+ }
+ // a probe that panicked (already logged): nothing is known about any file
+ h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect())
+ });
+ #[cfg(target_arch = "wasm32")]
+ let r = crate::import::probe_all(probe, &paths, &done, cancel);
+ progress(done.load(Ordering::Relaxed));
+ r
+}
+
+/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual
+/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed
+/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns
+/// the photos that changed.
+pub fn apply(s: &mut Session, items: Vec- , results: Vec
>) -> Result> {
+ let mut ops = Vec::new();
+ let mut reloaded = Vec::new();
+ for (item, info) in items.into_iter().zip(results) {
+ let Ok(info) = info else { continue };
+ let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path));
+ let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket);
+ if !current || superseded {
+ continue;
+ }
+ s.reload_tickets.applied.insert(item.id, item.ticket);
+ crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded);
+ }
+ for id in &reloaded {
+ s.media.forget(*id);
+ }
+ if !ops.is_empty() {
+ s.commit("Reload Changed Files", Op::Batch { ops })?;
+ }
+ Ok(reloaded)
+}
+
+/// `{reloaded: [ids]}`.
+pub fn to_json(reloaded: &[PhotoId]) -> Value {
+ json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()})
+}
diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs
index f5384e23d..848251c65 100644
--- a/crates/engine/src/crs_masks.rs
+++ b/crates/engine/src/crs_masks.rs
@@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value {
Value::Object(a)
}
+/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set:
+/// reported, never dropped silently.
+fn unmapped_local(c: &XmpValue) -> Vec {
+ let XmpValue::Struct(m) = c else { return Vec::new() };
+ m.keys()
+ .filter_map(|k| {
+ let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?;
+ // 100 = "curve saturation unchanged", written with every correction
+ let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 };
+ let set = num(c, k).is_some_and(|v| v != neutral);
+ (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string())
+ })
+ .collect()
+}
+
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
@@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result {
let q: Vec = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
+ // full strength at white (1, the top of their scale) takes in everything brighter: ours
+ // goes on above scene white, so that end stays open instead of cutting off at 0 EV
+ let open_top = c >= 1.0;
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
- json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
+ let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) };
+ json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather})
}
Some("3") => {
let q: Vec = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
@@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec, Vec) {
if parts.is_empty() {
continue;
}
+ skipped.extend(unmapped_local(c));
// brush dabs of one correction are one brush component
let mut merged: Vec = Vec::new();
for p in parts {
@@ -506,6 +526,28 @@ mod tests {
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
+ #[test]
+ fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
+ // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
+ let x = r#"
+
+
+
+
+
+
+
+
+ "#;
+ let d = lightcraft_meta::parse_xmp(x).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ let s = DevelopSettings::default().merged(&partial).expect("valid settings");
+ let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
+ assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
+ assert_eq!(s.masks[0].adjust.clarity, -30.0);
+ assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
+ }
+
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs
new file mode 100644
index 000000000..c2b0e9f69
--- /dev/null
+++ b/crates/engine/src/doc_path.rs
@@ -0,0 +1,63 @@
+//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the
+//! originals a Lightroom catalog lists.
+//!
+//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the
+//! document's folder, and only local files, or files on the network share the document itself is
+//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing
+//! file.
+
+use std::fmt;
+use std::path::{Path, PathBuf};
+
+pub use craft_docpath::Refused;
+
+/// Why a stored path is not followed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NotFollowed {
+ /// The path is on another computer (other than the document's share), names a device, is a
+ /// URL, or is not a usable path.
+ Refused(Refused),
+ /// The path is not an absolute path on this computer, such as a Windows drive path read on
+ /// macOS or Linux.
+ NotHere,
+}
+
+impl fmt::Display for NotFollowed {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Refused(r) => r.fmt(f),
+ Self::NotHere => f.write_str("the path is not a path on this computer"),
+ }
+ }
+}
+
+impl std::error::Error for NotFollowed {}
+
+/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`,
+/// or why it is not followed. A relative `document` is taken from the working directory, where it
+/// was opened from.
+pub fn resolve(stored: &str, document: Option<&Path>) -> Result {
+ let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
+ let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
+ // A path that is still relative would be taken from the working directory, which the
+ // document does not choose.
+ if path.is_relative() {
+ return Err(NotFollowed::NotHere);
+ }
+ Ok(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() {
+ let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat");
+ assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg")));
+ assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere));
+ assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
+ assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
+ assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
+ }
+}
diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs
index d294ed4d9..a9e3dd21b 100644
--- a/crates/engine/src/export.rs
+++ b/crates/engine/src/export.rs
@@ -816,8 +816,17 @@ impl ExportOptions {
/// newer version may have written and this one doesn't know dropped, also inside `watermark`.
/// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are
/// not the caller's typo, and refusing them would make a preset or Export with Previous fail
- /// for good after an update.
+ /// for good after an update. A `bitDepth` the stored format doesn't have (written at the
+ /// format's own depth before issue #1952) is dropped too.
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
+ let mut p = Self::known_keys(p);
+ Self::drop_unfit_depth(&mut p, ExportFormat::default());
+ p
+ }
+
+ /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be
+ /// known once the call's own params (`path`) are merged in.
+ pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value {
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str()));
@@ -828,13 +837,44 @@ impl ExportOptions {
p
}
+ /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with
+ /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952.
+ pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) {
+ let format = Self::export_format(p, fallback);
+ if let Some(o) = p.as_object_mut()
+ && let Some(depth) = o.get("bitDepth").and_then(whole)
+ && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth)
+ {
+ o.remove("bitDepth");
+ }
+ }
+
/// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an
/// unknown parameter or a value that cannot be read as what it is for is an error, never a
/// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`)
/// are accepted and left to the caller.
pub fn from_params(p: &serde_json::Value) -> crate::Result {
- Self::validate("app.export", p)?;
- Ok(Self::from_json(p))
+ Self::from_params_or(p, ExportFormat::default())
+ }
+
+ /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no
+ /// format; the format returned is [`ExportOptions::export_format`], the one written.
+ pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result {
+ let format = Self::export_format(p, fallback);
+ Self::validate_as("app.export", p, Some(format))?;
+ Ok(Self { format, ..Self::from_json(p) })
+ }
+
+ /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of
+ /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there,
+ /// the headless backend PNG), else the default.
+ pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat {
+ use serde_json::Value;
+ match (p.get("format"), p.get("path").and_then(Value::as_str)) {
+ (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(),
+ (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback),
+ (None, None) => ExportFormat::default(),
+ }
}
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
@@ -842,6 +882,12 @@ impl ExportOptions {
/// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`]
/// with its sizes in range (issue #183). `null` means "not given", as a dropped key does.
pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> {
+ Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default())))
+ }
+
+ /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported
+ /// (`None`: not known yet, as for a preset without `format`).
+ pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> {
use serde_json::Value;
let bad = |msg: String| crate::cmd::bad(cmd, msg);
let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) };
@@ -949,6 +995,18 @@ impl ExportOptions {
_ => return Err(bad(unknown_key("parameter", k, &known))),
}
}
+ // issue #1952: a depth the format doesn't have would be written at the format's own depth
+ if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) {
+ let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect();
+ if !depths.iter().any(|d| i64::from(*d) == depth) {
+ depths.sort_unstable();
+ let mut list: Vec = depths.iter().map(u8::to_string).collect();
+ let last = list.pop().unwrap_or_default();
+ let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) };
+ let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
+ return Err(bad(format!("`bitDepth` must be {list} for {name}")));
+ }
+ }
Ok(())
}
@@ -1310,7 +1368,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
- let e = EncodeImage::rgba8(&img);
+ // Opaque photo exports need no alpha channel (#1670). Keep real transparency for
+ // callers of this encoder, and leave unrelated formats on their existing path.
+ let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp)
+ && img.data.iter().all(|p| p.last() == Some(&255)))
+ .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect());
+ let e = match &rgb {
+ Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)),
+ None => EncodeImage::rgba8(&img),
+ };
let r = match o.format {
ExportFormat::Jpeg => {
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
@@ -2013,6 +2079,9 @@ fn run_batch_with(
ahead: lanes,
};
std::thread::scope(|sc| {
+ // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer
+ // would otherwise leave them waiting and the scope's join would never return
+ let _stop = StopOnDrop(&queue);
for k in 1..lanes {
let q = &queue;
// a lane that can't start leaves its photos to the others (and to this thread)
@@ -2030,13 +2099,20 @@ fn run_batch_with(
}
Ok::<_, String>(())
};
- let r = placed();
- queue.stop();
- r
+ placed()
})?;
Ok(std::mem::take(&mut placer.out))
}
+/// Stops a [`Queue`] when dropped, also while unwinding from a panic.
+struct StopOnDrop<'a>(&'a Queue);
+
+impl Drop for StopOnDrop<'_> {
+ fn drop(&mut self) {
+ self.0.stop();
+ }
+}
+
/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic
/// becomes that photo's error, not the batch's end.
fn run_gated(item: PreparedExport) -> Result {
@@ -2281,6 +2357,43 @@ mod tests {
img
}
+ /// Issue #1670: removing a redundant alpha channel preserves samples and metadata.
+ #[test]
+ fn opaque_eight_bit_exports_are_rgb() {
+ let img = test_image();
+ let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() };
+ for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
+ for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
+ let options = ExportOptions { format, tiff_compression: compression, ..Default::default() };
+ let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap();
+ let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
+ assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB");
+ assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8));
+ assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels");
+ assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice()));
+ assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test"));
+ if format == ExportFormat::Png {
+ assert_eq!(bytes[25], 2, "PNG IHDR declares RGB");
+ }
+ if format != ExportFormat::Tiff {
+ break;
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn eight_bit_exports_preserve_real_alpha() {
+ let mut img = test_image();
+ img.data[0][3] = 128;
+ for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
+ let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap();
+ let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
+ assert!(decoded.has_alpha, "{format:?} must retain real alpha");
+ assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples");
+ }
+ }
+
#[test]
fn formats_have_magic() {
let img = test_image();
@@ -3167,6 +3280,7 @@ mod tests {
resize: Some(Resize::default()),
limit_kb: Some(500),
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
+ format: ExportFormat::Png,
bit_depth: Some(16),
..Default::default()
};
@@ -3203,6 +3317,51 @@ mod tests {
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
}
+ /// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers
+ /// it), not written at the format's own depth; the error names the depths the format takes.
+ #[test]
+ fn bit_depth_must_suit_the_format() {
+ use serde_json::json;
+ let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
+ assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg"));
+ assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png"));
+ assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff"));
+ // no `format`: the one `path` names, else the default (JPEG)
+ assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png"));
+ assert!(err(json!({"bitDepth": 16})).contains("for jpeg"));
+ assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok());
+ // a path naming no format, or a `null` format, exports the default too
+ assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg"));
+ assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg"));
+ assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg"));
+ // or the caller's own format for such a path, which is the one returned
+ let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png);
+ assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png"));
+ assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png);
+ assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff);
+ // stored settings (a preset, Export with Previous) lose such a depth instead of failing
+ let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16}));
+ assert_eq!(stored.get("bitDepth"), None);
+ assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32);
+ for f in [
+ ExportFormat::Jpeg,
+ ExportFormat::Png,
+ ExportFormat::Tiff,
+ ExportFormat::Psd,
+ ExportFormat::Webp,
+ ExportFormat::Avif,
+ ExportFormat::Jxl,
+ ExportFormat::Original,
+ ExportFormat::Dng,
+ ExportFormat::RenderedDng,
+ ] {
+ for &(b, _) in ExportOptions::bit_depths(f) {
+ let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() };
+ assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}");
+ }
+ }
+ }
+
/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own
/// folder; it is checked like the other params and kept in presets and Export with Previous.
#[test]
@@ -3429,6 +3588,32 @@ mod tests {
assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written");
}
+ // A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it
+ #[test]
+ fn a_panic_in_progress_does_not_hang_the_lanes() {
+ let mut s = crate::Session::with_demo();
+ let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
+ let o = ExportOptions::from_json(&json!({"format": "png", "width": 40}));
+ let items = prepare_batch(&mut s, &ids, &o).unwrap();
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let to = Destination { dir: "out".into(), exact: None };
+ let mut write = |_: &str, _: &[u8]| Ok(());
+ let mut calls = 0;
+ let mut progress = |_: usize, _: &str| {
+ calls += 1;
+ assert!(calls < 2, "progress panics on its second call");
+ true
+ };
+ let r = crate::guard::catch("test batch", || {
+ run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2)
+ });
+ tx.send(r.and_then(|r| r)).ok();
+ });
+ let r = rx.recv_timeout(std::time::Duration::from_secs(60));
+ assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}");
+ }
+
// A photo that fails is reported in its place while the others are exported; with
// stop_on_error the batch ends there and nothing after it is written
#[test]
diff --git a/crates/engine/src/face_download.rs b/crates/engine/src/face_download.rs
index df79d5630..f5d0dd5fc 100644
--- a/crates/engine/src/face_download.rs
+++ b/crates/engine/src/face_download.rs
@@ -181,9 +181,16 @@ impl Drop for Downloads {
for job in self.jobs.values() {
job.cancel.store(true, Ordering::Relaxed);
}
- let started = std::time::Instant::now();
- while self.running() && started.elapsed() < Duration::from_secs(1) {
- std::thread::sleep(Duration::from_millis(10));
+ // Give a running download a moment to notice. Not on the web: there are no download
+ // threads, and `Instant::now` / `thread::sleep` panic on wasm32 ("time not implemented"),
+ // which killed the page when a `Session` was replaced after its library failed to open
+ // (issue #1507).
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let started = std::time::Instant::now();
+ while self.running() && started.elapsed() < Duration::from_secs(1) {
+ std::thread::sleep(Duration::from_millis(10));
+ }
}
}
}
@@ -317,6 +324,18 @@ mod tests {
}
}
+ /// Dropping a `Downloads` with nothing running (every session has one) returns at once: the
+ /// wait for running threads is the only part that takes time, and the web has no threads.
+ #[test]
+ fn dropping_idle_downloads_returns_at_once() {
+ let t = std::time::Instant::now();
+ drop(Downloads::default());
+ let mut d = Downloads::default();
+ d.set_outcome("m", State::Failed("x".into()));
+ drop(d);
+ assert!(t.elapsed() < Duration::from_millis(500), "{:?}", t.elapsed());
+ }
+
#[test]
fn a_running_download_shows_in_activity_and_cancels() {
use crate::activity::{Activity, Unit};
diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs
index bf10e3502..17bffbc19 100644
--- a/crates/engine/src/faces_index.rs
+++ b/crates/engine/src/faces_index.rs
@@ -167,6 +167,9 @@ impl Index {
self.entries.insert((photo, key), (emb.into(), view));
}
}
+ // a half-written last record (a crash while appending): written fresh at the next save, so later records are
+ // not appended after it and shifted
+ self.rewrite = !rest.is_empty();
}
fn header(&self) -> Vec {
@@ -201,12 +204,11 @@ impl Index {
return Ok(());
}
if self.file_matches && !self.rewrite {
- let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let mut buf = Vec::new();
for (k, e) in &self.unsaved {
buf.extend(self.record(k, e));
}
- f.write_all(&buf)?;
+ append(&path, &buf)?;
} else {
let mut buf = self.header();
for (k, e) in &self.entries {
@@ -229,6 +231,22 @@ impl Drop for Index {
}
}
+/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so
+/// the part that got in does not shift what is appended later.
+fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> {
+ let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
+ let old_len = f.metadata()?.len();
+ let written = f.write_all(buf);
+ if written.is_err() {
+ drop(f);
+ // an append-only handle may not truncate (Windows): cut through a writable one
+ if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) {
+ let _ = w.set_len(old_len);
+ }
+ }
+ written
+}
+
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
const SCAN_VERSION: u8 = 1;
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
@@ -325,8 +343,11 @@ impl Scanned {
let head = self.header();
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
self.file_matches = true;
- // a half-written last id (a crash while appending) is ignored
- self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
+ // a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save
+ // so later ids are not appended after it and shifted
+ let (ids, torn) = rest.as_chunks::<8>();
+ self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c)));
+ self.rewrite = !torn.is_empty();
}
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
@@ -341,9 +362,8 @@ impl Scanned {
return Ok(());
}
if self.file_matches && !self.rewrite {
- let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let buf: Vec = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
- f.write_all(&buf)?;
+ append(&path, &buf)?;
} else {
let mut buf = self.header();
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
@@ -487,6 +507,51 @@ mod tests {
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A torn last record (a crash or a full disk while appending) does not shift the records appended after it
+ /// (issue #2129).
+ #[test]
+ fn records_appended_after_a_torn_tail_stay_aligned() {
+ let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
+ let junk = |p: &Path| {
+ let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap();
+ f.write_all(&[0x3f; 5]).unwrap();
+ };
+ let mut a = Index::default();
+ a.reset("m@abc", 3, Some(emb.clone()));
+ a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
+ a.save().unwrap();
+ drop(a);
+ junk(&emb);
+ let mut b = Index::default();
+ b.reset("m@abc", 3, Some(emb.clone()));
+ b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]);
+ b.save().unwrap();
+ drop(b);
+ let mut c = Index::default();
+ c.reset("m@abc", 3, Some(emb));
+ assert_eq!(c.len(), 2);
+ assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5]));
+ assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125]));
+ let mut s = Scanned::default();
+ s.reset(Some(scan.clone()));
+ s.insert(42);
+ s.save().unwrap();
+ drop(s);
+ junk(&scan);
+ let mut t = Scanned::default();
+ t.reset(Some(scan.clone()));
+ t.insert(43);
+ t.save().unwrap();
+ drop(t);
+ let mut u = Scanned::default();
+ u.reset(Some(scan));
+ assert!(u.contains(42) && u.contains(43) && u.len() == 2);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs
index dd246cf84..31bb930fb 100644
--- a/crates/engine/src/files.rs
+++ b/crates/engine/src/files.rs
@@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview(
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(_));
+ // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
+ // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
+ let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
Some(space) => {
let space = match space {
diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs
index a246c4dca..db9e658d0 100644
--- a/crates/engine/src/fonts.rs
+++ b/crates/engine/src/fonts.rs
@@ -105,7 +105,7 @@ mod tests {
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
// Every script face that ships is a UI fallback, and has the glyphs of its script (#736).
use ab_glyph::Font;
- for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] {
+ for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة"), ("Geor", "ფოტო")] {
let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue };
let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses");
assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family);
diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs
index 9aa847b01..7a3bc1f05 100644
--- a/crates/engine/src/import.rs
+++ b/crates/engine/src/import.rs
@@ -494,7 +494,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
/// `cancel` is set: the files not reached come back as errors.
-fn probe_all(
+pub(crate) fn probe_all(
probe: Option<&crate::media::FileProbe>,
paths: &[String],
done: &std::sync::atomic::AtomicUsize,
@@ -965,9 +965,11 @@ impl ImportJob {
}
/// 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 };
+ /// the first files (on the thread that prepares them), from the library as it was then. The
+ /// snapshot comes back: the files the import is adding are ranked against it for the ownership
+ /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]).
+ fn look_up_library(&mut self) -> Option {
+ let (library, fresh) = self.library.take()?;
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 {
@@ -980,12 +982,14 @@ impl ImportJob {
self.by_hash.insert(h.clone(), Some(p.id));
}
}
+ Some(library)
}
/// [`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();
+ // the library as it was, kept for the ownership of the shared stem sidecars below
+ let library = self.look_up_library();
let opts = self.opts.clone();
let mode = self.mode;
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
@@ -1025,6 +1029,13 @@ impl ImportJob {
let probed: Vec> =
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
crate::memory::release();
+ // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones
+ // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar
+ // and never the raw's packet (issue #1905).
+ let stems = crate::sidecar::ImportStems::for_import(
+ library.as_ref(),
+ todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))),
+ );
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
// copies or moves — the slow part, reading and writing every byte and verifying it — are
// collected and run on several threads (issue #367), then their outcomes take their places.
@@ -1063,7 +1074,7 @@ impl ImportJob {
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
// and names the copy when the file itself has none
let raw = info.kind == MediaKind::Raw;
- let packet = crate::sidecar::find_sidecar(&path, self.naming)
+ let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path)
.and_then(|f| std::fs::read_to_string(f).ok())
.or_else(|| info.xmp.clone().filter(|_| raw));
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
@@ -1566,6 +1577,7 @@ impl Session {
display: None,
slide: None,
preview_cut: None,
+ display_fit: None,
};
let embedded = match (&self.media.preview_loader, c.kind) {
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 8a282ce3e..ec7c9f7e7 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -29,6 +29,7 @@ pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
+pub mod doc_path;
mod errors;
pub mod export;
pub mod face_download;
@@ -56,6 +57,7 @@ pub mod media;
pub mod memory;
pub mod merge;
mod model_download;
+pub mod open_path;
pub mod originals;
mod pdf;
pub mod preset_import;
@@ -184,6 +186,10 @@ pub struct Interaction {
pub label: String,
pub photo: PhotoId,
pub original: Arc,
+ /// Whether an edit during the interaction changed the crop itself. If none did, the crop the
+ /// photo shows was fitted by Constrain Crop, and the final commit starts again from the original
+ /// crop, so Auto Sync carries the edit and not the active photo's fitted crop.
+ pub crop_edited: bool,
}
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
@@ -238,6 +244,8 @@ pub struct Session {
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
pub(crate) skip_auto_write: bool,
+ /// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]).
+ pub(crate) reload_tickets: cmd::reload::Tickets,
/// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by
/// [`Session::retry_unsaved_sidecars`] once their file is reachable.
pub(crate) unsaved_sidecars: std::collections::BTreeSet,
@@ -298,6 +306,10 @@ pub struct Session {
pub xmp: sidecar::XmpPrefs,
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
pub last_export: Option,
+ /// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
+ /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
+ /// them. Persisted in prefs.json.
+ pub last_merge: std::collections::BTreeMap,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec,
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
@@ -428,6 +440,7 @@ impl Session {
pending_sidecar_repairs: Vec::new(),
interaction: None,
skip_auto_write: false,
+ reload_tickets: Default::default(),
unsaved_sidecars: Default::default(),
denoise: Default::default(),
face_models_dir: None,
@@ -459,6 +472,7 @@ impl Session {
library_identity: Arc::new(()),
xmp: sidecar::XmpPrefs::default(),
last_export: None,
+ last_merge: Default::default(),
export_presets: Vec::new(),
print: Default::default(),
print_templates: Vec::new(),
@@ -874,22 +888,58 @@ impl Session {
/// undo step; otherwise it is committed with a history entry.
pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> {
let now = (self.clock)();
- let settings = Arc::new(settings);
- if let Some(i) = &self.interaction
+ // Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990)
+ let requested = settings;
+ let settings = Arc::new(self.fit_constrained_crop(id, requested.clone()));
+ let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry);
+ if let Some(i) = &mut self.interaction
&& i.photo == id
{
+ i.crop_edited |= crop_edited;
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
}
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
- ops.extend(self.auto_sync_ops(id, &settings, label));
+ ops.extend(self.auto_sync_ops(id, &requested, label));
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
self.commit(label, op)
}
- /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
- /// other selected photos: only the settings that changed; never spot removal or red eye (they
- /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
- /// history / snapshot restores.
+ /// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue
+ /// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections,
+ /// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner
+ /// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as
+ /// drawn (the render still constrains it).
+ fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings {
+ if !d.geometry.constrain_crop {
+ return d;
+ }
+ let Some(photo) = self.catalog.photo(id) else { return d };
+ let before = &photo.develop;
+ let turned_on = !before.geometry.constrain_crop;
+ let warp_changed = before.geometry != d.geometry
+ || before.optics != d.optics
+ || before.orientation != d.orientation
+ || ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s));
+ let crop_edited = before.crop.geometry != d.crop.geometry;
+ if !(turned_on || (warp_changed && !crop_edited)) {
+ return d;
+ }
+ let frame = lightcraft_pipeline::geometry::Frame::with_lens(
+ photo.width.max(1) as usize,
+ photo.height.max(1) as usize,
+ &d,
+ true,
+ photo.embedded_lens.as_ref(),
+ );
+ d.crop.geometry.rect = frame.crop.rect;
+ d
+ }
+
+ /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked,
+ /// before its crop is fitted) over to the other selected photos: only the settings that changed;
+ /// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process
+ /// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each
+ /// photo's own crop: the active photo's fitted crop is never copied over another's.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec {
if !self.auto_sync
|| self.active() != Some(id)
@@ -915,7 +965,7 @@ impl Session {
.filter(|x| **x != id)
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
.filter_map(|(x, d)| {
- let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
+ let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0));
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
})
.collect()
@@ -937,7 +987,7 @@ impl Session {
}
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
let original = self.develop_of(id).unwrap_or_default();
- self.interaction = Some(Interaction { label: label.into(), photo: id, original });
+ self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false });
Ok(())
}
@@ -948,9 +998,15 @@ impl Session {
if *cur == *i.original {
return Ok(());
}
- // Restore the original silently, then commit the final value as one step.
+ // Restore the original silently, then commit the final value as one step. A crop nobody
+ // edited goes back to the original's: Constrain Crop fits it again for the final warp, and
+ // Auto Sync carries the edit, not the active photo's fitted crop (review of #1990).
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?;
- self.set_develop(i.photo, (*cur).clone(), &i.label)
+ let mut last = (*cur).clone();
+ if !i.crop_edited {
+ last.crop.geometry = i.original.crop.geometry;
+ }
+ self.set_develop(i.photo, last, &i.label)
}
pub fn cancel_interaction(&mut self) -> Result<()> {
@@ -1242,8 +1298,12 @@ mod tests_albums_local;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
+mod tests_constrain_crop;
+#[cfg(test)]
mod tests_crop_angle;
#[cfg(test)]
+mod tests_crop_aspect;
+#[cfg(test)]
mod tests_denoise;
#[cfg(test)]
mod tests_export;
diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs
index 51655aea5..32ad1da53 100644
--- a/crates/engine/src/library.rs
+++ b/crates/engine/src/library.rs
@@ -84,6 +84,9 @@ pub struct Library {
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
/// exist only in memory ([`Session::unsaved`]).
pub unsaved_error: Option,
+ /// Why the last write of presets.json failed; `None` once it is written. While set, the preset
+ /// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266).
+ presets_error: Option,
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
retry_at: Option,
/// What forgetting untouched Local records did when the library opened.
@@ -207,6 +210,8 @@ struct PrefsFile {
/// Parameters of the last export (for Export with Previous).
#[serde(alias = "last_export")]
last_export: Option,
+ /// Options of the last Photo Merge, per command.
+ last_merge: std::collections::BTreeMap,
/// The user's export presets.
export_presets: Vec,
/// Print templates.
@@ -492,6 +497,7 @@ impl Session {
let prefs = settings.read::(files.as_mut(), "prefs.json").unwrap_or_default();
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
+ self.last_merge = prefs.last_merge;
self.export_presets = prefs.export_presets;
self.print_templates = prefs.print_templates;
self.metadata_presets = prefs.metadata_presets;
@@ -574,6 +580,7 @@ impl Session {
report,
last_error: None,
unsaved_error: None,
+ presets_error: None,
retry_at: None,
forgot_local: None,
presets_saved,
@@ -648,10 +655,17 @@ impl Session {
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if state.hash == lib.presets_saved.hash {
lib.presets_saved = state;
+ lib.presets_error = None;
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
- // the state stays stale: the next save tries again
+ // the state stays stale: retried by the next command and the frame loop (backing off);
+ // meanwhile the presets count as unsaved, so the UI warns and a backup refuses
log::error!("library: presets: {e}");
+ lib.presets_error = Some(format!("presets: {e}"));
+ lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
} else {
+ if lib.presets_error.take().is_some() {
+ log::info!("library: presets written to disk");
+ }
lib.presets_saved = state;
}
Ok(())
@@ -661,7 +675,8 @@ impl Session {
/// changed outside [`Session::execute`]).
pub fn persist_if_dirty(&mut self) {
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
- if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
+ let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some());
+ if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off {
let _ = self.persist();
} else if let Some(lib) = self.library.as_mut() {
poll_compaction(lib);
@@ -669,11 +684,14 @@ impl Session {
}
/// Changes applied in memory but not yet written to disk because saving failed: the number of
- /// queued ops and why (`None` when everything is saved, or no library is open). Retried by
- /// every command and by [`Session::persist_if_dirty`].
+ /// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library
+ /// is open). Retried by every command and by [`Session::persist_if_dirty`].
pub fn unsaved(&self) -> Option<(usize, &str)> {
let lib = self.library.as_ref()?;
- lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
+ let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty());
+ let presets = lib.presets_error.as_deref();
+ let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some());
+ ops.or(presets).map(|why| (n, why))
}
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
@@ -746,6 +764,7 @@ impl Session {
let v = serde_json::to_vec_pretty(&PrefsFile {
xmp: self.xmp,
last_export: self.last_export.clone(),
+ last_merge: self.last_merge.clone(),
export_presets: self.export_presets.clone(),
print_templates: self.print_templates.clone(),
metadata_presets: self.metadata_presets.clone(),
diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs
index 0dceb421d..9c9979d97 100644
--- a/crates/engine/src/lightroom_archive.rs
+++ b/crates/engine/src/lightroom_archive.rs
@@ -524,6 +524,7 @@ mod tests {
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
+ skipped: Vec::new(),
}
}
@@ -538,6 +539,7 @@ mod tests {
keywords: vec!["Trips|Goa".into()],
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
+ original: crate::lightroom_catalog::Original::Local,
};
CatalogImport {
source: "source.lrcat".into(),
@@ -546,6 +548,7 @@ mod tests {
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
warnings: vec!["one warning".into()],
+ skipped: Vec::new(),
}
}
@@ -621,6 +624,7 @@ mod tests {
keywords: Vec::new(),
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
snapshots: Vec::new(),
+ original: crate::lightroom_catalog::Original::Local,
})
.collect();
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs
index 777206bcb..797a96708 100644
--- a/crates/engine/src/lightroom_catalog.rs
+++ b/crates/engine/src/lightroom_catalog.rs
@@ -2,13 +2,13 @@
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
use std::collections::{BTreeMap, HashMap, HashSet};
-use std::path::{Component, Path, PathBuf};
+use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
-use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
+use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue};
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
use lightcraft_geom::{Affine, Orientation, Point, Rect};
use serde::{Deserialize, Serialize};
@@ -146,6 +146,31 @@ pub struct CatalogPhoto {
pub keywords: Vec,
pub history: Vec,
pub snapshots: Vec,
+ /// Whether `path` may be looked at (derived when the catalog is read; not archived).
+ #[serde(skip)]
+ pub original: Original,
+}
+
+/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
+/// as the document ([`crate::doc_path::resolve`]).
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub enum Original {
+ /// `path` is a local file path, or one on the network share the catalog is on.
+ Local,
+ /// `path` is not a path on this computer (a catalog made on another system): the photo is
+ /// kept as an offline record for later relinking, and the path is not looked at.
+ #[default]
+ NotHere,
+ /// `path` is refused (why): it is on another computer or a device, or its folder or file name
+ /// is an absolute path. The photo is reported missing and not added to the library.
+ Refused(String),
+}
+
+impl CatalogPhoto {
+ /// Whether the original is a file here; only a [`Original::Local`] path is looked at.
+ pub fn original_is_file(&self) -> bool {
+ self.original == Original::Local && Path::new(&self.path).is_file()
+ }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -156,6 +181,34 @@ pub struct CatalogImport {
pub members: Vec,
pub collection_content: Vec,
pub warnings: Vec,
+ /// Catalog images left out because their records are incomplete; reported as data (not text)
+ /// so each front end can say so in its own language.
+ #[serde(default)]
+ pub skipped: Vec,
+}
+
+/// A catalog image that was not imported, and why.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SkippedImage {
+ /// The image's `Adobe_images.id_local`.
+ pub image: i64,
+ pub reason: SkipReason,
+}
+
+/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum SkipReason {
+ /// Its `AgLibraryFile` record is gone.
+ MissingFile,
+ /// Its file's `AgLibraryFolder` record is gone.
+ MissingFolder,
+ /// Its folder's `AgLibraryRootFolder` record is gone.
+ MissingRoot,
+ /// Its file name or root folder path is empty.
+ IncompletePath,
+ /// A virtual copy whose master was skipped.
+ MasterSkipped,
}
#[derive(Clone, Default, Serialize, Deserialize)]
@@ -343,25 +396,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result {
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
-/// catalog's volume, resolved against the catalog's folder, if that is a folder.
+/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
+/// looked at only when [`crate::doc_path::resolve`] follows them.
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option {
- if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
+ if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
return None;
}
- let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
- let mut out = PathBuf::new();
- for part in dir.components() {
- match part {
- Component::ParentDir => {
- if !out.pop() {
- return None;
- }
- }
- Component::CurDir => {}
- part => out.push(part),
- }
+ crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
+}
+
+/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
+/// place of the root folder.
+fn is_absolute_part(part: &str) -> bool {
+ let bytes = part.as_bytes();
+ part.starts_with(['/', '\\'])
+ || (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
+ || Path::new(part).is_absolute()
+}
+
+/// The path of an original under `root` and whether it may be looked at, resolved with the
+/// catalog at `catalog` as the document.
+fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
+ let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
+ if is_absolute_part(folder) || is_absolute_part(name) {
+ return (stored, Original::Refused("its folder or file name is an absolute path".into()));
+ }
+ match crate::doc_path::resolve(&stored, Some(catalog)) {
+ Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
+ Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
+ Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
}
- out.is_dir().then_some(out)
}
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
@@ -372,6 +436,9 @@ pub fn read(path: &Path) -> Result {
read_with_progress(path, &cancel, &total, &done)
}
+/// Read a Lightroom Classic catalog for import, counting the tables read in `total` / `done` and
+/// stopping when `cancel` is set. Images whose catalog records are incomplete are left out and
+/// listed in [`CatalogImport::skipped`] instead of failing the read.
pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result {
// 13 catalog tables plus one validation stage; ImportJob adds one unit per original path.
total.store(14, Ordering::Relaxed);
@@ -380,15 +447,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
let before = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_path = PathBuf::from(format!("{}-wal", path.display()));
let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
- let main = bytes(path)?;
+ let main = FileSource::open(path)?;
let wal = match std::fs::metadata(&wal_path) {
Ok(_) => bytes(&wal_path)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.to_string()),
};
- let wal_len = committed_wal_len(&main, &wal)?;
+ let wal_len = committed_wal_len(main.header(), &wal)?;
ensure_read_active(cancel)?;
- let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
+ let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
let after = std::fs::metadata(path).map_err(|e| e.to_string())?;
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after {
@@ -457,6 +524,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
out
};
let mut root_paths = HashMap::new();
+ let mut refused_roots = HashSet::new();
let mut root_ids: Vec<_> = roots.keys().copied().collect();
root_ids.sort_unstable();
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
@@ -469,6 +537,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
}
None => absolute.to_string(),
};
+ if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
+ warnings
+ .push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
+ refused_roots.insert(id);
+ }
root_paths.insert(id, resolved);
}
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
@@ -480,20 +553,43 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
members: optional_table("AgLibraryCollectionImage", &mut warnings)?,
collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?,
warnings,
+ skipped: Vec::new(),
};
+ // images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves
+ // these behind and ignores them); they and their virtual copies are skipped and reported
+ // instead of failing the import
+ let mut orphans = HashSet::new();
for image in table("Adobe_images", true)? {
ensure_read_active(cancel)?;
let id = number(&image, "id_local");
- let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
- let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
- let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
- let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
+ let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| {
+ let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?;
+ let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?;
+ Ok((file, folder, root))
+ });
+ let (file, folder, root) = match record {
+ Ok(found) => found,
+ Err(reason) => {
+ out.skipped.push(SkippedImage { image: id, reason });
+ orphans.insert(id);
+ continue;
+ }
+ };
+ let root_id = number(root, "id_local");
+ let root_path = root_paths.get(&root_id).map_or("", String::as_str);
let folder_path = text(folder, "pathFromRoot");
let name = text(file, "idx_filename");
if root_path.is_empty() || name.is_empty() {
- return Err(format!("image {id}: incomplete original path"));
+ out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath });
+ orphans.insert(id);
+ continue;
+ }
+ let (photo_path, original) = original_path(root_path, folder_path, name, path);
+ if let Original::Refused(why) = &original
+ && !refused_roots.contains(&root_id)
+ {
+ out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
}
- let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string();
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
Ok(s) => s,
Err(e) => {
@@ -504,19 +600,89 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
out.photos.push(CatalogPhoto {
source_id: id,
uuid: text(&image, "id_global").into(),
- path,
+ path: photo_path,
image,
settings: develops.get(&id).cloned().unwrap_or_default(),
xmp: packet,
keywords: keywords.remove(&id).unwrap_or_default(),
history: history.remove(&id).unwrap_or_default(),
snapshots: snapshots.remove(&id).unwrap_or_default(),
+ original,
});
}
+ drop_copies_of(&mut out, &orphans);
+ refuse_non_media(&mut out);
ensure_read_active(cancel)?;
+ reader.db.ensure_unchanged()?;
+ let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
+ if wal_before != wal_after {
+ return Err("catalog changed during reading; close Lightroom and retry".into());
+ }
Ok(out)
}
+/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has
+/// no original of its own.
+fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet) {
+ if masters.is_empty() {
+ return;
+ }
+ let mut skipped = Vec::new();
+ data.photos.retain(|p| {
+ let master = number(&p.image, "masterImage");
+ // 0 marks a master, never a copy
+ if master == 0 || !masters.contains(&master) {
+ return true;
+ }
+ skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped });
+ false
+ });
+ data.skipped.extend(skipped);
+}
+
+/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
+/// import's warnings.
+///
+/// The catalog decides these paths, and a record it leaves behind is one click from the system
+/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
+/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
+/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
+/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
+///
+/// Called where the catalog is read and again where its photos become records, so neither route
+/// can keep one; the second pass finds nothing left to say.
+pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
+ // masters first: a virtual copy may come before its master in the table
+ let refused: HashSet = data
+ .photos
+ .iter()
+ .filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
+ .map(|p| p.source_id)
+ .collect();
+ if refused.is_empty() {
+ return;
+ }
+ let mut warnings = Vec::new();
+ data.photos.retain(|p| {
+ let master = number(&p.image, "masterImage");
+ // `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
+ // take every master of the catalog with it
+ if master != 0 {
+ if !refused.contains(&master) {
+ return true;
+ }
+ warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
+ return false;
+ }
+ if !refused.contains(&p.source_id) {
+ return true;
+ }
+ warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
+ false
+ });
+ data.warnings.extend(warnings);
+}
+
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
}
@@ -665,11 +831,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
let paths: Vec<_> = data
.photos
.iter()
- .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file())
+ .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
.map(|p| p.path.clone())
.collect();
let missing_sources: HashSet =
- data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect();
+ data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
let applied =
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
@@ -692,8 +858,12 @@ pub(crate) struct ApplyPrepared<'a> {
pub(crate) undo0: usize,
}
-pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result {
+/// Turn the read catalog into library records in one undo step and return the import report
+/// (added, missing, failed, warnings and skipped images).
+pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result {
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
+ // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
+ refuse_non_media(&mut data);
validate(&data).map_err(error)?;
let mut warnings = data.warnings.clone();
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
@@ -713,12 +883,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
let mut photos: Vec<_> = data.photos.iter().collect();
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
+ let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
let mut planned: HashMap = HashMap::new();
for src in photos {
let master = number(&src.image, "masterImage");
if master == 0 && missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
+ // A refused path must not become a library record the app would look at later; its
+ // virtual copies go with it.
+ if refused.contains(&src.source_id) || refused.contains(&master) {
+ continue;
+ }
let indexed = index
.photos
.get(&identity(&data, src))
@@ -917,7 +1093,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
}
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
Ok(AppliedImport {
- report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
+ report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
index: index.clone(),
})
}
@@ -940,6 +1116,7 @@ mod tests {
keywords: vec!["Travel|Goa".into()],
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
snapshots: Vec::new(),
+ original: Original::Local,
};
let mut copy = photo.clone();
copy.source_id = 2;
@@ -960,8 +1137,51 @@ mod tests {
],
collection_content: Vec::new(),
warnings: Vec::new(),
+ skipped: Vec::new(),
}
}
+ /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
+ /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
+ /// copies go with it; the catalog's real photos still import.
+ #[test]
+ fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
+ let mut data = sample();
+ let mut exe = data.photos[1].clone();
+ exe.source_id = 3;
+ exe.uuid = "payload".into();
+ exe.path = "/Users/sky/Downloads/payload.exe".into();
+ exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
+ let mut exe_copy = exe.clone();
+ exe_copy.source_id = 4;
+ exe_copy.uuid = "payload-copy".into();
+ exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
+ exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
+ // the copy comes before its master, as a catalog's table order may have it
+ data.photos.insert(0, exe_copy);
+ data.photos.push(exe);
+ for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
+ let mut one = data.clone();
+ one.photos.retain(|p| p.source_id < 3);
+ let mut planted = one.photos[1].clone();
+ planted.source_id = 5;
+ planted.uuid = "planted".into();
+ planted.path = bad.into();
+ planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
+ one.photos.push(planted);
+ let mut s = crate::Session::new();
+ let r = apply(&mut s, one, false).unwrap();
+ assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
+ assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
+ }
+ let mut s = crate::Session::new();
+ let r = apply(&mut s, data, false).unwrap();
+ assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
+ assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
+ let warnings = r["warnings"].to_string();
+ assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
+ assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
+ }
+
#[test]
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
let mut s = crate::Session::new();
@@ -1377,6 +1597,16 @@ mod tests {
}
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec {
+ native_catalog_with_last_table_page(tables, None)
+ }
+ /// [`native_catalog`] whose last table claims `last_page` as its root page.
+ fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec)], last_page: Option) -> Vec {
+ let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect();
+ native_catalog_rows(&tables, last_page)
+ }
+ /// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last
+ /// table's root page.
+ fn native_catalog_rows(tables: &[(&str, &str, Vec>)], last_page: Option) -> Vec {
use SqlValue::{Integer as I, Text as T};
let mut bytes = vec![0u8; 5 * 2048];
bytes[..16].copy_from_slice(b"SQLite format 3\0");
@@ -1390,15 +1620,15 @@ mod tests {
bytes[44..48].copy_from_slice(&4u32.to_be_bytes());
bytes[56..60].copy_from_slice(&1u32.to_be_bytes());
let mut schema = Vec::new();
- for (i, (name, columns, row)) in tables.iter().enumerate() {
+ for (i, (name, columns, rows)) in tables.iter().enumerate() {
schema.push(vec![
T("table".into()),
T((*name).into()),
T((*name).into()),
- I(i as i64 + 2),
+ I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }),
T(format!("CREATE TABLE {name} ({columns})")),
]);
- leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
+ leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows);
}
leaf(&mut bytes[..2048], 100, &schema);
bytes
@@ -1432,11 +1662,104 @@ mod tests {
assert_eq!(catalog.photos[0].uuid, "fixture-image");
assert_eq!(number(&catalog.photos[0].image, "rating"), 5);
assert!(catalog.photos[0].path.ends_with("test.ARW"));
+ // Put the required image table beyond the old 1 GiB cutoff without allocating it.
+ // A main-file-sized allocation would defeat the purpose of the page-backed reader.
+ // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere.
+ #[cfg(unix)]
+ {
+ let far_offset = 1u64 << 30;
+ let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64));
+ use std::io::{Seek, SeekFrom, Write};
+ let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap();
+ sparse.write_all(&bytes).unwrap();
+ sparse.set_len(far_offset + 2048).unwrap();
+ sparse.seek(SeekFrom::Start(far_offset)).unwrap();
+ sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap();
+ drop(sparse);
+ let large = read(&path).unwrap();
+ assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap());
+ let mut session = crate::Session::new();
+ session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap();
+ assert_eq!(session.catalog.len(), 1);
+ assert_eq!(session.catalog.photos().next().unwrap().rating, 5);
+ session.execute("edit.undo", &json!({})).unwrap();
+ assert!(session.catalog.is_empty());
+ }
std::fs::write(&path, b"bad catalog").unwrap();
assert!(read(&path).is_err());
std::fs::remove_file(path).unwrap();
}
+ /// Reads `tables` as a catalog file and returns what the reader made of it.
+ fn read_tables(tables: &[(&str, &str, Vec>)], name: &str) -> CatalogImport {
+ let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id()));
+ std::fs::write(&path, native_catalog_rows(tables, None)).unwrap();
+ let catalog = read(&path).unwrap();
+ std::fs::remove_file(path).unwrap();
+ catalog
+ }
+
+ /// `tables` with each row as a one-row table, for [`native_catalog_rows`].
+ fn one_row(tables: Vec<(&'static str, &'static str, Vec)>) -> Vec<(&'static str, &'static str, Vec>)> {
+ tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect()
+ }
+
+ #[test]
+ fn images_with_incomplete_records_are_skipped_not_fatal() {
+ use SqlValue::{Integer as I, Text as T};
+ // (table, column, value): the record that breaks, and the reason the import gives
+ let cases = [
+ (3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file
+ (2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder
+ (1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root
+ (2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty
+ ];
+ for (table, column, value, reason) in cases {
+ let mut tables = one_row(photo_tables(("/catalog/", "")));
+ tables[table].2[0][column] = value;
+ let catalog = read_tables(&tables, &format!("skipped-{reason:?}"));
+ assert!(catalog.photos.is_empty(), "{reason:?}");
+ assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]);
+ assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings);
+ }
+ }
+
+ #[test]
+ fn a_virtual_copy_of_a_skipped_master_is_skipped_too() {
+ use SqlValue::{Integer as I, Null as N, Text as T};
+ let mut tables = one_row(photo_tables(("/catalog/", "")));
+ tables[3] = (
+ "Adobe_images",
+ "id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick",
+ vec![
+ // the master's file record is gone, so it is skipped …
+ vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)],
+ // … and its virtual copy goes with it, although its own file record is there
+ vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)],
+ ],
+ );
+ let catalog = read_tables(&tables, "skipped-copy");
+ assert!(catalog.photos.is_empty());
+ assert_eq!(
+ catalog.skipped,
+ [SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }]
+ );
+ validate(&catalog).unwrap();
+ }
+
+ #[test]
+ fn skipped_images_are_in_the_import_report() {
+ let mut data = sample();
+ data.photos.retain(|p| p.source_id != 1);
+ drop_copies_of(&mut data, &HashSet::from([1]));
+ data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile });
+ let mut s = crate::Session::new();
+ let report = apply(&mut s, data, false).unwrap();
+ assert_eq!(report["photos"], 0);
+ assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}]));
+ assert_eq!(report["warnings"], json!([]));
+ }
+
#[test]
fn preparation_skips_missing_original_probes_but_keeps_catalog_records() {
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id()));
@@ -1508,11 +1831,57 @@ mod tests {
// a relative path that leads nowhere keeps the recorded one
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
let catalog = read(&path).unwrap();
- assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
+ assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
std::fs::remove_dir_all(dir).unwrap();
}
+ #[test]
+ fn originals_on_other_computers_are_never_looked_at_or_added() {
+ let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("Photos")).unwrap();
+ std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
+ let source = dir.join("source.lrcat");
+ let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
+ if cfg!(unix) {
+ // `///` is a local folder to Linux, but a network path to the resolver.
+ roots.push(format!("/{}/", dir.display()));
+ }
+ for root in &roots {
+ std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
+ assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
+ let mut s = crate::Session::new();
+ let probes = Arc::new(AtomicUsize::new(0));
+ let calls = probes.clone();
+ s.media.file_probe = Some(Arc::new(move |_| {
+ calls.fetch_add(1, Ordering::Relaxed);
+ Err("a refused original must not be probed".into())
+ }));
+ let report = import(&mut s, &source, false).unwrap();
+ assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
+ assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
+ assert!(s.catalog.is_empty(), "{root}");
+ }
+ // An absolute folder path would replace the root.
+ let mut tables = photo_tables((dir.to_str().unwrap(), ""));
+ tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
+ std::fs::write(&source, native_catalog(&tables)).unwrap();
+ assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
+ // A root beside the catalog is followed.
+ std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert_eq!(catalog.photos[0].original, Original::Local);
+ assert!(catalog.photos[0].original_is_file());
+ // A root that moved is found through its path relative to the catalog.
+ std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
#[test]
fn oversized_optional_table_warns_and_is_skipped() {
let mut main = vec![0u8; 2 * 512];
diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs
index 8de1cc806..762c8588d 100644
--- a/crates/engine/src/lightroom_job.rs
+++ b/crates/engine/src/lightroom_job.rs
@@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V
if !seen.insert(key.clone()) {
continue;
}
- if Path::new(&photo.path).is_file() {
+ if photo.original_is_file() {
paths.push(photo.path.clone());
} else {
missing.insert(key);
@@ -355,6 +355,7 @@ mod tests {
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
+ skipped: Vec::new(),
}
}
@@ -393,11 +394,13 @@ mod tests {
keywords: Vec::new(),
history: Vec::new(),
snapshots: Vec::new(),
+ original: lightroom_catalog::Original::Local,
}],
collections: Vec::new(),
members: Vec::new(),
collection_content: Vec::new(),
warnings: Vec::new(),
+ skipped: Vec::new(),
},
normal: Prepared::default(),
index: ImportIndex::default(),
@@ -531,6 +534,7 @@ mod tests {
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
)])],
snapshots: Vec::new(),
+ original: lightroom_catalog::Original::Local,
}
})
.collect();
diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs
index 803707c1c..58019f2c1 100644
--- a/crates/engine/src/lightroom_sqlite.rs
+++ b/crates/engine/src/lightroom_sqlite.rs
@@ -6,7 +6,79 @@
//! than a SQL engine. It never writes its input and has no SQLite runtime
//! dependency.
+use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
+use std::fs::{File, Metadata};
+use std::io::{Read, Seek, SeekFrom};
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+
+const MAX_DATABASE_BYTES: u64 = 16 << 30;
+
+/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file.
+pub struct FileSource {
+ file: Mutex,
+ path: PathBuf,
+ before: Metadata,
+ header: Vec,
+}
+
+impl FileSource {
+ pub fn open(path: &Path) -> Result {
+ if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
+ return Err("SQLite catalog must be a regular file".into());
+ }
+ let mut file = File::open(path).map_err(|e| e.to_string())?;
+ let before = file.metadata().map_err(|e| e.to_string())?;
+ if !before.is_file() {
+ return Err("SQLite catalog must be a regular file".into());
+ }
+ if before.len() > MAX_DATABASE_BYTES {
+ return Err("catalog exceeds 16 GiB import limit".into());
+ }
+ let mut header = vec![0; 100];
+ file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?;
+ let source = Self { file: Mutex::new(file), path: path.into(), before, header };
+ source.ensure_unchanged()?;
+ Ok(source)
+ }
+
+ pub fn header(&self) -> &[u8] {
+ &self.header
+ }
+
+ pub fn ensure_unchanged(&self) -> Result<(), String> {
+ let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
+ let handle = file.metadata().map_err(|e| e.to_string())?;
+ let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?;
+ if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) {
+ return Err("catalog changed during reading; close Lightroom and retry".into());
+ }
+ Ok(())
+ }
+
+ fn page(&self, page_id: u32, size: usize) -> Result, String> {
+ let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?;
+ if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) {
+ return Err(format!("page {page_id} is truncated"));
+ }
+ let mut out = vec![0; size];
+ let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
+ file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?;
+ Ok(out)
+ }
+}
+
+fn same_metadata(a: &Metadata, b: &Metadata) -> bool {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ if a.dev() != b.dev() || a.ino() != b.ino() {
+ return false;
+ }
+ }
+ a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok()
+}
const MAX_PAGE_ID: u32 = 16_777_216;
const MAX_COLUMNS: usize = 16_384;
@@ -44,6 +116,7 @@ pub struct LiveTable {
/// Read-only SQLite database image, with a committed WAL overlay if supplied.
pub struct Database {
main: Vec,
+ file: Option,
overlay: HashMap>,
page_size: usize,
reserved: usize,
@@ -84,17 +157,34 @@ impl Database {
/// The WAL argument must include its 32-byte header and only complete
/// frames through a commit. Frames are overlaid by page number, with the
/// final commit's database-size field defining the visible page count.
+ #[cfg(test)]
pub fn open_with_wal(main: Vec, committed_wal: &[u8]) -> Result {
+ Self::open(main, None, committed_wal)
+ }
+
+ pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result {
+ Self::open(source.header().to_vec(), Some(source), committed_wal)
+ }
+
+ pub fn ensure_unchanged(&self) -> Result<(), String> {
+ if let Some(file) = &self.file {
+ file.ensure_unchanged()?;
+ }
+ Ok(())
+ }
+
+ fn open(main: Vec, file: Option, committed_wal: &[u8]) -> Result {
let main_header = main.get(..100).ok_or("truncated SQLite header")?;
if main_header.get(..16) != Some(b"SQLite format 3\0") {
return Err("invalid SQLite header signature".into());
}
let page_size = sqlite_page_size(be_u16(main_header, 16)?)?;
validate_header_fields(main_header, page_size)?;
- if main.len() < page_size || !main.len().is_multiple_of(page_size) {
+ let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len());
+ if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) {
return Err("SQLite image is not a whole number of pages".into());
}
- let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?;
+ let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?;
if main_pages == 0 || main_pages > MAX_PAGE_ID {
return Err("invalid SQLite image page count".into());
}
@@ -140,7 +230,7 @@ impl Database {
_ => return Err("unsupported SQLite text encoding".into()),
};
- let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
+ let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
let schema = db.parse_schema()?;
db.tables = schema.tables;
db.rowid_aliases = schema.aliases;
@@ -166,17 +256,20 @@ impl Database {
Ok(state.out)
}
- fn page(&self, page_id: u32) -> Result<&[u8], String> {
+ fn page(&self, page_id: u32) -> Result, String> {
if page_id == 0 || page_id > self.page_count {
return Err(format!("page {page_id} is outside database"));
}
if let Some(page) = self.overlay.get(&page_id) {
- return Ok(page);
+ return Ok(Cow::Borrowed(page));
+ }
+ if let Some(file) = &self.file {
+ return file.page(page_id, self.page_size).map(Cow::Owned);
}
let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?;
let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?;
let end = start.checked_add(self.page_size).ok_or("page offset overflow")?;
- self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated"))
+ self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated"))
}
fn visit_table(&self, page_id: u32, ncols: usize, alias: Option, depth: usize, state: &mut WalkState) -> Result<(), String> {
@@ -219,7 +312,7 @@ impl Database {
if state.payload_total > MAX_TABLE_PAYLOAD {
return Err("SQLite table payload exceeds bounded import size".into());
}
- let payload = self.payload(page, at, payload_len)?;
+ let payload = self.payload(&page, at, payload_len)?;
let mut values = decode_record(&payload, ncols, self.encoding)?;
if let Some(index) = alias {
if index >= values.len() {
@@ -325,7 +418,7 @@ impl Database {
out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?);
remaining -= take;
if remaining > 0 {
- next = be_u32_at(overflow, 0)?;
+ next = be_u32_at(&overflow, 0)?;
}
}
Ok(out)
@@ -873,6 +966,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result {
mod tests {
use super::*;
+ struct Scratch(PathBuf);
+ impl Scratch {
+ fn new(bytes: &[u8]) -> Self {
+ static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+ let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id()));
+ std::fs::write(&path, bytes).unwrap();
+ Self(path)
+ }
+ }
+ impl Drop for Scratch {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_file(&self.0);
+ }
+ }
+
+ fn file_backed(mut db: Database) -> (Scratch, Database) {
+ let scratch = Scratch::new(&db.main);
+ db.file = Some(FileSource::open(&scratch.0).unwrap());
+ db.main.truncate(100);
+ (scratch, db)
+ }
+
+ #[test]
+ fn file_source_bounds_and_mutation_detection() {
+ let scratch = Scratch::new(&[0; 512]);
+ let source = FileSource::open(&scratch.0).unwrap();
+ assert_eq!(source.page(1, 512).unwrap().len(), 512);
+ assert!(source.page(2, 512).unwrap_err().contains("truncated"));
+ File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap();
+ assert!(source.page(1, 512).is_err());
+ assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
+ drop(source);
+ // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows.
+ #[cfg(unix)]
+ {
+ File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap();
+ assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit");
+ }
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn file_source_detects_same_sized_path_replacement() {
+ let original = Scratch::new(&[0; 512]);
+ let replacement = Scratch::new(&[0; 512]);
+ let source = FileSource::open(&original.0).unwrap();
+ std::fs::rename(&replacement.0, &original.0).unwrap();
+ assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
+ }
+
+ #[test]
+ fn file_source_detects_same_length_content_changes() {
+ let scratch = Scratch::new(&[0; 512]);
+ let source = FileSource::open(&scratch.0).unwrap();
+ std::fs::write(&scratch.0, [1; 512]).unwrap();
+ // Make the timestamp change deterministic even on filesystems with coarse timestamps.
+ File::options()
+ .write(true)
+ .open(&scratch.0)
+ .unwrap()
+ .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2))
+ .unwrap();
+ assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
+ }
+
fn varint(mut value: u64) -> Vec {
let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)];
value >>= 7;
@@ -1026,6 +1185,40 @@ mod tests {
assert!(tables.iter().any(|table| table.name == "t"));
let rows = db.read_table(2, 2).unwrap();
assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1)));
+ let scratch = Scratch::new(&db.main);
+ let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap();
+ assert_eq!(file_db.live_tables().unwrap(), tables);
+ assert_eq!(file_db.read_table(2, 2).unwrap(), rows);
+ assert_eq!(file_db.main.len(), 100);
+ file_db.ensure_unchanged().unwrap();
+ // The same committed WAL page overrides the lazily read main page.
+ let mut wal = vec![0; 32 + 24 + 512];
+ wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes());
+ wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes());
+ wal[8..12].copy_from_slice(&512u32.to_be_bytes());
+ wal[32..36].copy_from_slice(&2u32.to_be_bytes());
+ wal[36..40].copy_from_slice(&2u32.to_be_bytes());
+ leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']);
+ let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap();
+ let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap();
+ assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap());
+ assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2));
+ let mut malformed = db.main.clone();
+ malformed.push(0);
+ let partial = Scratch::new(&malformed);
+ assert_eq!(
+ Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(),
+ "SQLite image is not a whole number of pages"
+ );
+ let mut malformed = db.main.clone();
+ malformed[24..28].copy_from_slice(&1u32.to_be_bytes());
+ malformed[92..96].copy_from_slice(&1u32.to_be_bytes());
+ malformed[28..32].copy_from_slice(&3u32.to_be_bytes());
+ let oversized = Scratch::new(&malformed);
+ assert_eq!(
+ Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(),
+ "SQLite header page count exceeds image"
+ );
}
#[test]
@@ -1042,6 +1235,7 @@ mod tests {
leaf(&mut main[1024..1536], 0, 1, &[2, 0]);
leaf(&mut main[1536..], 0, 2, &[2, 0]);
let db = Database {
+ file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1073,6 +1267,7 @@ mod tests {
let mut aliases = HashMap::new();
aliases.insert(1, 0);
let db = Database {
+ file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1093,6 +1288,7 @@ mod tests {
main[103..105].copy_from_slice(&1u16.to_be_bytes());
main[108..110].copy_from_slice(&490u16.to_be_bytes());
let db = Database {
+ file: None,
main,
overlay: HashMap::new(),
page_size: 512,
@@ -1103,6 +1299,8 @@ mod tests {
rowid_aliases: HashMap::new(),
};
assert!(db.read_table(1, 1).is_err());
+ let (_scratch, db) = file_backed(db);
+ assert!(db.read_table(1, 1).is_err());
}
#[test]
@@ -1115,6 +1313,7 @@ mod tests {
let mut main = vec![0u8; 2 * 512];
let db = Database {
+ file: None,
main: std::mem::take(&mut main),
overlay: HashMap::new(),
page_size: 512,
@@ -1141,6 +1340,10 @@ mod tests {
assert_eq!(bytes.len(), 600);
assert_eq!(&bytes[..92], &[b'x'; 92]);
assert_eq!(&bytes[92..], &[b'y'; 508]);
+ let (_scratch, mut db) = file_backed(db);
+ db.overlay.clear();
+ let bytes = db.payload(&cell_page, 0, 600).unwrap();
+ assert_eq!(&bytes[92..], &[b'y'; 508]);
}
#[test]
@@ -1164,6 +1367,7 @@ mod tests {
let mut page = vec![0u8; 512];
page[16..18].copy_from_slice(&512u16.to_be_bytes());
let db = Database {
+ file: None,
main: vec![0; 512],
overlay: HashMap::new(),
page_size: 512,
diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs
index 22d76528d..6805f8537 100644
--- a/crates/engine/src/media.rs
+++ b/crates/engine/src/media.rs
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
-pub const RENDER_CACHE_VERSION: u64 = 64;
+pub const RENDER_CACHE_VERSION: u64 = 65;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -277,7 +277,9 @@ impl SourceRef {
if from.image.width.max(from.image.height) <= *max_edge {
return Ok((**from).clone());
}
- let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
+ // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near
+ // the output's resolution limit and aliases what is finer
+ let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3);
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
}
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
@@ -761,6 +763,24 @@ pub struct RenderJob {
/// 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,
+ /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once
+ /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up
+ /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at
+ /// small ratios and aliases it at large ones. The caches keep the full render.
+ pub display_fit: Option<(usize, usize)>,
+}
+
+/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]).
+pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 {
+ use lightcraft_raster::resample::{Filter, fit};
+ let f = lightcraft_raster::Image {
+ width: img.width,
+ height: img.height,
+ data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(),
+ };
+ let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3);
+ let c = |v: f32| v.round().clamp(0.0, 255.0) as u8;
+ Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() }
}
pub struct RenderResult {
@@ -784,6 +804,14 @@ pub struct RenderResult {
impl RenderJob {
/// Render at draft quality (interactive drags). Gets its own result key.
+ /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see
+ /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again.
+ pub fn with_display_fit(mut self, w: usize, h: usize) -> Self {
+ self.display_fit = Some((w, h));
+ self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
+ self
+ }
+
pub fn draft(mut self) -> Self {
if self.request.quality != Quality::Draft {
self.request.quality = Quality::Draft;
@@ -994,6 +1022,15 @@ impl RenderJob {
{
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
}
+ // the display fit, after the caches have the full render
+ if let Some((fw, fh)) = self.display_fit
+ && self.request.quality == Quality::Full
+ {
+ let (w, h) = (rendered.image.width, rendered.image.height);
+ if w > fw.max(1) || h > fh.max(1) {
+ rendered.image = fit_for_display(&rendered.image, fw, fh);
+ }
+ }
RenderResult {
request_id: self.request_id,
source_key: self.source_key,
@@ -1266,6 +1303,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut: None,
+ display_fit: None,
})
}
@@ -1319,6 +1357,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut: None,
+ display_fit: None,
})
}
@@ -1373,6 +1412,7 @@ impl crate::Session {
display: None,
slide: None,
preview_cut,
+ display_fit: None,
})
}
@@ -1798,6 +1838,28 @@ mod thumbnail_hash_tests {
mod tests {
use super::*;
+ /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept;
+ /// a draft, and a render no larger than the drawn size, are left as rendered.
+ #[test]
+ fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() {
+ let mut s = crate::Session::with_demo();
+ let id = s.active().unwrap();
+ let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image;
+ let long = plain.width.max(plain.height);
+ assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}");
+ let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image;
+ assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height);
+ let aspect = |i: &Rgba8| i.width as f32 / i.height as f32;
+ assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept");
+ let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image;
+ assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk");
+ let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image;
+ assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered");
+ let a = s.loupe_job(id, 1024, 1024, true).unwrap();
+ let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400);
+ assert_ne!(a.key, b.key, "the fit is part of the result key");
+ }
+
#[test]
fn renders_know_the_original_size_whichever_level_they_come_from() {
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
diff --git a/crates/engine/src/open_path.rs b/crates/engine/src/open_path.rs
new file mode 100644
index 000000000..d80039729
--- /dev/null
+++ b/crates/engine/src/open_path.rs
@@ -0,0 +1,117 @@
+//! Whether a path the library holds may be handed to the operating system.
+//!
+//! A photo's `Source::File` path is not always something a user typed: it can come from an
+//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
+//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
+//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
+//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
+//!
+//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
+//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
+//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
+//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
+//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
+//!
+//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
+//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
+//! refused original never becomes a library record in the first place.
+
+use std::path::Path;
+
+/// The absolute path to hand to an opener or external editor, or the reason it is refused.
+///
+/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
+/// the path's own file name.
+pub fn openable(path: &str, label: &str) -> Result {
+ let p = Path::new(path);
+ let name = match label.trim() {
+ "" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
+ l => l.to_string(),
+ };
+ if !is_media(p) {
+ return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
+ }
+ match std::fs::metadata(p) {
+ Ok(m) if m.is_file() => {}
+ Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
+ Err(e) => return Err(format!("the original is missing: {path} ({e})")),
+ }
+ // a link may name a photo and point at a program; what runs is what it points at
+ if let Ok(target) = p.canonicalize()
+ && !is_media(&target)
+ {
+ return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
+ }
+ let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
+ Ok(absolute.to_string_lossy().into_owned())
+}
+
+/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
+pub fn is_media(path: &Path) -> bool {
+ crate::import::is_supported(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn write(dir: &Path, name: &str) -> String {
+ let p = dir.join(name);
+ std::fs::write(&p, b"x").unwrap();
+ p.to_string_lossy().into_owned()
+ }
+
+ #[test]
+ fn only_file_types_lightcraft_reads_are_handed_over() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
+ let path = write(&dir, good);
+ assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
+ }
+ for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
+ let path = write(&dir, bad);
+ let e = openable(&path, "").unwrap_err();
+ assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
+ }
+ // a bundle (macOS `.app`) and any other folder are not files
+ let bundle = dir.join("Evil.app");
+ std::fs::create_dir_all(&bundle).unwrap();
+ let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
+ let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("missing"), "{e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// A relative path is made absolute: the opener runs with its own working directory.
+ #[test]
+ fn the_path_handed_over_is_absolute() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = write(&dir, "a.jpg");
+ let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
+ let out = openable(&dotted.to_string_lossy(), "").unwrap();
+ assert!(Path::new(&out).is_absolute(), "{out}");
+ assert!(out.ends_with("a.jpg"), "{out}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// A link named like a photo that points at a program is refused: what the opener runs is
+ /// the target, not the name.
+ #[cfg(unix)]
+ #[test]
+ fn a_link_to_a_program_is_refused() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let target = write(&dir, "payload.command");
+ let link = dir.join("holiday.jpg");
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+ let e = openable(&link.to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs
index 91e5db69e..f4e50703b 100644
--- a/crates/engine/src/print/compose.rs
+++ b/crates/engine/src/print/compose.rs
@@ -1,5 +1,6 @@
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
-//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
+//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
+//! marks, page number, page info) and returns [`PageRaster`]s.
//!
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
pub const MAX_STROKE_PT: f64 = 36.0;
/// Renders kept for cells that print the same photo at the same size.
const RENDER_CACHE: usize = 4;
+/// A crop mark's length and its gap from the photo's corner, inches.
+pub const CROP_MARK_IN: f64 = 0.25;
+pub const CROP_MARK_GAP_IN: f64 = 0.0625;
+/// A crop mark's weight, points.
+pub const CROP_MARK_PT: f64 = 0.5;
+/// Size of the page number and page info text, points.
+pub const PAGE_TEXT_PT: f64 = 9.0;
/// Lightroom's Image Settings panel.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -85,11 +93,18 @@ impl ImageSettings {
pub struct PageStyle {
/// Page background colour, sRGB.
pub background: [u8; 3],
+ /// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
+ /// the page number at the bottom right of each page.
+ pub page_numbers: bool,
+ /// The print settings (sharpening, profile, resolution) at the bottom left of each page.
+ pub page_info: bool,
+ /// Marks at the corners of each photo to cut along.
+ pub crop_marks: bool,
}
impl Default for PageStyle {
fn default() -> Self {
- Self { background: [255; 3] }
+ Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
}
}
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
}
+/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
+/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
+pub fn has_page_options(style: Style) -> bool {
+ !style.is_package()
+}
+
+/// The colour of crop marks and page text on `background`: black, or white on a dark page.
+pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
+ let [r, g, b] = background.map(f64::from);
+ if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
+}
+
+/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
+/// where the resolution is; this print goes to a PDF).
+pub fn page_info_text(settings: &PrintSettings) -> String {
+ use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
+ let c = &settings.job.color;
+ let media = match c.media {
+ PrintMedia::Matte => "Matte",
+ PrintMedia::Glossy => "Glossy",
+ };
+ let sharpen = match c.sharpen {
+ PrintSharpen::Off => "Off".to_string(),
+ PrintSharpen::Low => format!("Low ({media})"),
+ PrintSharpen::Standard => format!("Standard ({media})"),
+ PrintSharpen::High => format!("High ({media})"),
+ };
+ let profile = match &c.profile {
+ PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
+ PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
+ PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
+ };
+ format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
+}
+
// ---- placement ------------------------------------------------------------------------------
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
@@ -355,8 +405,8 @@ impl CellStyle {
}
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
-/// asked), then the inner stroke on top of it.
-pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
+/// asked), then the inner stroke on top of it. Returns where the photo landed.
+pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result {
// The border may not eat the cell: at least one pixel of photo area stays.
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
let (ax, ay) = (cell.x + border, cell.y + border);
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
}
- Ok(())
+ Ok(PxRect { x: dx, y: dy, w, h })
+}
+
+/// What the Page Options draw on every page, in page pixels.
+struct Marks {
+ /// Crop mark length, gap from the corner and weight.
+ crop: Option<(usize, usize, usize)>,
+ numbers: bool,
+ info: Option,
+ ink: [u16; 3],
+ text_px: f32,
+ /// The printable area: the text sits in the margin below it, aligned with its sides.
+ area: Option,
+}
+
+impl Marks {
+ fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
+ let on = has_page_options(settings.layout.style);
+ let page = &settings.page;
+ let px = |inches: f64| (inches * ppi).round() as usize;
+ Marks {
+ crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
+ numbers: on && page.page_numbers,
+ info: (on && page.page_info).then(|| page_info_text(settings)),
+ ink: rec2020_from_srgb8(mark_ink(page.background)),
+ text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
+ area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
+ }
+ }
+
+ /// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
+ fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
+ if let Some((len, gap, weight)) = self.crop {
+ for r in photos {
+ crop_marks(page, r, len, gap, weight, photos, self.ink);
+ }
+ }
+ let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
+ let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
+ // centred in the bottom margin, and on the paper when the margin is thinner than the text
+ let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
+ if self.numbers
+ && let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
+ {
+ let x = ((area.x + area.w) as f32 - t.width).max(0.0);
+ draw_text(page, &t, x, top(t.height), self.ink);
+ }
+ if let Some(info) = &self.info
+ && let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
+ {
+ draw_text(page, &t, area.x as f32, top(t.height), self.ink);
+ }
+ }
+}
+
+/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
+/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
+fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
+ let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
+ let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
+ let segments = [
+ (x0 - g - l, y0, l, t),
+ (x1 + g, y0, l, t),
+ (x0 - g - l, y1 - t, l, t),
+ (x1 + g, y1 - t, l, t),
+ (x0, y0 - g - l, t, l),
+ (x0, y1 + g, t, l),
+ (x1 - t, y0 - g - l, t, l),
+ (x1 - t, y1 + g, t, l),
+ ];
+ let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
+ for (sx, sy, sw, sh) in segments {
+ for y in sy.max(0)..(sy + sh).min(page.h as i64) {
+ for x in sx.max(0)..(sx + sw).min(page.w as i64) {
+ let (x, y) = (x as usize, y as usize);
+ if !on_photo(x, y) {
+ page.fill_rect(x, y, 1, 1, ink);
+ }
+ }
+ }
+ }
+}
+
+/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
+fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
+ let (w, h) = (page.w, page.h);
+ text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
+ let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
+ if gx >= w || gy >= h {
+ return;
+ }
+ let i = (gy * w + gx) * 3;
+ if let Some(px) = page.rgb.get_mut(i..i + 3) {
+ let k = cov.clamp(0.0, 1.0);
+ for (p, c) in px.iter_mut().zip(ink) {
+ *p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
+ }
+ }
+ });
}
// ---- the job ---------------------------------------------------------------------------------
@@ -402,6 +550,7 @@ pub struct Prepared {
ppi: f32,
background: [u16; 3],
style: CellStyle,
+ marks: Marks,
/// Print sharpening, applied to each photo once it's rendered at its print size.
color: crate::print_color::PrintColorSettings,
}
@@ -430,6 +579,7 @@ impl Prepared {
let mut out = Vec::with_capacity(self.pages.len());
for (index, &(w, h)) in self.pages.iter().enumerate() {
let mut page = Image16::filled(w, h, self.background)?;
+ let mut placed = Vec::new();
while let Some(item) = items.next_if(|i| i.page == index) {
if !progress(done, &item.name) {
return Err("Print cancelled".into());
@@ -448,8 +598,9 @@ impl Prepared {
}
};
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
- draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
+ placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
}
+ self.marks.draw(&mut page, &placed, index + 1);
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
}
if !progress(done, "") {
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
ppi: ppi as f32,
background: rec2020_from_srgb8(settings.page.background),
style,
+ marks: Marks::of(&settings, ppi, pw, ph),
color: settings.job.color.clone(),
})
}
@@ -825,6 +977,94 @@ mod tests {
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
}
+ /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
+ /// margins are bare background unless something draws there).
+ fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec {
+ let ids = first_ids(s, 2);
+ let mut set = settings(style);
+ if style.is_package() {
+ set.layout.add_cell(3.0, 5.0).unwrap();
+ set.layout.new_page().unwrap();
+ let id = set.layout.add_cell(3.0, 5.0).unwrap();
+ set.layout.set_cell_page(id, 1).unwrap();
+ }
+ set.image.zoom_to_fill = true;
+ set.page = page;
+ prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
+ }
+
+ fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
+ let i = (y * p.width as usize + x) * 3;
+ [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
+ }
+
+ /// The pixels of `x0..x1` x `y0..y1` that are not the white background.
+ fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
+ (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
+ }
+
+ #[test]
+ fn crop_marks_sit_outside_each_photo_corner() {
+ let mut s = Session::with_demo();
+ let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
+ assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
+ let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
+ // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
+ // along each edge of the photo.
+ for p in &pages {
+ for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
+ assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
+ }
+ for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
+ assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
+ }
+ }
+ // a dark page gets white marks
+ let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
+ assert_eq!(rgb(&dark[0], 30, 50), WHITE);
+ }
+
+ #[test]
+ fn page_numbers_and_info_print_in_the_bottom_margin() {
+ let mut s = Session::with_demo();
+ let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
+ // bottom right: the number, different on each page; nothing at the bottom left
+ let (right, left) = ((200, 400), (0, 200));
+ for p in &numbered {
+ assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
+ assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
+ assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
+ }
+ let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>();
+ assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
+ let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
+ for p in &info {
+ assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
+ }
+ // the packages have no Page Options in Lightroom: nothing is drawn
+ let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
+ let package = page_options_pages(&mut s, Style::PicturePackage, all);
+ assert_eq!(package.len(), 2);
+ for p in &package {
+ assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
+ }
+ }
+
+ #[test]
+ fn page_info_names_the_sharpening_profile_and_resolution() {
+ use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
+ let mut set = PrintSettings::default();
+ let line = page_info_text(&set);
+ assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
+ set.job.color.sharpen = PrintSharpen::Off;
+ set.job.color.media = PrintMedia::Matte;
+ set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
+ set.job.ppi = 300.0;
+ assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
+ set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
+ assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
+ }
+
#[test]
fn caps_and_hostile_settings_are_refused() {
let mut s = Session::with_demo();
diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs
index 16003cd61..f0d725793 100644
--- a/crates/engine/src/rename.rs
+++ b/crates/engine/src/rename.rs
@@ -916,7 +916,8 @@ fn plan_rename_core(
) -> Vec {
let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
- for (seq, p) in (start..).zip(photos) {
+ for (i, p) in photos.iter().enumerate() {
+ let seq = start.saturating_add(i);
let want = expand(template, p, seq);
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs
index c955dcc01..b69a6dca2 100644
--- a/crates/engine/src/segment/mod.rs
+++ b/crates/engine/src/segment/mod.rs
@@ -75,6 +75,7 @@ pub(crate) struct Input {
}
/// What a detail pass re-runs.
+#[derive(Clone)]
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
pub(crate) enum Prompt {
Clicks(Vec),
@@ -440,7 +441,11 @@ impl Session {
let job = self.segment_job(id, kind, Tag::Wait, tx, region)?;
self.segmenter.worker.submit(job)?;
match rx.recv_timeout(WAIT) {
- Ok(o) => o.result,
+ Ok(o) => {
+ // (shown by the next poll in an app; logged anyway)
+ self.segmenter.messages.extend(o.notice);
+ o.result
+ }
Err(_) => Err("the AI model took too long; try again".into()),
}
}
@@ -579,7 +584,7 @@ impl Session {
self.segment_queue(id, kind, tag, Some(region))?;
} else {
let patch = self.segment_wait(id, kind, Some(region))?;
- let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
+ let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false, notice: None };
let mut polled = Polled::default();
self.apply_outcome(outcome, &mut polled);
}
@@ -618,6 +623,7 @@ impl Session {
if o.superseded {
return;
}
+ polled.messages.extend(o.notice);
match o.tag {
Tag::Wait => {}
Tag::Prepare => {
@@ -711,6 +717,18 @@ impl Session {
mod tests {
use super::*;
+ #[cfg(feature = "sam")]
+ #[test]
+ fn a_worker_notice_reaches_the_user_with_the_result() {
+ // the job after which the model runs on the CPU (the GPU failed) says so
+ let mut s = Session::with_demo();
+ let notice = Some(worker::GPU_FAILED.to_string());
+ s.segmenter.results.0.send(worker::Outcome { tag: Tag::Prepare, result: Ok(None), superseded: false, notice }).unwrap();
+ let polled = s.segment_poll();
+ assert_eq!(polled.messages, vec![worker::GPU_FAILED.to_string()]);
+ assert!(s.segment_poll().messages.is_empty(), "once");
+ }
+
fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask {
let l: Vec =
(0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect();
diff --git a/crates/engine/src/segment/worker.rs b/crates/engine/src/segment/worker.rs
index 5769fd2ae..efbb1c21c 100644
--- a/crates/engine/src/segment/worker.rs
+++ b/crates/engine/src/segment/worker.rs
@@ -7,6 +7,13 @@
//! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy
//! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded
//! after [`IDLE_UNLOAD`] without requests (it holds several GB).
+//!
+//! Where the model can run on the GPU (Metal on macOS), a job that fails there (a panic or an
+//! error from candle, e.g. a kernel the GPU's compiler can't build, issue #1974) frees the
+//! model and runs once more on the CPU. When that works, the model stays on the CPU for the rest
+//! of the session and the job's outcome says so; when it fails too, the GPU wasn't the cause and
+//! the next job uses it again. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep the model on
+//! the CPU from the start.
use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
@@ -29,6 +36,7 @@ use crate::media::RenderJob;
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
/// What to compute.
+#[derive(Clone)]
pub enum Kind {
/// Load the model and encode the photo (so the first click is fast).
Prepare,
@@ -42,6 +50,12 @@ pub enum Kind {
/// Panics (tests of the worker's recovery).
#[cfg(test)]
Panic,
+ /// Fails like the model on a GPU that can't run it (a panic or a candle error), and
+ /// succeeds on the CPU (tests of the CPU fallback).
+ #[cfg(test)]
+ FailOnGpu {
+ panic: bool,
+ },
}
/// One request.
@@ -65,6 +79,70 @@ pub struct Outcome {
pub result: Result, String>,
/// A newer request for the same selection came in; this one was skipped.
pub superseded: bool,
+ /// Something the user should know besides the result ([`GPU_FAILED`]).
+ pub notice: Option,
+}
+
+/// The notice of the job after which the model runs on the CPU.
+pub const GPU_FAILED: &str =
+ "The GPU couldn't run the AI mask model, so it runs on the CPU until LightCraft restarts (slower). Details are in the log.";
+
+/// Where the model is loaded.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Placement {
+ /// On the GPU when there is one (Metal on macOS); a job that fails there is retried on the CPU.
+ Gpu,
+ /// On the CPU: this build has no GPU path, the environment switches the GPU off, or the model
+ /// failed on it.
+ Cpu,
+}
+
+impl Placement {
+ fn initial() -> Self {
+ if !lightcraft_segment::GPU_BUILD {
+ return Placement::Cpu;
+ }
+ if let Some(why) = lightcraft_gpu::env_off_reason() {
+ log::info!("SAM 3 runs on the CPU ({why})");
+ return Placement::Cpu;
+ }
+ Placement::Gpu
+ }
+}
+
+/// Why a job failed.
+#[derive(Debug)]
+enum Failure {
+ /// The model failed (a candle error, or a panic): on the GPU, the CPU may run it.
+ Model(String),
+ /// Anything else (no model files, the photo, a remote worker): another device won't help.
+ Other(String),
+}
+
+impl Failure {
+ fn message(self) -> String {
+ match self {
+ Failure::Model(m) | Failure::Other(m) => m,
+ }
+ }
+}
+
+impl From for Failure {
+ fn from(m: String) -> Self {
+ Failure::Other(m)
+ }
+}
+
+impl From<&str> for Failure {
+ fn from(m: &str) -> Self {
+ Failure::Other(m.into())
+ }
+}
+
+impl From for Failure {
+ fn from(e: lightcraft_segment::Error) -> Self {
+ if matches!(e, lightcraft_segment::Error::Candle(_)) { Failure::Model(e.to_string()) } else { Failure::Other(e.to_string()) }
+ }
}
/// Counters the session reads without locking.
@@ -159,7 +237,7 @@ impl Worker {
// (re)start the thread
let (tx, rx) = mpsc::channel();
let shared = self.shared.clone();
- match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) {
+ match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD, Placement::initial())) {
Ok(t) => {
self.thread = Some(t);
self.tx = Some(tx.clone());
@@ -191,7 +269,7 @@ fn supersedes(later: &Job, job: &Job) -> bool {
}
}
-fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) {
+fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration, mut placement: Placement) {
let mut state = State::default();
let mut queue: VecDeque = VecDeque::new();
loop {
@@ -219,41 +297,78 @@ fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) {
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
drop(done);
- let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
+ let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true, notice: None });
continue;
}
- let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
- Ok(r) => r,
- Err(p) => {
- let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default();
- log::error!("SAM 3 panicked: {why}");
- // don't trust what the model kept: load it again next time
- state = State::default();
- Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string())
- }
+ // what a retry on the CPU needs (running the job takes them)
+ let retry = (placement == Placement::Gpu && remote::configured().is_none()).then(|| (job.kind.clone(), job.render.clone()));
+ let mut result = attempt(&mut state, shared, &mut job, placement);
+ let mut notice = None;
+ let gpu_failed = match &result {
+ Err(Failure::Model(why)) => Some(why.clone()),
+ _ => None,
};
+ if let (Some(why), Some((kind, render))) = (gpu_failed, retry) {
+ // the GPU may be the cause (e.g. a kernel its compiler can't build, issue #1974): free
+ // the model there and try once on the CPU
+ log::warn!("SAM 3 failed on the GPU ({why}); trying the CPU");
+ state = State::default();
+ (job.kind, job.render) = (kind, render);
+ match attempt(&mut state, shared, &mut job, Placement::Cpu) {
+ Ok(r) => {
+ log::warn!("SAM 3 runs on the CPU from now on: it failed on the GPU and works on the CPU");
+ placement = Placement::Cpu;
+ notice = Some(GPU_FAILED.to_string());
+ result = Ok(r);
+ }
+ Err(e) => {
+ // the CPU fails too, so the GPU isn't the cause: the next job uses it again
+ log::warn!("SAM 3 failed on the CPU too: {}", e.message());
+ state = State::default();
+ }
+ }
+ }
+ let result = result.map_err(Failure::message);
if result.is_err() && !is_detail {
state.cache = None;
}
shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst);
drop(done);
- let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
+ let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false, notice });
}
}
-/// The model for `dir`, loading it when needed.
-fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> {
+/// Run `job` with the model on `placement`. A panic is a [`Failure::Model`] and drops what the
+/// model kept.
+fn attempt(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result, Failure> {
+ match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(state, shared, job, placement))) {
+ Ok(r) => r,
+ Err(p) => {
+ let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default();
+ log::error!("SAM 3 panicked: {why}");
+ // don't trust what the model kept: load it again next time
+ *state = State::default();
+ Err(Failure::Model("the AI model failed unexpectedly (it will be reloaded on the next try)".into()))
+ }
+ }
+}
+
+/// The model for `dir`, loading it on `placement` when needed.
+fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path, placement: Placement) -> Result<&'a mut Sam3, Failure> {
if state.model.as_ref().is_none_or(|(d, _)| d != dir) {
state.model = None;
state.cache = None;
let _busy = Flag::raise(&shared.encoding);
let t = web_time::Instant::now();
- let m = Sam3::load(dir).map_err(|e| e.to_string())?;
- log::info!("SAM 3 loaded in {:?}", t.elapsed());
+ let m = match placement {
+ Placement::Gpu => Sam3::load(dir)?,
+ Placement::Cpu => Sam3::load_cpu(dir)?,
+ };
+ log::info!("SAM 3 loaded in {:?} ({})", t.elapsed(), if m.device().is_cpu() { "CPU" } else { "GPU" });
state.model = Some((dir.to_path_buf(), m));
shared.loaded.store(true, Ordering::SeqCst);
}
- state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string())
+ state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".into())
}
/// Render the model input.
@@ -275,16 +390,16 @@ fn render(job: &mut Job) -> Result {
}
/// The model and the encoded photo `job.key` (rendering and encoding it when needed).
-fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> {
- model(state, shared, &job.dir)?;
+fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<(&'a mut Sam3, &'a mut Encoded), Failure> {
+ model(state, shared, &job.dir, placement)?;
if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) {
state.cache = None;
let input = render(job)?;
let _busy = Flag::raise(&shared.encoding);
let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?;
let t = web_time::Instant::now();
- let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
- m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?;
+ let mut enc = m.encode(&input.rgb, input.w, input.h)?;
+ m.prepare_clicks(&mut enc)?;
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
state.cache = Some((job.key, enc));
}
@@ -328,7 +443,7 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
}
}
#[cfg(test)]
- Kind::Panic => return Err("test panic request".into()),
+ Kind::Panic | Kind::FailOnGpu { .. } => return Err("test panic request".into()),
};
let input = if region.is_some() || client.key != Some(job.key) { render(job)? } else { Input { rgb: Vec::new(), w: 0, h: 0 } };
let request = Request { width: input.w, height: input.h, temporary: region.is_some(), operation };
@@ -342,37 +457,44 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
}))
}
-fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result, String> {
+fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result , Failure> {
if let Some(endpoint) = remote::configured() {
let result = remote_job(state, shared, job, &endpoint);
if result.is_err() {
state.remote = None;
}
- return result.map_err(|e| format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry."));
+ return result.map_err(|e| Failure::Other(format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry.")));
}
let side = lightcraft_segment::MASK_SIDE;
match std::mem::replace(&mut job.kind, Kind::Prepare) {
Kind::Prepare => {
- encoded(state, shared, job)?;
+ encoded(state, shared, job, placement)?;
Ok(None)
}
Kind::Clicks(clicks) => {
- let (m, enc) = encoded(state, shared, job)?;
- let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?;
+ let (m, enc) = encoded(state, shared, job, placement)?;
+ let p = m.segment_clicks(enc, &clicks)?;
Ok(Some(SegMask::from_logits(side, &p.logits)))
}
Kind::Text(text) => {
- let (m, enc) = encoded(state, shared, job)?;
+ let (m, enc) = encoded(state, shared, job, placement)?;
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
}
#[cfg(test)]
#[allow(clippy::panic)]
Kind::Panic => panic!("a test panic"),
+ #[cfg(test)]
+ #[allow(clippy::panic)]
+ Kind::FailOnGpu { panic } => match placement {
+ Placement::Gpu if panic => panic!("a test panic on the GPU"),
+ Placement::Gpu => Err(Failure::Model("a test error on the GPU".into())),
+ Placement::Cpu => Ok(None),
+ },
Kind::Detail { prompt, region } => {
let input = render(job)?;
- let m = model(state, shared, &job.dir)?;
+ let m = model(state, shared, &job.dir, placement)?;
let t = web_time::Instant::now();
- let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
+ let mut enc = m.encode(&input.rgb, input.w, input.h)?;
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
if !(rw > 0.0 && rh > 0.0) {
return Ok(None);
@@ -388,7 +510,7 @@ fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result text_logits(m, &mut enc, &text)?,
};
@@ -438,7 +560,7 @@ mod tests {
jtx.send(job(Tag::Wait, &tx)).unwrap();
shared.pending.store(2, Ordering::SeqCst);
drop(jtx);
- run(&jrx, &shared, Duration::from_secs(60));
+ run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
let first = rx.recv().unwrap();
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
let second = rx.recv().unwrap();
@@ -447,6 +569,72 @@ mod tests {
assert!(!shared.encoding.load(Ordering::SeqCst));
}
+ /// Run `kinds` in turn on a worker that starts on `placement`; their outcomes.
+ fn run_kinds(placement: Placement, kinds: Vec) -> Vec {
+ let (tx, rx) = mpsc::channel();
+ let (jtx, jrx) = mpsc::channel();
+ let shared = Shared::default();
+ shared.pending.store(kinds.len(), Ordering::SeqCst);
+ for kind in kinds {
+ let mut j = job(Tag::Wait, &tx);
+ j.kind = kind;
+ jtx.send(j).unwrap();
+ }
+ drop(jtx);
+ run(&jrx, &shared, Duration::from_secs(60), placement);
+ assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
+ assert!(!shared.encoding.load(Ordering::SeqCst));
+ rx.try_iter().collect()
+ }
+
+ #[test]
+ fn a_job_that_panics_on_the_gpu_runs_on_the_cpu_and_so_do_the_next_ones() {
+ // issue #1974: candle panics compiling a Metal kernel the GPU's compiler can't build
+ let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: true }, Kind::FailOnGpu { panic: true }]);
+ assert_eq!(out.len(), 2);
+ assert!(matches!(out[0].result, Ok(None)), "retried on the CPU: {:?}", out[0].result.as_ref().err());
+ assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
+ assert!(matches!(out[1].result, Ok(None)), "the next job starts on the CPU");
+ assert_eq!(out[1].notice, None, "said once");
+ }
+
+ #[test]
+ fn a_model_error_on_the_gpu_is_retried_on_the_cpu_too() {
+ let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: false }]);
+ assert!(matches!(out[0].result, Ok(None)));
+ assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
+ }
+
+ #[test]
+ fn a_job_that_fails_on_the_cpu_too_keeps_the_gpu() {
+ let out = run_kinds(Placement::Gpu, vec![Kind::Panic, Kind::FailOnGpu { panic: true }]);
+ assert!(out[0].result.as_ref().unwrap_err().contains("failed unexpectedly"));
+ assert_eq!(out[0].notice, None);
+ // not the GPU's fault: the next job tries the GPU first again
+ assert!(matches!(out[1].result, Ok(None)));
+ assert_eq!(out[1].notice.as_deref(), Some(GPU_FAILED));
+ }
+
+ #[test]
+ fn a_worker_on_the_cpu_never_tries_the_gpu() {
+ // no GPU in this build, or LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off
+ let out = run_kinds(Placement::Cpu, vec![Kind::FailOnGpu { panic: true }]);
+ assert!(matches!(out[0].result, Ok(None)));
+ assert_eq!(out[0].notice, None);
+ if !lightcraft_segment::GPU_BUILD {
+ assert_eq!(Placement::initial(), Placement::Cpu);
+ }
+ }
+
+ #[test]
+ fn missing_model_files_are_not_retried_on_the_cpu() {
+ let out = run_kinds(Placement::Gpu, vec![Kind::Prepare]);
+ assert!(out[0].result.as_ref().unwrap_err().contains("not found"));
+ assert_eq!(out[0].notice, None);
+ let missing = lightcraft_segment::Error::Missing(PathBuf::from("models"));
+ assert!(matches!(Failure::from(missing), Failure::Other(_)));
+ }
+
#[test]
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
let (tx, rx) = mpsc::channel();
@@ -464,7 +652,7 @@ mod tests {
drop(jtx);
let shared = Shared::default();
shared.pending.store(3, Ordering::SeqCst);
- run(&jrx, &shared, Duration::from_secs(60));
+ run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
let out: Vec = rx.try_iter().collect();
assert_eq!(out.len(), 3);
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs
index ea06d69a7..2e1092140 100644
--- a/crates/engine/src/sidecar.rs
+++ b/crates/engine/src/sidecar.rs
@@ -4,6 +4,8 @@
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
+//! A sidecar is written only beside an original that is an existing file at an absolute path;
+//! otherwise the save fails.
//!
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
@@ -15,7 +17,10 @@
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
//! the first by file name — [`Session::sidecar_naming`]); the others use `.xmp`
-//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
+//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks
+//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF
+//! arriving beside its raw's `IMG_0001.xmp` reads its own `.xmp`, never the raw's packet
+//! (issue #1905).
//!
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
@@ -23,7 +28,7 @@
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -69,6 +74,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option {
sidecar_candidates(original, naming).find(|p| p.is_file())
}
+/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out
+/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not
+/// own its stem sidecar reads its own `.xmp` (the name it writes) and never the packet of
+/// the sibling that owns the stem one.
+pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option {
+ if stems.owns(original) {
+ return find_sidecar(original, naming);
+ }
+ let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem));
+ sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file())
+}
+
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
/// then under the other naming (the full name also with the photo extension in the other case),
/// each as `.xmp`, `.XMP` and `.Xmp`.
@@ -87,7 +104,12 @@ pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl
}
v
})
- .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
+ .flat_map(xmp_cases)
+}
+
+/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`.
+fn xmp_cases(p: PathBuf) -> [PathBuf; 3] {
+ [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]
}
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
@@ -526,6 +548,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec) {
}
}
+/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file
+/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks
+/// last among files of its kind and name: see [`ImportStems`]).
+type Rank = (bool, String, u64);
+
+/// The rank of the file at `path`, of kind `kind`, catalogued as `id`.
+fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank {
+ (kind != MediaKind::Raw, path.to_lowercase(), id)
+}
+
/// Which photo owns each stem sidecar that several catalogued files map to (see
/// [`Session::sidecar_naming`]).
pub(crate) struct StemOwners(HashMap);
@@ -536,10 +568,10 @@ impl StemOwners {
/// owners were worked out before, for every write).
#[cfg(test)]
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
- let mut best: HashMap = HashMap::new();
+ 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 };
- let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
+ let rank = rank_of(path, p.kind, p.id.0);
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
e.2 += 1;
if rank < e.0 {
@@ -564,7 +596,7 @@ impl StemOwners {
/// 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 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 };
@@ -572,7 +604,7 @@ impl StemOwners {
if !wanted.contains(key.as_str()) {
continue;
}
- let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
+ let rank = rank_of(path, p.kind, p.id.0);
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
e.2 += 1;
if rank < e.0 {
@@ -591,6 +623,66 @@ impl StemOwners {
}
}
+/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of
+/// their stem, and which read their own `.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905:
+/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating,
+/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with
+/// the arriving files ranked against the library and against each other: a raw first, then the
+/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when
+/// the import commits is above every id in the library, so it loses a tie against a catalogued
+/// file; between arriving files the order they arrive in stands for it.
+pub(crate) struct ImportStems {
+ /// The arriving files that own the stem sidecar of their stem, by path as it arrives.
+ owners: HashSet,
+}
+
+impl ImportStems {
+ /// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a
+ /// library already looked over (a job prepared twice): there is nothing to rank against, so
+ /// every arriving file reads as it did before. The library is passed over once, and only the
+ /// stems the arriving files map to are looked at closer.
+ pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator- ) -> ImportStems {
+ // per stem: the best of the library's files, and the best arriving file with its path
+ let mut best: HashMap
, Option<(Rank, &'a str)>)> = HashMap::new();
+ let mut arriving_paths = HashSet::new();
+ for (arriving, (path, kind)) in incoming.enumerate() {
+ arriving_paths.insert(path);
+ let e = best.entry(stem_key(path)).or_default();
+ let rank = rank_of(path, kind, arriving as u64);
+ if e.1.as_ref().is_none_or(|(b, _)| rank < *b) {
+ e.1 = Some((rank, path));
+ }
+ }
+ if let Some(cat) = cat {
+ let mut key = String::new();
+ for p in cat.photos().filter(|p| p.copy_of.is_none()) {
+ // the arriving file's own record (a trashed photo imported again) is not a rival
+ let Some(path) = file_path(p).filter(|path| !arriving_paths.contains(*path)) else { continue };
+ stem_key_into(&mut key, path);
+ let Some(e) = best.get_mut(key.as_str()) else { continue };
+ let rank = rank_of(path, p.kind, p.id.0);
+ if e.0.as_ref().is_none_or(|b| rank < *b) {
+ e.0 = Some(rank);
+ }
+ }
+ }
+ let owners = best
+ .into_values()
+ .filter_map(|(in_library, arriving)| {
+ let (rank, path) = arriving?;
+ let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str()));
+ wins.then(|| path.to_string())
+ })
+ .collect();
+ ImportStems { owners }
+ }
+
+ /// Whether the arriving file at `original` owns the stem sidecar of its stem.
+ pub(crate) fn owns(&self, original: &str) -> bool {
+ self.owners.contains(original)
+ }
+}
+
/// 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 {
@@ -632,6 +724,10 @@ impl Session {
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
+ // A relative path would be taken from the working directory.
+ if Path::new(orig).is_relative() || !Path::new(orig).is_file() {
+ return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name)));
+ }
let naming = owners.naming(p, self.xmp.naming);
let preferred = sidecar_path(orig, naming);
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
@@ -786,6 +882,30 @@ impl Session {
#[cfg(test)]
mod tests {
+ #[test]
+ fn sidecars_are_written_only_beside_an_existing_original() {
+ let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap();
+ let originals = [
+ (r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false),
+ (dir.join("IMG_2.jpg").to_string_lossy().to_string(), false),
+ (dir.join("IMG_1.jpg").to_string_lossy().to_string(), true),
+ ];
+ for (original, written) in originals {
+ let _ = std::fs::remove_file(dir.join("IMG_1.xmp"));
+ let mut s = Session::new();
+ let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00");
+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}");
+ assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}");
+ assert!(!dir.join("IMG_2.xmp").exists(), "{original}");
+ assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}");
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
/// gives the photo its keywords as paths.
#[test]
diff --git a/crates/engine/src/sidecar/tests_stem_owners.rs b/crates/engine/src/sidecar/tests_stem_owners.rs
index 43170f4f6..0d86fe92d 100644
--- a/crates/engine/src/sidecar/tests_stem_owners.rs
+++ b/crates/engine/src/sidecar/tests_stem_owners.rs
@@ -1,4 +1,5 @@
-//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
+//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and
+//! for the files an import is adding (`ImportStems`).
//!
//! 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.
@@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [
"/pics/dots/a.jpg",
];
+/// The kind a file of this name is catalogued as: the `.cr3` ones are raws.
+fn kind_of(path: &str) -> MediaKind {
+ if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image }
+}
+
fn library() -> (Catalog, Vec) {
let mut c = Catalog::new();
let mut ids = Vec::new();
@@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec) {
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;
- }
+ p.kind = kind_of(path);
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
@@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() {
assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
}
+/// Given a library where files share stems in every way a name can, when files arrive in an
+/// import, then each of them may read the shared stem sidecar exactly when it would own it once
+/// it is catalogued: the arriving files are ranked against the library's files and against each
+/// other, as the write rule ranks them (issue #1905).
+#[test]
+fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() {
+ let (c, _) = library();
+ // the arrivals: the same names under an upper-case folder, so each shares its stem with the
+ // library's files (the key lower-cases the path) without being one of them
+ let incoming: Vec<(String, MediaKind)> = PATHS
+ .iter()
+ .map(|p| {
+ let (dir, name) = p.rsplit_once('/').unwrap_or(("", p));
+ (format!("{}/{name}", dir.to_uppercase()), kind_of(p))
+ })
+ .collect();
+ // the library with these arrivals in it, the ids drawn in the order they arrive
+ let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog {
+ let mut cat = base.clone();
+ for &k in add {
+ let (path, kind) = &incoming[k];
+ let id = cat.alloc_photo_id();
+ let name = path.rsplit('/').next().unwrap_or(path);
+ let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01");
+ p.kind = *kind;
+ cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ }
+ cat
+ };
+ let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool {
+ let path = &incoming[k].0;
+ let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap();
+ StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem
+ };
+ let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() };
+ let all: Vec = (0..incoming.len()).collect();
+ for i in 0..incoming.len() {
+ let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter());
+ assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0);
+ for j in 0..incoming.len() {
+ // every pair arriving together
+ if i == j {
+ continue;
+ }
+ let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter());
+ let cat = arriving_in(&c, &[i, j]);
+ for k in [i, j] {
+ assert_eq!(
+ pair.owns(&incoming[k].0),
+ owns_once_catalogued(&cat, k),
+ "{} with {}",
+ incoming[k].0,
+ incoming[if k == i { j } else { i }].0
+ );
+ }
+ }
+ }
+ // a job prepared twice (a library already looked over) has nothing to rank against: every
+ // one of them is ranked among the arrivals alone, as it would be in a library of just them
+ let alone = ImportStems::for_import(None, as_arriving(&all).into_iter());
+ let only_arrivals = arriving_in(&Catalog::new(), &all);
+ for k in &all {
+ assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0);
+ }
+}
+
/// 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
diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs
index 409739335..a27f75d49 100644
--- a/crates/engine/src/slide_overlay.rs
+++ b/crates/engine/src/slide_overlay.rs
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
let my0 = (y - pad).floor().max(-pad);
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
- if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
+ if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
return;
}
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs
index 8f615bf0c..dbee505af 100644
--- a/crates/engine/src/slideshow.rs
+++ b/crates/engine/src/slideshow.rs
@@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result, String> {
Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None),
Value::String(s) => {
let h = s.strip_prefix('#').unwrap_or(s);
- if h.len() != 6 || !h.is_ascii() {
+ if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(bad());
}
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad);
@@ -747,6 +747,8 @@ mod tests {
json!({"repeat": 1}),
json!({"fadeColor": "#12345"}),
json!({"fadeColor": "#ééé"}),
+ json!({"fadeColor": "#+f+f+f"}),
+ json!({"fadeColor": "-12345"}),
json!({"fadeColor": [300, 0, 0]}),
json!({"fadeColor": [1, 2]}),
json!({"speed": 2}),
diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs
new file mode 100644
index 000000000..9f01ed05d
--- /dev/null
+++ b/crates/engine/src/tests_constrain_crop.rs
@@ -0,0 +1,147 @@
+//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the
+//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so
+//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop.
+
+use lightcraft_geom::{CropGeometry, Rect};
+use lightcraft_pipeline::geometry::Frame;
+use serde_json::json;
+
+use crate::Session;
+
+fn crop(s: &Session) -> CropGeometry {
+ s.develop_of(s.active().unwrap()).unwrap().crop.geometry
+}
+
+/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)?
+fn fits(s: &Session) -> bool {
+ let p = s.catalog.photo(s.active().unwrap()).unwrap();
+ let mut d = (*p.develop).clone();
+ d.geometry.constrain_crop = true;
+ let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref());
+ let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect);
+ [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
+}
+
+fn smaller(a: Rect, b: Rect) -> bool {
+ a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3
+}
+
+fn constrain(s: &mut Session, on: bool) {
+ s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap();
+}
+
+fn vertical(s: &mut Session, v: f64) {
+ s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap();
+}
+
+#[test]
+fn a_geometry_slider_tightens_the_crop_while_constrained() {
+ let mut s = Session::with_demo();
+ let full = crop(&s).rect;
+ constrain(&mut s, true);
+ assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off");
+ vertical(&mut s, -60.0);
+ assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect);
+ assert!(fits(&s), "and holds no blank corner");
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider");
+}
+
+#[test]
+fn ticking_constrain_after_the_warp_tightens_the_crop() {
+ let mut s = Session::with_demo();
+ let full = crop(&s).rect;
+ vertical(&mut s, -60.0);
+ assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners");
+ assert!(!fits(&s));
+ constrain(&mut s, true);
+ assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect);
+ // unticking keeps the tighter crop, as in Lightroom
+ let tight = crop(&s).rect;
+ constrain(&mut s, false);
+ assert_eq!(crop(&s).rect, tight);
+}
+
+#[test]
+fn a_crop_drawn_by_hand_is_kept_as_drawn() {
+ let mut s = Session::with_demo();
+ constrain(&mut s, true);
+ vertical(&mut s, -60.0);
+ s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
+ assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted");
+}
+
+/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's
+/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop.
+#[test]
+fn auto_sync_fits_each_photos_own_crop() {
+ let mut s = Session::with_demo();
+ let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
+ let (a, b) = (ids[0], ids[1]);
+ // B: a small crop off-centre; both constrained
+ s.selection = crate::Selection { ids: vec![b], active: Some(b) };
+ s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
+ constrain(&mut s, true);
+ s.selection = crate::Selection { ids: vec![a], active: Some(a) };
+ constrain(&mut s, true);
+ let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
+ // A active, both selected, Auto Sync on: a Geometry edit on A
+ s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
+ s.execute("develop.autoSync", &json!({"on": true})).unwrap();
+ vertical(&mut s, -60.0);
+ let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
+ assert_eq!(db.geometry.vertical, -60.0, "the edit reached B");
+ let b_after = db.crop.geometry.rect;
+ assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
+ let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
+ assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}");
+ s.selection = crate::Selection { ids: vec![b], active: Some(b) };
+ assert!(fits(&s), "and fits B's warp");
+}
+
+/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the
+/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B.
+#[test]
+fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() {
+ let mut s = Session::with_demo();
+ let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
+ let (a, b) = (ids[0], ids[1]);
+ s.selection = crate::Selection { ids: vec![b], active: Some(b) };
+ s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
+ constrain(&mut s, true);
+ s.selection = crate::Selection { ids: vec![a], active: Some(a) };
+ constrain(&mut s, true);
+ let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
+ s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
+ s.execute("develop.autoSync", &json!({"on": true})).unwrap();
+ s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap();
+ for v in [-20.0, -40.0, -60.0] {
+ vertical(&mut s, v);
+ }
+ s.execute("develop.endInteraction", &json!({})).unwrap();
+ let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
+ assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0));
+ let b_after = db.crop.geometry.rect;
+ assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
+ let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
+ assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}");
+ for photo in [a, b] {
+ s.selection = crate::Selection { ids: vec![photo], active: Some(photo) };
+ assert!(fits(&s), "each crop fits its photo's warp");
+ }
+}
+
+/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning
+/// another section's eye off leaves a crop drawn by hand as it is.
+#[test]
+fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() {
+ let mut s = Session::with_demo();
+ constrain(&mut s, true);
+ vertical(&mut s, -60.0);
+ s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
+ s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap();
+ assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change");
+ s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap();
+ s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap();
+ assert!(fits(&s), "the Geometry eye is one: the crop fits again");
+}
diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests_crop_aspect.rs
new file mode 100644
index 000000000..560290b7f
--- /dev/null
+++ b/crates/engine/src/tests_crop_aspect.rs
@@ -0,0 +1,86 @@
+//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5",
+//! "original") takes the crop's present orientation, landscape or portrait after X, as in
+//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop.
+
+use serde_json::{Value, json};
+
+use crate::Session;
+
+/// The demo photo, landscape, with its crop's width and height in pixels.
+fn crop_px(s: &Session) -> (f64, f64) {
+ let p = s.catalog.photo(s.active().unwrap()).unwrap();
+ let (w, h) = (p.width as f64, p.height as f64);
+ let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
+ let r = p.develop.crop.geometry.rect_px(w, h);
+ (r.width(), r.height())
+}
+
+fn landscape_photo() -> Session {
+ let mut s = Session::with_demo();
+ let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap();
+ s.selection = crate::Selection { ids: vec![id], active: Some(id) };
+ s
+}
+
+fn aspect(s: &mut Session, p: Value) {
+ s.execute("crop.aspect", &p).unwrap();
+}
+
+fn ratio(s: &Session) -> f64 {
+ let (w, h) = crop_px(s);
+ w / h
+}
+
+#[test]
+fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() {
+ let mut s = landscape_photo();
+ aspect(&mut s, json!({"aspect": "2x3"}));
+ assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s));
+ s.execute("crop.rotateAspect", &json!({})).unwrap();
+ assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s));
+ // another named ratio after X stays portrait (it used to turn back to the image's landscape)
+ aspect(&mut s, json!({"aspect": "4x5"}));
+ assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s));
+ aspect(&mut s, json!({"aspect": "16x9"}));
+ assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s));
+}
+
+#[test]
+fn a_custom_ratio_is_used_as_given() {
+ let mut s = landscape_photo();
+ aspect(&mut s, json!({"aspect": [2, 3]}));
+ assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s));
+ aspect(&mut s, json!({"aspect": [3, 2]}));
+ assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
+}
+
+/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a
+/// landscape photo it lands landscape, whatever photo it was chosen on.
+#[test]
+fn orientation_overrides_and_is_checked() {
+ let mut s = landscape_photo();
+ aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"}));
+ assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
+ aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"}));
+ assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s));
+ let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err();
+ assert!(e.to_string().contains("orientation"), "{e}");
+}
+
+/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked
+/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes.
+#[test]
+fn a_bad_orientation_is_refused_before_anything_is_locked() {
+ let mut s = landscape_photo();
+ let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop;
+ for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] {
+ for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] {
+ let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err();
+ assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}");
+ assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched");
+ }
+ }
+ // the valid words still lock as is
+ aspect(&mut s, json!({"aspect": "current", "orientation": "crop"}));
+ assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some());
+}
diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs
index 2f1a05116..4830490b5 100644
--- a/crates/engine/src/tests_export.rs
+++ b/crates/engine/src/tests_export.rs
@@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is
+/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth
+/// gets its own, as stored settings do, while a depth the call itself passes is still refused.
+#[test]
+fn a_preset_depth_is_judged_against_the_format_written() {
+ use crate::export::ExportPreset;
+ let mut s = Session::with_demo();
+ let id = s.active().unwrap();
+ s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) });
+ let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap();
+ let o = ExportOptions::from_params(&p).unwrap();
+ assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16)));
+ assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG");
+ let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap();
+ assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None);
+ let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap();
+ assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg"));
+ // such a preset can be saved: its depth is judged when it exports; with a format, at once
+ s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap();
+ assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err());
+}
+
+/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A
+/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does,
+/// instead of refusing the save; without `format`, the depth waits for the format written.
+#[test]
+fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() {
+ let mut s = Session::with_demo();
+ s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"}));
+ s.execute("export.savePreset", &json!({"name": "Old"})).unwrap();
+ assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80}));
+ s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"}));
+ s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap();
+ let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap();
+ assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16));
+}
+
#[test]
fn presets_conflicts_and_subfolders() {
use crate::export::{Destination, export_batch};
@@ -991,7 +1028,8 @@ fn same_folder_as_original_exports_next_to_each_photo() {
let exists = |p: &str| std::path::Path::new(p).exists();
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
- let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()];
+ // `export` joins the subfolder and name with `/` (`join_path`), on Windows too.
+ let mut want = vec![format!("{}/Exports/IMG_1.jpg", a.to_string_lossy()), format!("{}/Exports/IMG_1.jpg", b.to_string_lossy())];
let mut got: Vec = written.iter().map(|p| p.to_string()).collect();
want.sort();
got.sort();
@@ -1024,21 +1062,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
let mut s = Session::with_demo();
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
let mut written = Vec::new();
- let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
- written.push(p.to_string());
- Ok(())
+ // the writer lives in a block of its own, so its borrow of `written` has ended by the time
+ // the assertion below reads it
+ let files = {
+ let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
+ written.push(p.to_string());
+ Ok(())
+ };
+ let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
+ assert!(err.contains("no folder on disk"), "{err}");
+ // without stopping: one error per photo, nothing written
+ let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
+ crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap()
};
- let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
- assert!(err.contains("no folder on disk"), "{err}");
- // without stopping: one error per photo, nothing written
- let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
- let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
assert_eq!(files.len(), 2);
for (f, id) in files.iter().zip(&ids) {
assert_eq!(f["photo"], id.0, "{f}");
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
}
- drop(write);
assert!(written.is_empty(), "{written:?}");
// a photo on disk whose folder is gone (moved, an unplugged drive)
diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs
index ffe300795..2c22c00e3 100644
--- a/crates/engine/src/tests_history_cursor.rs
+++ b/crates/engine/src/tests_history_cursor.rs
@@ -115,3 +115,95 @@ fn negative_indices_are_refused() {
assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err());
assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed");
}
+
+#[test]
+fn fractional_version_delete_preserves_the_version() {
+ let mut s = Session::with_demo();
+ s.execute("version.create", &json!({"name": "A"})).unwrap();
+ let before = s.catalog.to_snapshot();
+ let result = s.execute("version.delete", &json!({"index": 0.9}));
+ assert!(result.is_err(), "index 0.9 must be refused; got {result:?}; catalog unchanged: {}", s.catalog.to_snapshot() == before);
+ assert_eq!(s.catalog.to_snapshot(), before, "the version must remain unchanged");
+}
+
+/// Invalid command indices must leave objects, settings, History and undo unchanged.
+#[test]
+fn invalid_command_indices_leave_the_catalog_and_history_unchanged() {
+ let (mut s, _) = three_edits();
+ for name in ["A", "B"] {
+ s.execute("version.create", &json!({"name": name})).unwrap();
+ }
+ s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
+ for x in [0.3, 0.6] {
+ s.execute("redeye.add", &json!({"center": [x, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
+ }
+ let before = s.catalog.to_snapshot();
+ let history = s.execute("history.list", &json!({})).unwrap();
+ let undo_len = s.undo.len();
+ // The integer beyond u64 is parsed as a float by serde_json; it must not saturate to usize::MAX.
+ let oversized: serde_json::Value = serde_json::from_str("18446744073709551616").unwrap();
+ for command in [
+ "version.delete",
+ "version.rename",
+ "version.update",
+ "version.restore",
+ "pointColor.delete",
+ "redeye.delete",
+ "redeye.catchlight",
+ "history.restore",
+ ] {
+ for index in [json!(0.5), json!(1.9), json!(-1), json!(-0.5), json!("0"), json!(null), json!(u64::MAX), oversized.clone()] {
+ let result = s.execute(command, &json!({"index": index, "name": "Changed"}));
+ assert!(result.is_err(), "{command} accepted {index}: {result:?}");
+ assert_eq!(s.catalog.to_snapshot(), before, "{command} changed the catalog for {index}");
+ assert_eq!(s.execute("history.list", &json!({})).unwrap(), history, "{command} moved the History cursor for {index}");
+ assert_eq!(s.undo.len(), undo_len, "{command} added an undo step for {index}");
+ }
+ assert!(s.execute(command, &json!({"name": "Changed"})).is_err(), "{command} accepted a missing index");
+ assert_eq!(s.catalog.to_snapshot(), before);
+ }
+}
+
+/// Whole floating-point JSON numbers remain valid for existing control and MCP callers.
+#[test]
+fn whole_command_indices_accept_integer_and_float_numbers() {
+ let (mut s, n) = three_edits();
+ let id = s.active().unwrap();
+ s.execute("history.restore", &json!({"index": (n - 3) as f64})).unwrap();
+ assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
+ s.execute("history.restore", &json!({"index": n - 1})).unwrap();
+ assert_eq!(exposure(&s), 3.0);
+ for name in ["A", "B", "C"] {
+ s.execute("version.create", &json!({"name": name})).unwrap();
+ }
+ s.execute("version.rename", &json!({"index": 2.0, "name": "Third"})).unwrap();
+ assert_eq!(s.catalog.photo(id).unwrap().versions[2].name, "Third");
+ s.execute("develop.set", &json!({"control": "light.exposure", "value": -2.0})).unwrap();
+ s.execute("version.update", &json!({"index": 1.0})).unwrap();
+ assert_eq!(s.catalog.photo(id).unwrap().versions[1].settings.light.exposure, -2.0);
+ s.execute("version.restore", &json!({"index": 0.0})).unwrap();
+ assert_eq!(exposure(&s), 3.0);
+ s.execute("version.delete", &json!({"index": 1})).unwrap();
+ assert_eq!(s.catalog.photo(id).unwrap().versions.iter().map(|v| v.name.as_str()).collect::>(), ["A", "Third"]);
+ s.execute("version.delete", &json!({"index": 1.0})).unwrap();
+ assert_eq!(s.catalog.photo(id).unwrap().versions.len(), 1);
+ s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
+ s.execute("pointColor.delete", &json!({"index": 0.0})).unwrap();
+ assert!(s.develop_of(id).unwrap().point_colors.is_empty());
+ s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
+ s.execute("redeye.catchlight", &json!({"index": 0.0})).unwrap();
+ assert!(s.develop_of(id).unwrap().red_eye[0].catchlight.is_some());
+ s.execute("redeye.delete", &json!({"index": 0.0})).unwrap();
+ assert!(s.develop_of(id).unwrap().red_eye.is_empty());
+}
+
+#[test]
+fn fractional_history_restore_preserves_the_cursor() {
+ let (mut s, _) = three_edits();
+ let before = s.execute("history.list", &json!({})).unwrap();
+ let result = s.execute("history.restore", &json!({"index": 1.9}));
+ let after = s.execute("history.list", &json!({})).unwrap();
+ assert!(result.is_err(), "index 1.9 must be refused; got {result:?}; History unchanged: {}", before == after);
+ assert_eq!(after, before);
+ assert_eq!(exposure(&s), 3.0);
+}
diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs
index 3d6469deb..c5ff90456 100644
--- a/crates/engine/src/tests_import.rs
+++ b/crates/engine/src/tests_import.rs
@@ -778,6 +778,50 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of
+/// every month, including those with fewer than 31 days.
+#[test]
+fn assisted_culling_groups_bursts_across_month_end() {
+ let dir = temp_dir("cull-month-end");
+ for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
+ let img = lightcraft_scenes::demo_library()[0].render(w, h);
+ let data: Vec<[u8; 4]> = img
+ .data
+ .iter()
+ .map(|c| {
+ let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
+ [e(c[0]), e(c[1]), e(c[2]), 255]
+ })
+ .collect();
+ let png = lightcraft_codecs::encode_png(
+ &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
+ &Default::default(),
+ )
+ .unwrap();
+ std::fs::write(dir.join(name), png).unwrap();
+ }
+ let mut s = Session::new().with_fs();
+ let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
+ let ids: Vec = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
+ let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
+ let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
+ for (before, after) in [
+ ("2026-01-31T23:59:59", "2026-02-01T00:00:00"),
+ ("2026-02-28T23:59:59", "2026-03-01T00:00:00"),
+ ("2026-04-30T23:59:59", "2026-05-01T00:00:00"),
+ ("2026-06-30T23:59:59", "2026-07-01T00:00:00"),
+ ("2026-09-30T23:59:59", "2026-10-01T00:00:00"),
+ ("2026-11-30T23:59:59", "2026-12-01T00:00:00"),
+ ("2026-12-31T23:59:59", "2027-01-01T00:00:00"),
+ ] {
+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();
+ s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();
+ let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap();
+ assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}");
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
#[test]
fn dust_spots_are_found_and_healed() {
@@ -1088,6 +1132,25 @@ fn import_can_replace_a_duplicate_from_recently_deleted() {
let _ = std::fs::remove_dir_all(&src);
}
+/// A trashed photo imported again as a new one owns its stem sidecar: its own old record is not a
+/// rival for `a.xmp` (issue #1905's ranking), so the fresh photo reads it.
+#[test]
+fn a_fresh_reimport_reads_its_own_stem_sidecar() {
+ let (mut s, src, id) = trashed_photo("trash-fresh-sidecar");
+ std::fs::write(
+ src.join("a.xmp"),
+ r#"
+ "#,
+ )
+ .unwrap();
+ let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap();
+ assert_eq!(ids(&r, "imported"), 1, "{r}");
+ let new = r["imported"][0].as_u64().unwrap();
+ assert_ne!(new, id);
+ assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap().rating, 3, "the fresh photo reads a.xmp: {r}");
+ let _ = std::fs::remove_dir_all(&src);
+}
+
#[test]
fn unknown_on_deleted_is_an_error() {
let mut s = Session::new().with_fs();
@@ -1748,3 +1811,94 @@ fn presets_apply_the_imported_profile_they_name() {
assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid");
let _ = std::fs::remove_dir_all(&src);
}
+
+/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one.
+fn write_raw(path: &Path) {
+ use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
+ let px: Vec = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect();
+ let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
+ let off = 8 + 2 + 24 + 4;
+ let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
+ for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] {
+ f.extend_from_slice(&tag.to_le_bytes());
+ f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
+ f.extend_from_slice(&v.to_le_bytes());
+ }
+ f.extend_from_slice(&[0, 0, 0, 0]);
+ f.extend_from_slice(&j);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, f).unwrap();
+}
+
+/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft.
+fn write_tiff(path: &Path, seed: u8) {
+ use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff};
+ let (w, h) = (48usize, 32usize);
+ let px: Vec = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect();
+ let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap();
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, bytes).unwrap();
+}
+
+/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first,
+/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never
+/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating,
+/// label, metadata and develop settings.
+#[test]
+fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() {
+ let sidecar = |rating: u8| {
+ format!(
+ r#"
+ "#
+ )
+ };
+ let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() };
+
+ // the raw and the edited TIFF of it, same name, in one import
+ let src = temp_dir("stem-pair");
+ write_raw(&src.join("IMG_1.NEF"));
+ write_tiff(&src.join("IMG_1.TIF"), 7);
+ std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap();
+ std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap();
+ let mut s = Session::new().with_fs();
+ let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
+ assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
+ assert_eq!(r["sidecars"], 2, "{r}");
+ assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own");
+ assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's");
+ let _ = std::fs::remove_dir_all(&src);
+
+ // the TIFF with no sidecar of its own: nothing of the raw's packet comes across
+ let src = temp_dir("stem-none");
+ write_raw(&src.join("IMG_2.NEF"));
+ write_tiff(&src.join("IMG_2.TIF"), 8);
+ std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap();
+ let mut s = Session::new().with_fs();
+ let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
+ assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
+ assert_eq!(r["sidecars"], 1, "{r}");
+ assert_eq!(read(&s, "IMG_2.NEF"), 4);
+ assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's");
+ let _ = std::fs::remove_dir_all(&src);
+
+ // the raw already in the library, the edited TIFF arriving later
+ use lightcraft_catalog::{MediaKind, Op, Photo, Source};
+ let src = temp_dir("stem-after");
+ write_raw(&src.join("IMG_3.NEF"));
+ write_tiff(&src.join("IMG_3.TIF"), 9);
+ std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap();
+ let mut s = Session::new().with_fs();
+ let raw = {
+ let id = s.catalog.alloc_photo_id();
+ let path = src.join("IMG_3.NEF").to_string_lossy().into_owned();
+ let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48");
+ p.kind = MediaKind::Raw;
+ p
+ };
+ s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap();
+ let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap();
+ assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}");
+ assert_eq!(r["sidecars"], 0, "{r}");
+ assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp");
+ let _ = std::fs::remove_dir_all(&src);
+}
diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs
index 8990acb4c..344838432 100644
--- a/crates/engine/src/tests_organize.rs
+++ b/crates/engine/src/tests_organize.rs
@@ -336,3 +336,23 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() {
drop(reopened);
let _ = std::fs::remove_dir_all(dir);
}
+
+/// A huge `start` is a validation error, not an arithmetic overflow in the numbering.
+#[test]
+fn rename_huge_start_is_a_bad_param() {
+ let mut s = Session::with_demo();
+ let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect();
+ let names_before: Vec = s.catalog.photos().map(|p| p.file_name.clone()).collect();
+ for cmd in ["photo.renamePreview", "photo.rename"] {
+ let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err();
+ assert!(e.to_string().contains("start out of range"), "{cmd}: {e}");
+ }
+ let names_after: Vec = s.catalog.photos().map(|p| p.file_name.clone()).collect();
+ assert_eq!(names_before, names_after, "a rejected rename changes nothing");
+ // the last start whose whole sequence fits is accepted, one more is not
+ let fits = usize::MAX as u64 - 2;
+ let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids}));
+ assert!(r.is_ok(), "{r:?}");
+ let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids}));
+ assert!(r.is_err(), "{r:?}");
+}
diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests_persist.rs
index 4fb3f0234..7ebf8464b 100644
--- a/crates/engine/src/tests_persist.rs
+++ b/crates/engine/src/tests_persist.rs
@@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() {
s.close_library().unwrap();
assert_eq!(s.unsaved(), None);
}
+
+/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as
+/// unsaved (the top bar warns, the web backup refuses) until a later save writes it.
+#[test]
+fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() {
+ let files = Flaky::default();
+ let mut s = Session::new();
+ let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
+ s.open_library_in(stores, true).unwrap();
+ assert!(s.unsaved().is_none());
+ files.set_failing(true);
+ s.execute("preset.create", &json!({"name": "Mine"})).unwrap();
+ let (n, why) = s.unsaved().expect("the preset is unsaved");
+ assert_eq!(n, 1);
+ assert!(why.contains("presets") && why.contains("disk full"), "{why}");
+ let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default();
+ assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine"));
+ // still failing: still unsaved
+ s.persist().unwrap();
+ assert!(s.unsaved().is_some());
+ files.set_failing(false);
+ s.persist().unwrap();
+ assert!(s.unsaved().is_none(), "written once the disk works again");
+ assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine"));
+}
diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs
index c69bb7da2..4c9d9582d 100644
--- a/crates/engine/src/tests_prefs.rs
+++ b/crates/engine/src/tests_prefs.rs
@@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() {
let _ = std::fs::remove_dir_all(&lib);
}
+#[test]
+fn last_merge_settings_persist_with_the_library() {
+ let lib = temp_dir("merge");
+ let opts = json!({"align": false, "deghost": "high"});
+ {
+ let mut s = Session::new().with_fs();
+ s.open_library(&lib, false).unwrap();
+ s.last_merge.insert("merge.hdr".into(), opts.clone());
+ s.save_prefs().unwrap();
+ s.close_library().unwrap();
+ }
+ let mut s = Session::new().with_fs();
+ s.open_library(&lib, false).unwrap();
+ assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts));
+ assert!(!s.last_merge.contains_key("merge.panorama"));
+ drop(s);
+ let _ = std::fs::remove_dir_all(&lib);
+}
+
#[test]
fn default_copyright_and_creator_fill_gaps_on_import() {
let dir = temp_dir("copyright");
diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs
index 5b890a7f0..898faa889 100644
--- a/crates/engine/src/tests_print.rs
+++ b/crates/engine/src/tests_print.rs
@@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() {
assert!(c.params.len() > 3, "{id}");
}
}
+
+#[test]
+fn page_options_are_print_settings_and_template_fields() {
+ let mut s = Session::with_demo();
+ let g = run(&mut s, "print.get", json!({}));
+ assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false}));
+ let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}}));
+ assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false)));
+ assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges");
+ fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}}));
+ fails(&mut s, "print.set", json!({"page": {"pageNumber": true}}));
+ // a template keeps them
+ run(&mut s, "print.template.save", json!({"name": "Marked"}));
+ run(&mut s, "print.reset", json!({}));
+ assert!(!s.print.page.crop_marks);
+ run(&mut s, "print.template.apply", json!({"name": "Marked"}));
+ assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info);
+ let set = crate::find_command("print.set").unwrap();
+ assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params);
+}
diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/tests_slide_template.rs
index f7a899c43..9d3e9c807 100644
--- a/crates/engine/src/tests_slide_template.rs
+++ b/crates/engine/src/tests_slide_template.rs
@@ -248,6 +248,9 @@ fn bad_files_are_reported_and_skipped() {
}
std::fs::create_dir_all(folder.join("folder.json")).unwrap();
std::fs::write(folder.join("notes.txt"), b"ignored").unwrap();
+ // written by hand, not through LightCraft: the folder's time may not have moved since the
+ // save's listing (same clock tick), so the cached listing is dropped as a write would drop it
+ s.slide_templates_cache = None;
let r = run(&mut s, "slideshow.templates.list", json!({}));
let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect();
assert_eq!(user.len(), 1, "only the good one: {r}");
diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs
index 988e7aac1..28a9fca0b 100644
--- a/crates/engine/src/tests_spots.rs
+++ b/crates/engine/src/tests_spots.rs
@@ -82,3 +82,242 @@ fn a_content_aware_spot_has_no_source() {
assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill");
assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err());
}
+
+fn target_session() -> Session {
+ let mut s = Session::with_demo();
+ s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap();
+ s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
+ s.execute("mask.addComponent", &json!({"kind": "colorRange"})).unwrap();
+ s.execute("mask.saveEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
+ s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
+ for x in [0.3, 0.7] {
+ s.execute("spot.add", &json!({"points": [[x, 0.5]], "source": [0.1, 0.0]})).unwrap();
+ }
+ // A rejected command must preserve a redo branch as well as existing undo steps.
+ s.execute("mask.rename", &json!({"name": "Temporary"})).unwrap();
+ s.execute("edit.undo", &json!({})).unwrap();
+ s
+}
+
+fn target_state(s: &Session) -> serde_json::Value {
+ let photo = s.catalog.photo(s.active().unwrap()).unwrap();
+ json!({
+ "develop": photo.develop,
+ "history": photo.history,
+ "selection": s.selection,
+ "activeMask": s.active_mask,
+ "activeSpot": s.active_spot,
+ "undo": format!("{:?}", s.undo),
+ "redo": format!("{:?}", s.redo),
+ "commits": s.commits(),
+ "effects": s.mask_effects,
+ "rememberedEffects": s.mask_tool_effects,
+ "journal": s.journal,
+ "revision": s.catalog.revision,
+ })
+}
+
+fn rejects_target(s: &mut Session, command: &str, params: &serde_json::Value) {
+ let before = target_state(s);
+ let result = s.execute(command, params);
+ let after = target_state(s);
+ assert!(result.is_err(), "{command} {params} returned {result:?}; state unchanged: {}", before == after);
+ assert_eq!(after, before, "{command} {params} changed edits, selection or history on rejection");
+ for field in ["id", "component", "index"] {
+ if let Some(value) = params.get(field)
+ && !value.is_null()
+ && value.as_u64().is_none()
+ {
+ assert!(
+ result.unwrap_err().to_string().contains(&format!("`{field}`")),
+ "{command} must reject the malformed {field} before doing other work"
+ );
+ break;
+ }
+ }
+}
+
+fn malformed_targets() -> Vec {
+ vec![json!(-1), json!(0.5), json!("1"), json!(true), json!([]), json!({}), json!(u64::MAX)]
+}
+
+#[test]
+fn malformed_mask_ids_are_rejected_without_changes() {
+ let mut s = target_session();
+ let commands = [
+ ("mask.delete", json!({})),
+ ("mask.rename", json!({"name": "Wrong mask"})),
+ ("mask.invert", json!({})),
+ ("mask.visible", json!({"visible": false})),
+ ("mask.duplicate", json!({})),
+ ("mask.move", json!({"to": 0})),
+ ("mask.adjust", json!({"values": {"exposure": 1.0}})),
+ ("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
+ ("mask.addComponent", json!({"kind": "brush"})),
+ ("mask.component", json!({"component": 0, "action": "invert"})),
+ ("mask.brushStroke", json!({"points": [[0.5, 0.5]]})),
+ ("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
+ ("mask.objectPoint", json!({"x": 0.5, "y": 0.5})),
+ ("mask.refineDetail", json!({})),
+ ];
+ for (command, mut params) in commands {
+ for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
+ params["id"] = target;
+ rejects_target(&mut s, command, ¶ms);
+ }
+ }
+}
+
+#[test]
+fn malformed_mask_selection_is_rejected_without_changes() {
+ let mut s = target_session();
+ for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_297_u64)]) {
+ rejects_target(&mut s, "mask.select", &json!({"id": target}));
+ }
+}
+
+#[test]
+fn overflowing_mask_id_does_not_wrap_to_an_existing_mask() {
+ let mut s = target_session();
+ rejects_target(&mut s, "mask.delete", &json!({"id": 4_294_967_297_u64}));
+}
+
+#[test]
+fn malformed_mask_effect_targets_are_rejected_without_changes() {
+ let mut s = target_session();
+ for (command, mut params) in [
+ ("mask.saveEffect", json!({"name": "Wrong effect"})),
+ ("mask.updateEffect", json!({"name": "Existing"})),
+ ("mask.applyEffect", json!({"name": "Existing"})),
+ ] {
+ for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
+ params["id"] = target;
+ rejects_target(&mut s, command, ¶ms);
+ }
+ }
+ for target in malformed_targets().into_iter().chain([json!([1, -1]), json!([1, null]), json!([1, 4_294_967_297_u64])]) {
+ rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": target}));
+ }
+}
+
+#[test]
+fn mask_effect_targets_preserve_explicit_defaults_and_lists() {
+ let mut s = target_session();
+ s.execute("mask.saveEffect", &json!({"id": 1, "name": "Explicit"})).unwrap();
+ assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 0.0);
+ for (name, target) in [("Omitted", json!({})), ("Null", json!({"id": null}))] {
+ let mut params = target;
+ params["name"] = json!(name);
+ s.execute("mask.saveEffect", ¶ms).unwrap();
+ assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0);
+ params["name"] = json!("Existing");
+ s.execute("mask.updateEffect", ¶ms).unwrap();
+ assert_eq!(s.mask_effects[0].adjust.exposure, 1.0);
+ }
+ s.execute("mask.updateEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
+ assert_eq!(s.mask_effects[0].adjust.exposure, 0.0);
+ for mut params in [json!({}), json!({"id": null}), json!({"ids": null})] {
+ s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
+ params["name"] = json!("Existing");
+ s.execute("mask.applyEffect", ¶ms).unwrap();
+ assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].adjust.exposure, 0.0);
+ }
+ s.execute("mask.adjust", &json!({"id": 1, "values": {"exposure": 1.0}})).unwrap();
+ s.execute("mask.adjust", &json!({"id": 2, "values": {"exposure": 2.0}})).unwrap();
+ s.execute("mask.applyEffect", &json!({"name": "Existing", "ids": [1, 2]})).unwrap();
+ assert!(s.develop_of(s.active().unwrap()).unwrap().masks.iter().all(|m| m.adjust.exposure == 0.0));
+ rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": []}));
+}
+
+#[test]
+fn malformed_mask_components_are_rejected_without_changes() {
+ let mut s = target_session();
+ for (command, mut params) in [
+ ("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
+ ("mask.component", json!({"action": "invert"})),
+ ("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
+ ("mask.refineDetail", json!({})),
+ ] {
+ for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
+ params["component"] = target;
+ rejects_target(&mut s, command, ¶ms);
+ }
+ }
+}
+
+#[test]
+fn malformed_spot_indices_are_rejected_without_changes() {
+ let mut s = target_session();
+ for (command, mut params) in [("spot.delete", json!({})), ("spot.update", json!({"size": 0.1})), ("spot.refreshSource", json!({}))] {
+ for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
+ params["index"] = target;
+ rejects_target(&mut s, command, ¶ms);
+ }
+ }
+}
+
+#[test]
+fn malformed_spot_selection_is_rejected_without_changes() {
+ let mut s = target_session();
+ for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_296_u64)]) {
+ rejects_target(&mut s, "spot.select", &json!({"index": target}));
+ }
+}
+
+#[test]
+fn mask_and_spot_targets_preserve_explicit_defaults_and_selection_clearing() {
+ use lightcraft_develop::MaskShape;
+ let mut s = target_session();
+ for target in [json!({}), json!({"id": null})] {
+ let mut params = target;
+ params["name"] = json!("Selected mask");
+ s.execute("mask.rename", ¶ms).unwrap();
+ assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].name, "Selected mask");
+ }
+ s.execute("mask.rename", &json!({"id": 1, "name": "Explicit mask"})).unwrap();
+ assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[0].name, "Explicit mask");
+ assert_eq!(s.active_mask, Some(2));
+ for params in [json!({"shape": {"kind": "brush", "strokes": []}}), json!({"component": null, "shape": {"kind": "brush", "strokes": []}})] {
+ s.execute("mask.update", ¶ms).unwrap();
+ let d = s.develop_of(s.active().unwrap()).unwrap();
+ assert!(matches!(d.masks[1].components[0].shape, MaskShape::Brush { .. }));
+ assert!(matches!(d.masks[1].components[1].shape, MaskShape::ColorRange { .. }));
+ }
+ s.execute("mask.update", &json!({"id": 1, "component": 0, "shape": {"kind": "sky"}})).unwrap();
+ assert!(matches!(s.develop_of(s.active().unwrap()).unwrap().masks[0].components[0].shape, MaskShape::Sky));
+ for params in [json!({"points": [[0.2, 0.2]]}), json!({"id": null, "points": [[0.4, 0.4]]})] {
+ s.execute("mask.brushStroke", ¶ms).unwrap();
+ }
+ assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 2);
+ for params in [json!({"x": 0.5, "y": 0.5}), json!({"component": null, "x": 0.5, "y": 0.5})] {
+ s.execute("mask.sampleColor", ¶ms).unwrap();
+ let d = s.develop_of(s.active().unwrap()).unwrap();
+ let MaskShape::ColorRange { samples, .. } = &d.masks[1].components[1].shape else { panic!("color range") };
+ assert_eq!(samples.len(), 1);
+ }
+ for params in [json!({"size": 0.04}), json!({"index": null, "size": 0.04})] {
+ s.execute("spot.update", ¶ms).unwrap();
+ assert_eq!(spots(&s)[1].size, 0.04);
+ }
+ s.execute("spot.update", &json!({"index": 0, "size": 0.03})).unwrap();
+ assert_eq!(spots(&s)[0].size, 0.03);
+ assert_eq!(s.active_spot, Some(1));
+ rejects_target(&mut s, "mask.component", &json!({"component": null, "action": "delete"}));
+ for params in [json!({"id": null}), json!({})] {
+ s.execute("mask.select", &json!({"id": 1})).unwrap();
+ assert_eq!(s.active_mask, Some(1));
+ s.execute("mask.select", ¶ms).unwrap();
+ assert_eq!(s.active_mask, None);
+ }
+ for params in [json!({"index": null}), json!({})] {
+ s.execute("spot.select", &json!({"index": 0})).unwrap();
+ assert_eq!(s.active_spot, Some(0));
+ s.execute("spot.select", ¶ms).unwrap();
+ assert_eq!(s.active_spot, None);
+ }
+ rejects_target(&mut s, "mask.delete", &json!({}));
+ rejects_target(&mut s, "spot.delete", &json!({}));
+ // An omitted mask still creates a brush mask when no mask is selected.
+ s.execute("mask.brushStroke", &json!({"points": [[0.5, 0.5]]})).unwrap();
+ assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 3);
+}
diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs
index c2b5bee1e..0b1b0ae98 100644
--- a/crates/fetch/src/http.rs
+++ b/crates/fetch/src/http.rs
@@ -10,7 +10,7 @@
//! the downloading thread. No proxies (see docs/ai-masks.md).
use std::io::{ErrorKind, Read, Write};
-use std::net::{TcpStream, ToSocketAddrs};
+use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
@@ -88,6 +88,9 @@ impl Url {
let end = v6.find(']').ok_or_else(|| bad("unterminated IPv6 address"))?;
let host = v6.get(..end).unwrap_or_default();
let after = v6.get(end + 1..).unwrap_or_default();
+ if !after.is_empty() && !after.starts_with(':') {
+ return Err(bad("unexpected text after IPv6 address"));
+ }
(host.to_string(), after.strip_prefix(':'))
} else {
match authority.rsplit_once(':') {
@@ -182,27 +185,68 @@ fn tls_config() -> Result, HttpError> {
Ok(Arc::new(config))
}
+/// Resolve a host name within `deadline`, checking the cancel flag while it runs. The system resolver blocks with no
+/// timeout of its own, so it runs on a helper thread (which ends when the resolver returns); a lookup that takes too
+/// long is given up on, and Cancel works during it (issue #1532).
+fn lookup(
+ host: &str,
+ resolve: impl FnOnce() -> std::io::Result> + Send + 'static,
+ deadline: Instant,
+ limits: &Limits,
+) -> Result, HttpError> {
+ if limits.cancel.load(Ordering::Relaxed) {
+ return Err(HttpError::Cancelled);
+ }
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::Builder::new()
+ .name("lc-fetch-lookup".into())
+ .spawn(move || {
+ let _ = tx.send(resolve());
+ })
+ .map_err(|e| HttpError::Connect(format!("{host}: {e}")))?;
+ loop {
+ if limits.cancel.load(Ordering::Relaxed) {
+ return Err(HttpError::Cancelled);
+ }
+ let left = deadline.saturating_duration_since(Instant::now());
+ if left.is_zero() {
+ return Err(HttpError::Connect(format!("{host}: the name lookup timed out")));
+ }
+ match rx.recv_timeout(left.min(TICK)) {
+ Ok(r) => return r.map_err(|e| HttpError::Connect(format!("{host}: {e}"))),
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Err(HttpError::Connect(format!("{host}: the name lookup failed"))),
+ }
+ }
+}
+
+/// Connect to the first of `addrs` that answers, all of them together within `deadline` (each used to get the whole
+/// connect timeout of its own, issue #1532).
+fn connect_any(host: &str, addrs: &[SocketAddr], deadline: Instant, limits: &Limits) -> Result {
+ let mut last = format!("{host}: no address");
+ for a in addrs {
+ if limits.cancel.load(Ordering::Relaxed) {
+ return Err(HttpError::Cancelled);
+ }
+ let left = deadline.saturating_duration_since(Instant::now());
+ if left.is_zero() {
+ return Err(HttpError::Connect(format!("{host}: connecting timed out")));
+ }
+ match TcpStream::connect_timeout(a, left) {
+ Ok(s) => return Ok(s),
+ Err(e) => last = format!("{host}: {e}"),
+ }
+ }
+ Err(HttpError::Connect(last))
+}
+
impl Stream {
fn connect(url: &Url, limits: &Limits) -> Result {
- let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect();
- if addrs.is_empty() {
- return Err(HttpError::Connect(format!("{}: no address", url.host)));
- }
- let mut last = String::new();
- let mut sock = None;
- for a in addrs {
- if limits.cancel.load(Ordering::Relaxed) {
- return Err(HttpError::Cancelled);
- }
- match TcpStream::connect_timeout(&a, limits.connect) {
- Ok(s) => {
- sock = Some(s);
- break;
- }
- Err(e) => last = format!("{}: {e}", url.host),
- }
- }
- let sock = sock.ok_or(HttpError::Connect(last))?;
+ // the lookup and the connection attempts share one connect timeout
+ let deadline = Instant::now() + limits.connect;
+ let (host, port) = (url.host.clone(), url.port);
+ let addrs = lookup(&url.host, move || (host.as_str(), port).to_socket_addrs().map(Iterator::collect), deadline, limits)?;
+ let sock = connect_any(&url.host, &addrs, deadline, limits)?;
sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?;
let _ = sock.set_nodelay(true);
@@ -512,6 +556,58 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result Limits<'_> {
+ Limits { connect: Duration::from_millis(300), stall: Duration::from_secs(1), cancel }
+ }
+
+ /// A name lookup that hangs is given up on at the connect timeout, and Cancel stops it at once (issue #1532).
+ #[test]
+ fn a_hung_name_lookup_times_out_and_can_be_cancelled() {
+ let cancel = AtomicBool::new(false);
+ let slow = || {
+ std::thread::sleep(Duration::from_secs(10));
+ Ok(Vec::new())
+ };
+ let t = Instant::now();
+ let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel));
+ assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}");
+ assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed());
+ // Cancel while the lookup is waiting on the resolver, not before it starts
+ let t = Instant::now();
+ let r = std::thread::scope(|scope| {
+ scope.spawn(|| {
+ std::thread::sleep(Duration::from_millis(100));
+ cancel.store(true, Ordering::Relaxed);
+ });
+ lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel))
+ });
+ assert!(matches!(r, Err(HttpError::Cancelled)), "{r:?}");
+ assert!(t.elapsed() >= Duration::from_millis(100) && t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed());
+ // and a lookup asked for after Cancel doesn't start
+ assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled)));
+ // a lookup that answers is used
+ let cancel = AtomicBool::new(false);
+ let local: SocketAddr = "127.0.0.1:9".parse().unwrap();
+ assert_eq!(lookup("local", move || Ok(vec![local]), Instant::now() + Duration::from_secs(5), &limits(&cancel)).unwrap(), [local]);
+ }
+
+ /// Several addresses that don't answer share one deadline instead of each getting the full timeout.
+ #[test]
+ fn addresses_share_one_connect_deadline() {
+ let cancel = AtomicBool::new(false);
+ // non-routable addresses: each either hangs until the deadline or fails at once, never longer
+ let addrs: Vec = (1..=5).map(|i| format!("10.255.255.{i}:81").parse().unwrap()).collect();
+ let t = Instant::now();
+ let r = connect_any("blackhole", &addrs, Instant::now() + Duration::from_millis(400), &limits(&cancel));
+ assert!(r.is_err());
+ assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed());
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -537,6 +633,17 @@ mod tests {
}
}
+ #[test]
+ fn rejects_text_after_a_bracketed_ipv6_host() {
+ for bad in ["http://[::1]junk/x", "https://[::1]junk/x", "http://[::1]8080/x", "http://[::1]8080"] {
+ assert!(matches!(Url::parse(bad), Err(HttpError::BadUrl(_))), "{bad}");
+ }
+ let u = Url::parse("http://[::1]:8080/x").unwrap();
+ assert_eq!((u.host.as_str(), u.port), ("::1", 8080));
+ let u = Url::parse("http://[::1]/x").unwrap();
+ assert_eq!((u.host.as_str(), u.port), ("::1", 80));
+ }
+
#[test]
fn resolves_redirects() {
let u = Url::parse("https://a.example/x/y/file?q").unwrap();
diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs
index 89fe86822..de0bea5c9 100644
--- a/crates/geocode/src/lib.rs
+++ b/crates/geocode/src/lib.rs
@@ -104,7 +104,8 @@ pub struct GeoNames {
fn cell(p: LatLon) -> (i32, i32) {
// valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range
- (p.lat.floor() as i32, p.lon.floor() as i32)
+ // longitude 180 is the same meridian as -180: key it as column -180, the wrap `reverse` probes
+ (p.lat.floor() as i32, (p.lon.floor() as i32 + 180).rem_euclid(360) - 180)
}
fn short(s: &str) -> String {
@@ -253,6 +254,14 @@ mod tests {
assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X");
}
+ #[test]
+ fn city_on_the_antimeridian_is_found() {
+ let g = GeoNames::parse("1\tTaveuni\tTaveuni\t\t0.0\t180.0\tP\tPPL\tFJ\t\t01\n", None, None);
+ for lon in [179.9, -179.9, 180.0, -180.0] {
+ assert_eq!(g.reverse(LatLon::new(0.0, lon)).map(|p| p.city).as_deref(), Some("Taveuni"), "from {lon}");
+ }
+ }
+
#[test]
fn high_latitude_cities_two_cells_away() {
let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None);
diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs
index b0409962d..7efae6a57 100644
--- a/crates/gpu/src/lib.rs
+++ b/crates/gpu/src/lib.rs
@@ -75,12 +75,8 @@ fn record_fallback(reason: String) {
/// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …".
/// Never blocks.
pub fn unavailable_reason() -> Option {
- if env_disabled() {
- #[cfg(not(target_arch = "wasm32"))]
- if backend::env_off() {
- return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into());
- }
- return Some("disabled by LIGHTCRAFT_GPU=0".into());
+ if let Some(why) = env_off_reason() {
+ return Some(why.into());
}
if !ENABLED.load(Ordering::Relaxed) {
return Some("disabled by the GPU rendering preference (app.gpu)".into());
@@ -245,12 +241,26 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool {
/// environment switches and the rendering preference, but not a render-device failure (the devices are separate).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn switched_off() -> Option {
- if env_disabled() {
- return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into());
+ if let Some(why) = env_off_reason() {
+ return Some(why.into());
}
(!ENABLED.load(Ordering::Relaxed)).then(|| "disabled by the GPU rendering preference (app.gpu)".into())
}
+/// Why the environment switches the GPU off for the whole process (`LIGHTCRAFT_GPU=0` or
+/// `LIGHTCRAFT_GPU_BACKEND=off`), or `None`. Code with a GPU path of its own (the SAM 3 model on
+/// Metal) follows it too.
+pub fn env_off_reason() -> Option<&'static str> {
+ if !env_disabled() {
+ return None;
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ if backend::env_off() {
+ return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off");
+ }
+ Some("disabled by LIGHTCRAFT_GPU=0")
+}
+
/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process.
fn env_disabled() -> bool {
static OFF: std::sync::OnceLock = std::sync::OnceLock::new();
diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs
index 8292024b1..21b9cf647 100644
--- a/crates/heif/src/lib.rs
+++ b/crates/heif/src/lib.rs
@@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
})
}
+/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles
+/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
+const GRID_BUDGET: u64 = 2;
+
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
struct Coded<'a> {
frames: Vec